Initial commit of Brainy vector database v0.1.0
This commit is contained in:
commit
49480e694c
29 changed files with 10871 additions and 0 deletions
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/dist
|
||||||
|
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Coverage directory
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/*
|
||||||
|
# WebStorm files to ignore
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/usage.statistics.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/shelf
|
||||||
|
# Include other WebStorm project files
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Data directories created by FileSystemStorage
|
||||||
|
/brainy-data
|
||||||
|
/custom-data
|
||||||
11
.idea/brainy.iml
generated
Normal file
11
.idea/brainy.iml
generated
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/dist" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
7
.idea/jsLibraryMappings.xml
generated
Normal file
7
.idea/jsLibraryMappings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<includedPredefinedLibrary name="Node.js Core" />
|
||||||
|
<includedPredefinedLibrary name="ECMAScript 6" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/material_theme_project_new.xml
generated
Normal file
17
.idea/material_theme_project_new.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
|
<option name="metadata">
|
||||||
|
<MTProjectMetadataState>
|
||||||
|
<option name="migrated" value="true" />
|
||||||
|
<option name="pristineConfig" value="false" />
|
||||||
|
<option name="userId" value="-1be7adca:196364ea35f:-7ffe" />
|
||||||
|
</MTProjectMetadataState>
|
||||||
|
</option>
|
||||||
|
<option name="titleBarState">
|
||||||
|
<MTProjectTitleBarConfigState>
|
||||||
|
<option name="overrideColor" value="false" />
|
||||||
|
</MTProjectTitleBarConfigState>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/misc.xml
generated
Normal file
9
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptSettings">
|
||||||
|
<option name="languageLevel" value="ES6" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/brainy.iml" filepath="$PROJECT_DIR$/.idea/brainy.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/typescript-compiler.xml
generated
Normal file
8
.idea/typescript-compiler.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="TypeScriptCompiler">
|
||||||
|
<option name="recompileOnChanges" value="true" />
|
||||||
|
<option name="typeScriptCompilerParams" value="--sourceMap true" />
|
||||||
|
<option name="useConfig" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Sodal
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
506
README.md
Normal file
506
README.md
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
# Soulcraft Brainy
|
||||||
|
|
||||||
|
A vector database that runs in a browser or Node.js and utilizes Origin Private File System (OPFS) for storage, with HNSW (Hierarchical Navigable Small World) for efficient vector indexing.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Cross-platform**: Works in both browsers and Node.js
|
||||||
|
- **Persistent storage**: Uses Origin Private File System (OPFS) in browsers, with fallback to in-memory storage
|
||||||
|
- **Efficient vector search**: Implements HNSW (Hierarchical Navigable Small World) algorithm for fast approximate nearest neighbor search
|
||||||
|
- **Automatic embedding**: Converts text and other data to vectors using embedding models
|
||||||
|
- **TensorFlow.js integration**: Uses Universal Sentence Encoder for high-quality text embeddings
|
||||||
|
- **Metadata support**: Store and retrieve metadata alongside vectors
|
||||||
|
- **TypeScript support**: Fully typed API with generics for metadata types
|
||||||
|
- **Multiple distance functions**: Supports cosine, Euclidean, Manhattan, and dot product distance metrics
|
||||||
|
- **Augmentation system**: Extensible architecture for adding specialized capabilities
|
||||||
|
- **Graph data model**: Structured representation of entities and relationships
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install brainy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {BrainyData} from 'brainy';
|
||||||
|
|
||||||
|
// Create a new vector database
|
||||||
|
const db = new BrainyData();
|
||||||
|
await db.init();
|
||||||
|
|
||||||
|
// Add vectors with metadata
|
||||||
|
const catId = await db.add([0.2, 0.3, 0.4, 0.1], {type: 'mammal', name: 'cat'});
|
||||||
|
const dogId = await db.add([0.3, 0.2, 0.4, 0.2], {type: 'mammal', name: 'dog'});
|
||||||
|
const fishId = await db.add([0.1, 0.1, 0.8, 0.2], {type: 'fish', name: 'fish'});
|
||||||
|
|
||||||
|
// Add text directly - it will be automatically embedded
|
||||||
|
const lionDescId = await db.add("Lions are large cats with a golden mane", {type: 'mammal', name: 'lion'});
|
||||||
|
const tigerDescId = await db.add("Tigers are large cats with striped fur", {type: 'mammal', name: 'tiger'});
|
||||||
|
|
||||||
|
// Search for similar vectors
|
||||||
|
const results = await db.search([0.2, 0.3, 0.4, 0.1], 2);
|
||||||
|
console.log(results);
|
||||||
|
// [
|
||||||
|
// { id: 'cat-id', score: 0, vector: [0.2, 0.3, 0.4, 0.1], metadata: { type: 'mammal', name: 'cat' } },
|
||||||
|
// { id: 'dog-id', score: 0.1, vector: [0.3, 0.2, 0.4, 0.2], metadata: { type: 'mammal', name: 'dog' } }
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// Search with text directly - it will be automatically embedded
|
||||||
|
const catResults = await db.search("cat", 2);
|
||||||
|
console.log(catResults);
|
||||||
|
// Results will include vectors similar to the embedding of "cat"
|
||||||
|
|
||||||
|
// Get a vector by ID
|
||||||
|
const cat = await db.get(catId);
|
||||||
|
console.log(cat);
|
||||||
|
// { id: 'cat-id', vector: [0.2, 0.3, 0.4, 0.1], metadata: { type: 'mammal', name: 'cat' } }
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
await db.updateMetadata(catId, {type: 'mammal', name: 'cat', color: 'orange'});
|
||||||
|
|
||||||
|
// Delete a vector
|
||||||
|
await db.delete(fishId);
|
||||||
|
|
||||||
|
// Clear the database
|
||||||
|
await db.clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
BrainyData,
|
||||||
|
euclideanDistance,
|
||||||
|
UniversalSentenceEncoder,
|
||||||
|
createEmbeddingFunction
|
||||||
|
} from 'brainy';
|
||||||
|
|
||||||
|
// Configure the vector database
|
||||||
|
const db = new BrainyData({
|
||||||
|
// HNSW index configuration
|
||||||
|
hnsw: {
|
||||||
|
M: 16, // Max number of connections per node
|
||||||
|
efConstruction: 200, // Size of dynamic candidate list during construction
|
||||||
|
efSearch: 50, // Size of dynamic candidate list during search
|
||||||
|
ml: 16 // Max level
|
||||||
|
},
|
||||||
|
|
||||||
|
// Distance function to use (default is cosineDistance)
|
||||||
|
distanceFunction: euclideanDistance,
|
||||||
|
|
||||||
|
// Custom embedding function (optional)
|
||||||
|
// By default, it uses the Universal Sentence Encoder for high-quality text embeddings
|
||||||
|
// You can use the SimpleEmbedding for a basic character-based embedding:
|
||||||
|
// embeddingFunction: createEmbeddingFunction(new SimpleEmbedding()),
|
||||||
|
|
||||||
|
// Or create your own custom embedding function:
|
||||||
|
// embeddingFunction: async (data) => {
|
||||||
|
// // Convert data to a vector
|
||||||
|
// return [0.1, 0.2, 0.3, 0.4]; // Return a vector
|
||||||
|
// },
|
||||||
|
|
||||||
|
// Custom storage adapter (optional)
|
||||||
|
// By default, it uses OPFS in browsers, FileSystemStorage in Node.js,
|
||||||
|
// or falls back to in-memory storage if neither is available
|
||||||
|
// storageAdapter: myCustomStorageAdapter
|
||||||
|
|
||||||
|
// You can also explicitly use the FileSystemStorage with a custom directory:
|
||||||
|
// import { FileSystemStorage } from 'brainy/storage/fileSystemStorage';
|
||||||
|
// storageAdapter: new FileSystemStorage('/custom/path')
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing and Publishing
|
||||||
|
|
||||||
|
Soulcraft Brainy is an open source project released under the MIT license. Contributions are welcome!
|
||||||
|
|
||||||
|
### Contributing to the Project
|
||||||
|
|
||||||
|
To contribute to the project:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests to ensure everything works:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
### Publishing a New Version
|
||||||
|
|
||||||
|
To publish a new version to npm:
|
||||||
|
|
||||||
|
1. Update the version in package.json following semantic versioning
|
||||||
|
2. Build the package:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
3. Publish the package:
|
||||||
|
```bash
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing the Package
|
||||||
|
|
||||||
|
To install the package in your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install brainy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
|
||||||
|
## Augmentation System
|
||||||
|
|
||||||
|
Brainy includes a powerful augmentation system that allows extending its capabilities through specialized modules. Each augmentation implements a specific interface and provides additional functionality.
|
||||||
|
|
||||||
|
### Base Augmentation Interface
|
||||||
|
|
||||||
|
All augmentations implement the `IAugmentation` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IAugmentation {
|
||||||
|
readonly name: string; // Unique identifier for the augmentation
|
||||||
|
readonly description: string; // Human-readable description
|
||||||
|
initialize(): Promise<void>; // Called when Brainy starts up
|
||||||
|
shutDown(): Promise<void>; // Called when shutting down
|
||||||
|
getStatus(): Promise<'active' | 'inactive' | 'error'>; // Current status
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
Augmentations can optionally implement WebSocket support:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IWebSocketSupport {
|
||||||
|
connectWebSocket(url: string, protocols?: string | string[]): Promise<WebSocketConnection>;
|
||||||
|
sendWebSocketMessage(connectionId: string, data: unknown): Promise<void>;
|
||||||
|
onWebSocketMessage(connectionId: string, callback: DataCallback<unknown>): Promise<void>;
|
||||||
|
closeWebSocket(connectionId: string, code?: number, reason?: string): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specialized Augmentation Types
|
||||||
|
|
||||||
|
Brainy supports several specialized augmentation types:
|
||||||
|
|
||||||
|
#### Cognition Augmentations
|
||||||
|
|
||||||
|
For reasoning, inference, and logical operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ICognitionAugmentation extends IAugmentation {
|
||||||
|
reason(query: string, context?: Record<string, unknown>): AugmentationResponse<{
|
||||||
|
inference: string;
|
||||||
|
confidence: number;
|
||||||
|
}>;
|
||||||
|
infer(dataSubset: Record<string, unknown>): AugmentationResponse<Record<string, unknown>>;
|
||||||
|
executeLogic(ruleId: string, input: Record<string, unknown>): AugmentationResponse<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sense Augmentations
|
||||||
|
|
||||||
|
For processing raw, unstructured data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ISenseAugmentation extends IAugmentation {
|
||||||
|
processRawData(rawData: Buffer | string, dataType: string): AugmentationResponse<{
|
||||||
|
nouns: string[];
|
||||||
|
verbs: string[];
|
||||||
|
}>;
|
||||||
|
listenToFeed(
|
||||||
|
feedUrl: string,
|
||||||
|
callback: DataCallback<{ nouns: string[]; verbs: string[] }>
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Perception Augmentations
|
||||||
|
|
||||||
|
For interpreting and contextualizing data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IPerceptionAugmentation extends IAugmentation {
|
||||||
|
interpret(
|
||||||
|
nouns: string[],
|
||||||
|
verbs: string[],
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<Record<string, unknown>>;
|
||||||
|
organize(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
criteria?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<Record<string, unknown>>;
|
||||||
|
generateVisualization(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
visualizationType: string
|
||||||
|
): AugmentationResponse<string | Buffer | Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Activation Augmentations
|
||||||
|
|
||||||
|
For triggering actions and generating outputs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IActivationAugmentation extends IAugmentation {
|
||||||
|
triggerAction(
|
||||||
|
actionName: string,
|
||||||
|
parameters?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<unknown>;
|
||||||
|
generateOutput(knowledgeId: string, format: string): AugmentationResponse<string | Record<string, unknown>>;
|
||||||
|
interactExternal(systemId: string, payload: Record<string, unknown>): AugmentationResponse<unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dialog Augmentations
|
||||||
|
|
||||||
|
For natural language understanding and generation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IDialogAugmentation extends IAugmentation {
|
||||||
|
processUserInput(naturalLanguageQuery: string, sessionId?: string): AugmentationResponse<{
|
||||||
|
intent: string;
|
||||||
|
nouns: string[];
|
||||||
|
verbs: string[];
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
generateResponse(
|
||||||
|
interpretedInput: Record<string, unknown>,
|
||||||
|
knowledgeContext: Record<string, unknown>,
|
||||||
|
sessionId?: string
|
||||||
|
): AugmentationResponse<string>;
|
||||||
|
manageContext(sessionId: string, contextUpdate: Record<string, unknown>): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Conduit Augmentations
|
||||||
|
|
||||||
|
For establishing data exchange channels:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IConduitAugmentation extends IAugmentation {
|
||||||
|
establishConnection(
|
||||||
|
targetSystemId: string,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): AugmentationResponse<WebSocketConnection>;
|
||||||
|
readData(
|
||||||
|
query: Record<string, unknown>,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<unknown>;
|
||||||
|
writeData(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<unknown>;
|
||||||
|
monitorStream(streamId: string, callback: DataCallback<unknown>): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graph Data Model
|
||||||
|
|
||||||
|
Brainy uses a graph-based data model to represent entities and relationships. This model consists of nouns (nodes) and verbs (edges).
|
||||||
|
|
||||||
|
### Common Types
|
||||||
|
|
||||||
|
#### Timestamp
|
||||||
|
|
||||||
|
Used for tracking creation and update times:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Timestamp {
|
||||||
|
seconds: number;
|
||||||
|
nanoseconds: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CreatorMetadata
|
||||||
|
|
||||||
|
Tracks which augmentation and model created an element:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreatorMetadata {
|
||||||
|
augmentation: string; // Name of the augmentation that created this element
|
||||||
|
version: string; // Version of the augmentation
|
||||||
|
model: string; // Model identifier used in creation
|
||||||
|
modelVersion: string; // Version of the model
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Graph Elements
|
||||||
|
|
||||||
|
#### GraphNoun
|
||||||
|
|
||||||
|
Base interface for nodes (entities) in the graph:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GraphNoun {
|
||||||
|
id: string; // Unique identifier for the noun
|
||||||
|
createdBy: CreatorMetadata; // Information about what created this noun
|
||||||
|
noun: NounType; // Type classification of the noun
|
||||||
|
createdAt: Timestamp; // When the noun was created
|
||||||
|
updatedAt: Timestamp; // When the noun was last updated
|
||||||
|
data?: Record<string, unknown>; // Additional flexible data storage
|
||||||
|
embedding?: number[]; // Vector representation of the noun
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GraphVerb
|
||||||
|
|
||||||
|
Base interface for edges (relationships) in the graph:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GraphVerb {
|
||||||
|
id: string; // Unique identifier for the verb
|
||||||
|
source: string; // ID of the source noun
|
||||||
|
target: string; // ID of the target noun
|
||||||
|
label?: string; // Optional descriptive label
|
||||||
|
verb: VerbType; // Type of relationship
|
||||||
|
createdAt: Timestamp; // When the verb was created
|
||||||
|
updatedAt: Timestamp; // When the verb was last updated
|
||||||
|
data?: Record<string, unknown>; // Additional flexible data storage
|
||||||
|
embedding?: number[]; // Vector representation of the relationship
|
||||||
|
confidence?: number; // Confidence score (0-1)
|
||||||
|
weight?: number; // Strength/importance of the relationship
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Noun Types
|
||||||
|
|
||||||
|
Brainy supports the following noun types:
|
||||||
|
|
||||||
|
- **Person**: Represents a person entity
|
||||||
|
- **Place**: Represents a physical location
|
||||||
|
- **Thing**: Represents a physical or virtual object
|
||||||
|
- **Event**: Represents an event or occurrence
|
||||||
|
- **Concept**: Represents an abstract concept or idea
|
||||||
|
- **Content**: Represents content (text, media, etc.)
|
||||||
|
|
||||||
|
### Verb Types
|
||||||
|
|
||||||
|
Brainy supports the following verb types:
|
||||||
|
|
||||||
|
- **AttributedTo**: Indicates attribution or authorship
|
||||||
|
- **Controls**: Indicates control or ownership
|
||||||
|
- **Created**: Indicates creation or authorship
|
||||||
|
- **Earned**: Indicates achievement or acquisition
|
||||||
|
- **Owns**: Indicates ownership
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
The repository includes several examples to help you get started:
|
||||||
|
|
||||||
|
### Modern UI Demo
|
||||||
|
|
||||||
|
A complete web application that demonstrates all the features of Soulcraft Brainy with a modern user interface:
|
||||||
|
|
||||||
|
- Initialize the database with different distance functions
|
||||||
|
- Configure HNSW parameters
|
||||||
|
- Add sample vectors and custom vectors with metadata
|
||||||
|
- Search for similar vectors
|
||||||
|
- Get, update, and delete vectors
|
||||||
|
- View database size and clear the database
|
||||||
|
|
||||||
|
To run the Modern UI Demo:
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Build the project with `npm run build`
|
||||||
|
3. Open `examples/demo.html` in a browser
|
||||||
|
|
||||||
|
### Node.js Examples
|
||||||
|
|
||||||
|
The repository also includes TypeScript examples for Node.js:
|
||||||
|
|
||||||
|
- `src/examples/basicUsage.ts`: Demonstrates basic vector operations
|
||||||
|
- `src/examples/customStorage.ts`: Shows how to use a custom storage adapter
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### HNSW Indexing
|
||||||
|
|
||||||
|
The Hierarchical Navigable Small World (HNSW) algorithm is used for efficient approximate nearest neighbor search. It creates a multi-layered graph structure that allows for logarithmic-time search complexity.
|
||||||
|
|
||||||
|
Key features of the HNSW implementation:
|
||||||
|
|
||||||
|
- Hierarchical graph structure for efficient navigation
|
||||||
|
- Configurable parameters for tuning performance vs. accuracy
|
||||||
|
- Support for different distance metrics
|
||||||
|
|
||||||
|
### Origin Private File System (OPFS) Storage
|
||||||
|
|
||||||
|
In browser environments, the database uses the Origin Private File System (OPFS) API for persistent storage. This provides:
|
||||||
|
|
||||||
|
- Fast, local storage that persists between sessions
|
||||||
|
- Isolation from other origins for security
|
||||||
|
- Efficient file operations
|
||||||
|
|
||||||
|
In Node.js environments, the database uses a file system-based storage adapter that stores data in JSON files. This provides:
|
||||||
|
|
||||||
|
- Persistent storage between application restarts
|
||||||
|
- Efficient file operations using Node.js fs module
|
||||||
|
- Configurable storage location
|
||||||
|
|
||||||
|
In environments where neither OPFS nor Node.js file system is available, the database automatically falls back to in-memory storage.
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### BrainyData
|
||||||
|
|
||||||
|
The main class for interacting with the vector database.
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
constructor(config?: BrainyDataConfig)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
- `init(): Promise<void>` - Initialize the database
|
||||||
|
- `add(vectorOrData: Vector | any, metadata?: T, options?: { forceEmbed?: boolean }): Promise<string>` - Add a vector or data to the database
|
||||||
|
- `addBatch(items: Array<{ vectorOrData: Vector | any, metadata?: T }>, options?: { forceEmbed?: boolean }): Promise<string[]>` - Add multiple vectors or data items
|
||||||
|
- `search(queryVectorOrData: Vector | any, k?: number, options?: { forceEmbed?: boolean }): Promise<SearchResult<T>[]>` - Search for similar vectors
|
||||||
|
- `get(id: string): Promise<VectorDocument<T> | null>` - Get a vector by ID
|
||||||
|
- `delete(id: string): Promise<boolean>` - Delete a vector
|
||||||
|
- `updateMetadata(id: string, metadata: T): Promise<boolean>` - Update metadata
|
||||||
|
- `clear(): Promise<void>` - Clear the database
|
||||||
|
- `size(): number` - Get the number of vectors in the database
|
||||||
|
|
||||||
|
### Distance Functions
|
||||||
|
|
||||||
|
- `euclideanDistance(a: Vector, b: Vector): number` - Euclidean (L2) distance
|
||||||
|
- `cosineDistance(a: Vector, b: Vector): number` - Cosine distance
|
||||||
|
- `manhattanDistance(a: Vector, b: Vector): number` - Manhattan (L1) distance
|
||||||
|
- `dotProductDistance(a: Vector, b: Vector): number` - Dot product distance
|
||||||
|
|
||||||
|
### Embedding Models
|
||||||
|
|
||||||
|
- `SimpleEmbedding` - A simple character-based embedding model for text
|
||||||
|
- `UniversalSentenceEncoder` - TensorFlow Universal Sentence Encoder for high-quality text embeddings
|
||||||
|
|
||||||
|
### Embedding Functions
|
||||||
|
|
||||||
|
- `createEmbeddingFunction(model: EmbeddingModel): EmbeddingFunction` - Create an embedding function from an embedding model
|
||||||
|
- `defaultEmbeddingFunction` - Default embedding function using UniversalSentenceEncoder
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
The Soulcraft Brainy database works in all modern browsers that support the Origin Private File System API:
|
||||||
|
|
||||||
|
- Chrome 86+
|
||||||
|
- Edge 86+
|
||||||
|
- Opera 72+
|
||||||
|
- Chrome for Android 86+
|
||||||
|
|
||||||
|
For browsers without OPFS support, the database will automatically fall back to in-memory storage.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
1083
examples/demo.html
Normal file
1083
examples/demo.html
Normal file
File diff suppressed because it is too large
Load diff
5735
package-lock.json
generated
Normal file
5735
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
86
package.json
Normal file
86
package.json
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
{
|
||||||
|
"name": "brainy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A vector database using HNSW indexing with Origin Private File System storage",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "jest",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"vector-database",
|
||||||
|
"hnsw",
|
||||||
|
"opfs",
|
||||||
|
"origin-private-file-system",
|
||||||
|
"embeddings"
|
||||||
|
],
|
||||||
|
"author": "David Snelling (david@soulcraft.com)",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.3",
|
||||||
|
"@types/node": "^20.4.5",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"jest": "^29.6.2",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tensorflow-models/universal-sentence-encoder": "^1.3.3",
|
||||||
|
"@tensorflow/tfjs": "^4.22.0",
|
||||||
|
"@tensorflow/tfjs-backend-cpu": "^4.22.0",
|
||||||
|
"@tensorflow/tfjs-core": "^4.22.0",
|
||||||
|
"@tensorflow/tfjs-layers": "^4.22.0",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"useTabs": false
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"root": true,
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"args": "after-used",
|
||||||
|
"argsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
549
src/brainyData.ts
Normal file
549
src/brainyData.ts
Normal file
|
|
@ -0,0 +1,549 @@
|
||||||
|
/**
|
||||||
|
* BrainyData
|
||||||
|
* Main class that provides the vector database functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { HNSWIndex } from './hnsw/hnswIndex.js'
|
||||||
|
import { createStorage } from './storage/opfsStorage.js'
|
||||||
|
import { DistanceFunction, Edge, EmbeddingFunction, HNSWConfig, SearchResult, StorageAdapter, Vector, VectorDocument } from './coreTypes.js'
|
||||||
|
import { cosineDistance, defaultEmbeddingFunction, euclideanDistance } from './utils/index.js'
|
||||||
|
|
||||||
|
export interface BrainyDataConfig {
|
||||||
|
/**
|
||||||
|
* HNSW index configuration
|
||||||
|
*/
|
||||||
|
hnsw?: Partial<HNSWConfig>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance function to use for similarity calculations
|
||||||
|
*/
|
||||||
|
distanceFunction?: DistanceFunction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom storage adapter (if not provided, will use OPFS or memory storage)
|
||||||
|
*/
|
||||||
|
storageAdapter?: StorageAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedding function to convert data to vectors
|
||||||
|
*/
|
||||||
|
embeddingFunction?: EmbeddingFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrainyData<T = any> {
|
||||||
|
private index: HNSWIndex
|
||||||
|
private storage: StorageAdapter | null = null
|
||||||
|
private isInitialized = false
|
||||||
|
private embeddingFunction: EmbeddingFunction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new vector database
|
||||||
|
*/
|
||||||
|
constructor(config: BrainyDataConfig = {}) {
|
||||||
|
// Initialize HNSW index
|
||||||
|
this.index = new HNSWIndex(
|
||||||
|
config.hnsw,
|
||||||
|
config.distanceFunction || cosineDistance
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set storage if provided, otherwise it will be initialized in init()
|
||||||
|
this.storage = config.storageAdapter || null
|
||||||
|
|
||||||
|
// Set embedding function if provided, otherwise use default
|
||||||
|
this.embeddingFunction = config.embeddingFunction || defaultEmbeddingFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database
|
||||||
|
* Loads existing data from storage if available
|
||||||
|
*/
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize storage if not provided in constructor
|
||||||
|
if (!this.storage) {
|
||||||
|
this.storage = await createStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
await this.storage!.init()
|
||||||
|
|
||||||
|
// Load all nodes from storage
|
||||||
|
const nodes = await this.storage!.getAllNodes()
|
||||||
|
|
||||||
|
// Clear the index and add all nodes
|
||||||
|
this.index.clear()
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Add to index
|
||||||
|
this.index.addItem({
|
||||||
|
id: node.id,
|
||||||
|
vector: node.vector
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isInitialized = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize vector database:', error)
|
||||||
|
throw new Error(`Failed to initialize vector database: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a vector or data to the database
|
||||||
|
* If the input is not a vector, it will be converted using the embedding function
|
||||||
|
* @param vectorOrData Vector or data to add
|
||||||
|
* @param metadata Optional metadata to associate with the vector
|
||||||
|
* @param options Additional options
|
||||||
|
* @returns The ID of the added vector
|
||||||
|
*/
|
||||||
|
public async add(
|
||||||
|
vectorOrData: Vector | any,
|
||||||
|
metadata?: T,
|
||||||
|
options: {
|
||||||
|
forceEmbed?: boolean // Force using the embedding function even if input is a vector
|
||||||
|
} = {}
|
||||||
|
): Promise<string> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let vector: Vector
|
||||||
|
|
||||||
|
// Check if input is already a vector
|
||||||
|
if (
|
||||||
|
Array.isArray(vectorOrData) &&
|
||||||
|
vectorOrData.every((item) => typeof item === 'number') &&
|
||||||
|
!options.forceEmbed
|
||||||
|
) {
|
||||||
|
// Input is already a vector
|
||||||
|
vector = vectorOrData
|
||||||
|
} else {
|
||||||
|
// Input needs to be vectorized
|
||||||
|
try {
|
||||||
|
vector = await this.embeddingFunction(vectorOrData)
|
||||||
|
} catch (embedError) {
|
||||||
|
throw new Error(`Failed to vectorize data: ${embedError}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vector is defined
|
||||||
|
if (!vector) {
|
||||||
|
throw new Error('Vector is undefined or null')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ID if isn't provided
|
||||||
|
const id = uuidv4()
|
||||||
|
|
||||||
|
// Add to index
|
||||||
|
this.index.addItem({ id, vector })
|
||||||
|
|
||||||
|
// Get the node from the index
|
||||||
|
const node = this.index.getNodes().get(id)
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(`Failed to retrieve newly created node with ID ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save node to storage
|
||||||
|
await this.storage!.saveNode(node)
|
||||||
|
|
||||||
|
// Save metadata if provided
|
||||||
|
if (metadata !== undefined) {
|
||||||
|
await this.storage!.saveMetadata(id, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add vector:', error)
|
||||||
|
throw new Error(`Failed to add vector: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple vectors or data items to the database
|
||||||
|
* @param items Array of items to add
|
||||||
|
* @param options Additional options
|
||||||
|
* @returns Array of IDs for the added items
|
||||||
|
*/
|
||||||
|
public async addBatch(
|
||||||
|
items: Array<{
|
||||||
|
vectorOrData: Vector | any;
|
||||||
|
metadata?: T
|
||||||
|
}>,
|
||||||
|
options: {
|
||||||
|
forceEmbed?: boolean // Force using the embedding function even if input is a vector
|
||||||
|
} = {}
|
||||||
|
): Promise<string[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
const ids: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
const id = await this.add(item.vectorOrData, item.metadata, options)
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add batch of items:', error)
|
||||||
|
throw new Error(`Failed to add batch of items: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for similar vectors
|
||||||
|
* @param queryVectorOrData Query vector or data to search for
|
||||||
|
* @param k Number of results to return
|
||||||
|
* @param options Additional options
|
||||||
|
* @returns Array of search results
|
||||||
|
*/
|
||||||
|
public async search(
|
||||||
|
queryVectorOrData: Vector | any,
|
||||||
|
k: number = 10,
|
||||||
|
options: {
|
||||||
|
forceEmbed?: boolean // Force using the embedding function even if input is a vector
|
||||||
|
} = {}
|
||||||
|
): Promise<SearchResult<T>[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let queryVector: Vector
|
||||||
|
|
||||||
|
// Check if input is already a vector
|
||||||
|
if (
|
||||||
|
Array.isArray(queryVectorOrData) &&
|
||||||
|
queryVectorOrData.every((item) => typeof item === 'number') &&
|
||||||
|
!options.forceEmbed
|
||||||
|
) {
|
||||||
|
// Input is already a vector
|
||||||
|
queryVector = queryVectorOrData
|
||||||
|
} else {
|
||||||
|
// Input needs to be vectorized
|
||||||
|
try {
|
||||||
|
queryVector = await this.embeddingFunction(queryVectorOrData)
|
||||||
|
} catch (embedError) {
|
||||||
|
throw new Error(`Failed to vectorize query data: ${embedError}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if query vector is defined
|
||||||
|
if (!queryVector) {
|
||||||
|
throw new Error('Query vector is undefined or null')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in the index
|
||||||
|
const results = this.index.search(queryVector, k)
|
||||||
|
|
||||||
|
// Get metadata for each result
|
||||||
|
const searchResults: SearchResult<T>[] = []
|
||||||
|
|
||||||
|
for (const [id, score] of results) {
|
||||||
|
const node = this.index.getNodes().get(id)
|
||||||
|
if (!node) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await this.storage!.getMetadata(id)
|
||||||
|
|
||||||
|
searchResults.push({
|
||||||
|
id,
|
||||||
|
score,
|
||||||
|
vector: node.vector,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResults
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to search vectors:', error)
|
||||||
|
throw new Error(`Failed to search vectors: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a vector by ID
|
||||||
|
*/
|
||||||
|
public async get(id: string): Promise<VectorDocument<T> | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get node from index
|
||||||
|
const node = this.index.getNodes().get(id)
|
||||||
|
if (!node) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata
|
||||||
|
const metadata = await this.storage!.getMetadata(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
vector: node.vector,
|
||||||
|
metadata
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get vector ${id}:`, error)
|
||||||
|
throw new Error(`Failed to get vector ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a vector by ID
|
||||||
|
*/
|
||||||
|
public async delete(id: string): Promise<boolean> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove from index
|
||||||
|
const removed = this.index.removeItem(id)
|
||||||
|
if (!removed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from storage
|
||||||
|
await this.storage!.deleteNode(id)
|
||||||
|
|
||||||
|
// Try to remove metadata (ignore errors)
|
||||||
|
try {
|
||||||
|
await this.storage!.saveMetadata(id, null)
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete vector ${id}:`, error)
|
||||||
|
throw new Error(`Failed to delete vector ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update metadata for a vector
|
||||||
|
*/
|
||||||
|
public async updateMetadata(id: string, metadata: T): Promise<boolean> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if a vector exists
|
||||||
|
const node = this.index.getNodes().get(id)
|
||||||
|
if (!node) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
await this.storage!.saveMetadata(id, metadata)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update metadata for vector ${id}:`, error)
|
||||||
|
throw new Error(`Failed to update metadata for vector ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an edge between two nodes
|
||||||
|
*/
|
||||||
|
public async addEdge(
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
vector?: Vector,
|
||||||
|
options: {
|
||||||
|
type?: string
|
||||||
|
weight?: number
|
||||||
|
metadata?: any
|
||||||
|
} = {}
|
||||||
|
): Promise<string> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if source and target nodes exist
|
||||||
|
const sourceNode = this.index.getNodes().get(sourceId)
|
||||||
|
const targetNode = this.index.getNodes().get(targetId)
|
||||||
|
|
||||||
|
if (!sourceNode) {
|
||||||
|
throw new Error(`Source node with ID ${sourceId} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetNode) {
|
||||||
|
throw new Error(`Target node with ID ${targetId} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ID for the edge
|
||||||
|
const id = uuidv4()
|
||||||
|
|
||||||
|
// Use a provided vector or average of source and target vectors
|
||||||
|
const edgeVector =
|
||||||
|
vector ||
|
||||||
|
sourceNode.vector.map((val, i) => (val + targetNode.vector[i]) / 2)
|
||||||
|
|
||||||
|
// Create edge
|
||||||
|
const edge: Edge = {
|
||||||
|
id,
|
||||||
|
vector: edgeVector,
|
||||||
|
connections: new Map(),
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
type: options.type,
|
||||||
|
weight: options.weight,
|
||||||
|
metadata: options.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to index
|
||||||
|
this.index.addItem({ id, vector: edgeVector })
|
||||||
|
|
||||||
|
// Get the node from the index
|
||||||
|
const indexNode = this.index.getNodes().get(id)
|
||||||
|
|
||||||
|
if (!indexNode) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to retrieve newly created edge node with ID ${id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update edge connections from index
|
||||||
|
edge.connections = indexNode.connections
|
||||||
|
|
||||||
|
// Save edge to storage
|
||||||
|
await this.storage!.saveEdge(edge)
|
||||||
|
|
||||||
|
return id
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add edge:', error)
|
||||||
|
throw new Error(`Failed to add edge: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an edge by ID
|
||||||
|
*/
|
||||||
|
public async getEdge(id: string): Promise<Edge | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.storage!.getEdge(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edge ${id}:`, error)
|
||||||
|
throw new Error(`Failed to get edge ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all edges
|
||||||
|
*/
|
||||||
|
public async getAllEdges(): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.storage!.getAllEdges()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get all edges:', error)
|
||||||
|
throw new Error(`Failed to get all edges: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by source node ID
|
||||||
|
*/
|
||||||
|
public async getEdgesBySource(sourceId: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.storage!.getEdgesBySource(sourceId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by source ${sourceId}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by source ${sourceId}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by target node ID
|
||||||
|
*/
|
||||||
|
public async getEdgesByTarget(targetId: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.storage!.getEdgesByTarget(targetId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by target ${targetId}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by target ${targetId}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by type
|
||||||
|
*/
|
||||||
|
public async getEdgesByType(type: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.storage!.getEdgesByType(type)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by type ${type}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by type ${type}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an edge
|
||||||
|
*/
|
||||||
|
public async deleteEdge(id: string): Promise<boolean> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove from index
|
||||||
|
const removed = this.index.removeItem(id)
|
||||||
|
if (!removed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from storage
|
||||||
|
await this.storage!.deleteEdge(id)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete edge ${id}:`, error)
|
||||||
|
throw new Error(`Failed to delete edge ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the database
|
||||||
|
*/
|
||||||
|
public async clear(): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear index
|
||||||
|
this.index.clear()
|
||||||
|
|
||||||
|
// Clear storage
|
||||||
|
await this.storage!.clear()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear vector database:', error)
|
||||||
|
throw new Error(`Failed to clear vector database: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of vectors in the database
|
||||||
|
*/
|
||||||
|
public size(): number {
|
||||||
|
return this.index.size()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the database is initialized
|
||||||
|
*/
|
||||||
|
private async ensureInitialized(): Promise<void> {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export distance functions for convenience
|
||||||
|
export { euclideanDistance, cosineDistance, manhattanDistance, dotProductDistance } from './utils/index.js'
|
||||||
123
src/coreTypes.ts
Normal file
123
src/coreTypes.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for the Soulcraft Brainy
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vector representation - an array of numbers
|
||||||
|
*/
|
||||||
|
export type Vector = number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A document with a vector embedding and optional metadata
|
||||||
|
*/
|
||||||
|
export interface VectorDocument<T = any> {
|
||||||
|
id: string;
|
||||||
|
vector: Vector;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search result with similarity score
|
||||||
|
*/
|
||||||
|
export interface SearchResult<T = any> {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
vector: Vector;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance function for comparing vectors
|
||||||
|
*/
|
||||||
|
export type DistanceFunction = (a: Vector, b: Vector) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedding function for converting data to vectors
|
||||||
|
*/
|
||||||
|
export type EmbeddingFunction = (data: any) => Promise<Vector>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedding model interface
|
||||||
|
*/
|
||||||
|
export interface EmbeddingModel {
|
||||||
|
/**
|
||||||
|
* Initialize the embedding model
|
||||||
|
*/
|
||||||
|
init(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed data into a vector
|
||||||
|
*/
|
||||||
|
embed(data: any): Promise<Vector>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the model resources
|
||||||
|
*/
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HNSW graph node
|
||||||
|
*/
|
||||||
|
export interface HNSWNode {
|
||||||
|
id: string;
|
||||||
|
vector: Vector;
|
||||||
|
connections: Map<number, Set<string>>; // level -> set of connected node ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge representing a relationship between nodes
|
||||||
|
* Extends HNSWNode to allow edges to be first-class entities in the data model
|
||||||
|
*/
|
||||||
|
export interface Edge extends HNSWNode {
|
||||||
|
sourceId: string; // ID of the source node
|
||||||
|
targetId: string; // ID of the target node
|
||||||
|
type?: string; // Optional type of the relationship
|
||||||
|
weight?: number; // Optional weight of the relationship
|
||||||
|
metadata?: any; // Optional metadata for the edge
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HNSW index configuration
|
||||||
|
*/
|
||||||
|
export interface HNSWConfig {
|
||||||
|
M: number; // Maximum number of connections per node
|
||||||
|
efConstruction: number; // Size of the dynamic candidate list during construction
|
||||||
|
efSearch: number; // Size of the dynamic candidate list during search
|
||||||
|
ml: number; // Maximum level
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage interface for persistence
|
||||||
|
*/
|
||||||
|
export interface StorageAdapter {
|
||||||
|
init(): Promise<void>;
|
||||||
|
|
||||||
|
saveNode(node: HNSWNode): Promise<void>;
|
||||||
|
|
||||||
|
getNode(id: string): Promise<HNSWNode | null>;
|
||||||
|
|
||||||
|
getAllNodes(): Promise<HNSWNode[]>;
|
||||||
|
|
||||||
|
deleteNode(id: string): Promise<void>;
|
||||||
|
|
||||||
|
saveEdge(edge: Edge): Promise<void>;
|
||||||
|
|
||||||
|
getEdge(id: string): Promise<Edge | null>;
|
||||||
|
|
||||||
|
getAllEdges(): Promise<Edge[]>;
|
||||||
|
|
||||||
|
getEdgesBySource(sourceId: string): Promise<Edge[]>;
|
||||||
|
|
||||||
|
getEdgesByTarget(targetId: string): Promise<Edge[]>;
|
||||||
|
|
||||||
|
getEdgesByType(type: string): Promise<Edge[]>;
|
||||||
|
|
||||||
|
deleteEdge(id: string): Promise<void>;
|
||||||
|
|
||||||
|
saveMetadata(id: string, metadata: any): Promise<void>;
|
||||||
|
|
||||||
|
getMetadata(id: string): Promise<any | null>;
|
||||||
|
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
163
src/examples/basicUsage.ts
Normal file
163
src/examples/basicUsage.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* Basic usage example for the Soulcraft Brainy database
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrainyData } from '../brainyData.js'
|
||||||
|
|
||||||
|
// Example data - word embeddings
|
||||||
|
const wordEmbeddings = {
|
||||||
|
cat: [0.2, 0.3, 0.4, 0.1],
|
||||||
|
dog: [0.3, 0.2, 0.4, 0.2],
|
||||||
|
fish: [0.1, 0.1, 0.8, 0.2],
|
||||||
|
bird: [0.1, 0.4, 0.2, 0.5],
|
||||||
|
tiger: [0.3, 0.4, 0.3, 0.1],
|
||||||
|
lion: [0.4, 0.3, 0.2, 0.1],
|
||||||
|
shark: [0.2, 0.1, 0.7, 0.3],
|
||||||
|
eagle: [0.2, 0.5, 0.1, 0.4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example metadata
|
||||||
|
const metadata = {
|
||||||
|
cat: { type: 'mammal', domesticated: true },
|
||||||
|
dog: { type: 'mammal', domesticated: true },
|
||||||
|
fish: { type: 'fish', domesticated: false },
|
||||||
|
bird: { type: 'bird', domesticated: false },
|
||||||
|
tiger: { type: 'mammal', domesticated: false },
|
||||||
|
lion: { type: 'mammal', domesticated: false },
|
||||||
|
shark: { type: 'fish', domesticated: false },
|
||||||
|
eagle: { type: 'bird', domesticated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the example
|
||||||
|
*/
|
||||||
|
async function runExample() {
|
||||||
|
console.log('Initializing vector database...')
|
||||||
|
|
||||||
|
// Create a new vector database
|
||||||
|
const db = new BrainyData()
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
console.log('Adding vectors to the database...')
|
||||||
|
|
||||||
|
// Add vectors to the database
|
||||||
|
const ids: Record<string, string> = {}
|
||||||
|
const metadata: Record<string, { type: string; domesticated: boolean }> = {
|
||||||
|
cat: { type: 'mammal', domesticated: true },
|
||||||
|
dog: { type: 'mammal', domesticated: true },
|
||||||
|
fish: { type: 'fish', domesticated: false },
|
||||||
|
bird: { type: 'bird', domesticated: false },
|
||||||
|
tiger: { type: 'mammal', domesticated: false },
|
||||||
|
lion: { type: 'mammal', domesticated: false },
|
||||||
|
shark: { type: 'fish', domesticated: false },
|
||||||
|
eagle: { type: 'bird', domesticated: false }
|
||||||
|
}
|
||||||
|
for (const [word, vector] of Object.entries(wordEmbeddings)) {
|
||||||
|
ids[word] = await db.add(vector, metadata[word])
|
||||||
|
|
||||||
|
console.log(`Added "${word}" with ID: ${ids[word]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDatabase size:', db.size())
|
||||||
|
|
||||||
|
// Search for similar vectors
|
||||||
|
console.log('\nSearching for vectors similar to "cat"...')
|
||||||
|
const catResults = await db.search(wordEmbeddings['cat'], 3)
|
||||||
|
console.log('Results:')
|
||||||
|
for (const result of catResults) {
|
||||||
|
const word =
|
||||||
|
Object.entries(ids).find(([_, id]) => id === result.id)?.[0] || 'unknown'
|
||||||
|
console.log(
|
||||||
|
`- ${word} (score: ${result.score.toFixed(4)}, metadata:`,
|
||||||
|
result.metadata,
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for similar vectors
|
||||||
|
console.log('\nSearching for vectors similar to "fish"...')
|
||||||
|
const fishResults = await db.search(wordEmbeddings['fish'], 3)
|
||||||
|
console.log('Results:')
|
||||||
|
for (const result of fishResults) {
|
||||||
|
const word =
|
||||||
|
Object.entries(ids).find(([_, id]) => id === result.id)?.[0] || 'unknown'
|
||||||
|
console.log(
|
||||||
|
`- ${word} (score: ${result.score.toFixed(4)}, metadata:`,
|
||||||
|
result.metadata,
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
console.log('\nUpdating metadata for "bird"...')
|
||||||
|
await db.updateMetadata(ids['bird'], {
|
||||||
|
...metadata['bird'],
|
||||||
|
notes: 'Can fly'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the updated document
|
||||||
|
const birdDoc = await db.get(ids['bird'])
|
||||||
|
console.log('Updated bird document:', birdDoc)
|
||||||
|
|
||||||
|
// Delete a vector
|
||||||
|
console.log('\nDeleting "shark"...')
|
||||||
|
await db.delete(ids['shark'])
|
||||||
|
console.log('Database size after deletion:', db.size())
|
||||||
|
|
||||||
|
// Search again to verify shark is gone
|
||||||
|
console.log('\nSearching for vectors similar to "fish" after deletion...')
|
||||||
|
const fishResultsAfterDeletion = await db.search(wordEmbeddings['fish'], 3)
|
||||||
|
console.log('Results:')
|
||||||
|
for (const result of fishResultsAfterDeletion) {
|
||||||
|
const word =
|
||||||
|
Object.entries(ids).find(([_, id]) => id === result.id)?.[0] || 'unknown'
|
||||||
|
console.log(
|
||||||
|
`- ${word} (score: ${result.score.toFixed(4)}, metadata:`,
|
||||||
|
result.metadata,
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nExample completed successfully!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a browser or Node.js environment
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Browser environment
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.textContent = 'Run BrainyData Example'
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const output = document.createElement('pre')
|
||||||
|
document.body.appendChild(output)
|
||||||
|
|
||||||
|
// Redirect console.log to the output element
|
||||||
|
const originalLog = console.log
|
||||||
|
console.log = (...args) => {
|
||||||
|
originalLog(...args)
|
||||||
|
output.textContent +=
|
||||||
|
args
|
||||||
|
.map((arg) =>
|
||||||
|
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
|
||||||
|
)
|
||||||
|
.join(' ') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runExample()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error running example:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore console.log
|
||||||
|
console.log = originalLog
|
||||||
|
})
|
||||||
|
|
||||||
|
document.body.appendChild(button)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Node.js environment
|
||||||
|
runExample().catch((error) => {
|
||||||
|
console.error('Error running example:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
76
src/examples/customStorage.ts
Normal file
76
src/examples/customStorage.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Example demonstrating how to use the FileSystemStorage adapter with a custom directory
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrainyData } from '../brainyData.js'
|
||||||
|
import { FileSystemStorage } from '../storage/fileSystemStorage.js'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// Example data - word embeddings
|
||||||
|
const wordEmbeddings = {
|
||||||
|
'cat': [0.2, 0.3, 0.4, 0.1],
|
||||||
|
'dog': [0.3, 0.2, 0.4, 0.2],
|
||||||
|
'fish': [0.1, 0.1, 0.8, 0.2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example metadata
|
||||||
|
const metadata: {
|
||||||
|
[key: string]: { type: string; [key]: boolean } | undefined | null
|
||||||
|
} = {
|
||||||
|
'cat': { type: 'mammal', domesticated: true },
|
||||||
|
'dog': { type: 'mammal', domesticated: true },
|
||||||
|
'fish': { type: 'fish', domesticated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the example
|
||||||
|
*/
|
||||||
|
async function runExample() {
|
||||||
|
console.log('Initializing vector database with custom storage location...')
|
||||||
|
|
||||||
|
// Create a custom storage adapter with a specific directory
|
||||||
|
// This will store data in ./custom-data directory relative to the current working directory
|
||||||
|
const customStoragePath = path.join(process.cwd(), 'custom-data')
|
||||||
|
const storageAdapter = new FileSystemStorage(customStoragePath)
|
||||||
|
|
||||||
|
// Create a new vector database with the custom storage adapter
|
||||||
|
const db = new BrainyData({
|
||||||
|
storageAdapter
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
console.log(`Using custom storage location: ${customStoragePath}`)
|
||||||
|
console.log('Adding vectors to the database...')
|
||||||
|
|
||||||
|
// Add vectors to the database
|
||||||
|
const ids: Record<string, string> = {}
|
||||||
|
for (const [word, vector] of Object.entries(wordEmbeddings)) {
|
||||||
|
ids[word] = await db.add(vector, metadata[word])
|
||||||
|
console.log(`Added "${word}" with ID: ${ids[word]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDatabase size:', db.size())
|
||||||
|
|
||||||
|
// Search for similar vectors
|
||||||
|
console.log('\nSearching for vectors similar to "cat"...')
|
||||||
|
const catResults = await db.search(wordEmbeddings['cat'], 2)
|
||||||
|
console.log('Results:')
|
||||||
|
for (const result of catResults) {
|
||||||
|
const word = Object.entries(ids).find(([_, id]) => id === result.id)?.[0] || 'unknown'
|
||||||
|
console.log(`- ${word} (score: ${result.score.toFixed(4)}, metadata:`, result.metadata, ')')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nExample completed successfully!')
|
||||||
|
console.log(`Data has been stored in: ${customStoragePath}`)
|
||||||
|
console.log('You can restart this example to verify that data persists between runs.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run in Node.js environment
|
||||||
|
if (typeof process !== 'undefined' && process.versions && process.versions.node) {
|
||||||
|
runExample().catch(error => {
|
||||||
|
console.error('Error running example:', error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.error('This example is designed to run in Node.js environments only.')
|
||||||
|
}
|
||||||
527
src/hnsw/hnswIndex.ts
Normal file
527
src/hnsw/hnswIndex.ts
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
/**
|
||||||
|
* HNSW (Hierarchical Navigable Small World) Index implementation
|
||||||
|
* Based on the paper: "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DistanceFunction, HNSWConfig, HNSWNode, Vector, VectorDocument } from '../coreTypes.js'
|
||||||
|
import { euclideanDistance } from '../utils/index.js'
|
||||||
|
|
||||||
|
// Default HNSW parameters
|
||||||
|
const DEFAULT_CONFIG: HNSWConfig = {
|
||||||
|
M: 16, // Max number of connections per node
|
||||||
|
efConstruction: 200, // Size of a dynamic candidate list during construction
|
||||||
|
efSearch: 50, // Size of a dynamic candidate list during search
|
||||||
|
ml: 16 // Max level
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HNSWIndex {
|
||||||
|
private nodes: Map<string, HNSWNode> = new Map()
|
||||||
|
private entryPointId: string | null = null
|
||||||
|
private maxLevel = 0
|
||||||
|
private config: HNSWConfig
|
||||||
|
private distanceFunction: DistanceFunction
|
||||||
|
private dimension: number | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: Partial<HNSWConfig> = {},
|
||||||
|
distanceFunction: DistanceFunction = euclideanDistance
|
||||||
|
) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
this.distanceFunction = distanceFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a vector to the index
|
||||||
|
*/
|
||||||
|
public addItem(item: VectorDocument): string {
|
||||||
|
// Check if item is defined
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('Item is undefined or null')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, vector } = item
|
||||||
|
|
||||||
|
// Check if vector is defined
|
||||||
|
if (!vector) {
|
||||||
|
throw new Error('Vector is undefined or null')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dimension on first insert
|
||||||
|
if (this.dimension === null) {
|
||||||
|
this.dimension = vector.length
|
||||||
|
} else if (vector.length !== this.dimension) {
|
||||||
|
throw new Error(
|
||||||
|
`Vector dimension mismatch: expected ${this.dimension}, got ${vector.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random level for this node
|
||||||
|
const nodeLevel = this.getRandomLevel()
|
||||||
|
|
||||||
|
// Create new node
|
||||||
|
const node: HNSWNode = {
|
||||||
|
id,
|
||||||
|
vector,
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize empty connection sets for each level
|
||||||
|
for (let level = 0; level <= nodeLevel; level++) {
|
||||||
|
node.connections.set(level, new Set<string>())
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the first node, make it the entry point
|
||||||
|
if (this.nodes.size === 0) {
|
||||||
|
this.entryPointId = id
|
||||||
|
this.maxLevel = nodeLevel
|
||||||
|
this.nodes.set(id, node)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find entry point
|
||||||
|
if (!this.entryPointId) {
|
||||||
|
console.error('Entry point ID is null')
|
||||||
|
// If there's no entry point, this is the first node, so we should have returned earlier
|
||||||
|
// This is a safety check
|
||||||
|
this.entryPointId = id
|
||||||
|
this.maxLevel = nodeLevel
|
||||||
|
this.nodes.set(id, node)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPoint = this.nodes.get(this.entryPointId)
|
||||||
|
if (!entryPoint) {
|
||||||
|
console.error(`Entry point with ID ${this.entryPointId} not found`)
|
||||||
|
// If the entry point doesn't exist, treat this as the first node
|
||||||
|
this.entryPointId = id
|
||||||
|
this.maxLevel = nodeLevel
|
||||||
|
this.nodes.set(id, node)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
let currObj = entryPoint
|
||||||
|
let currDist = this.distanceFunction(vector, entryPoint.vector)
|
||||||
|
|
||||||
|
// Traverse the graph from top to bottom to find the closest node
|
||||||
|
for (let level = this.maxLevel; level > nodeLevel; level--) {
|
||||||
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
changed = false
|
||||||
|
|
||||||
|
// Check all neighbors at current level
|
||||||
|
const connections = currObj.connections.get(level) || new Set<string>()
|
||||||
|
|
||||||
|
for (const neighborId of connections) {
|
||||||
|
const neighbor = this.nodes.get(neighborId)
|
||||||
|
if (!neighbor) {
|
||||||
|
console.error(`Neighbor with ID ${neighborId} not found in addItem traversal`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const distToNeighbor = this.distanceFunction(vector, neighbor.vector)
|
||||||
|
|
||||||
|
if (distToNeighbor < currDist) {
|
||||||
|
currDist = distToNeighbor
|
||||||
|
currObj = neighbor
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each level from nodeLevel down to 0
|
||||||
|
for (let level = Math.min(nodeLevel, this.maxLevel); level >= 0; level--) {
|
||||||
|
// Find ef nearest elements using greedy search
|
||||||
|
const nearestNodes = this.searchLayer(
|
||||||
|
vector,
|
||||||
|
currObj,
|
||||||
|
this.config.efConstruction,
|
||||||
|
level
|
||||||
|
)
|
||||||
|
|
||||||
|
// Select M nearest neighbors
|
||||||
|
const neighbors = this.selectNeighbors(
|
||||||
|
vector,
|
||||||
|
nearestNodes,
|
||||||
|
this.config.M
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add bidirectional connections
|
||||||
|
for (const [neighborId, _] of neighbors) {
|
||||||
|
const neighbor = this.nodes.get(neighborId)
|
||||||
|
if (!neighbor) {
|
||||||
|
console.error(`Neighbor with ID ${neighborId} not found`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
node.connections.get(level)!.add(neighborId)
|
||||||
|
|
||||||
|
// Add reverse connection
|
||||||
|
if (!neighbor.connections.has(level)) {
|
||||||
|
neighbor.connections.set(level, new Set<string>())
|
||||||
|
}
|
||||||
|
neighbor.connections.get(level)!.add(id)
|
||||||
|
|
||||||
|
// Ensure neighbor doesn't have too many connections
|
||||||
|
if (neighbor.connections.get(level)!.size > this.config.M) {
|
||||||
|
this.pruneConnections(neighbor, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update entry point for the next level
|
||||||
|
if (nearestNodes.size > 0) {
|
||||||
|
const [nearestId, nearestDist] = [...nearestNodes][0]
|
||||||
|
if (nearestDist < currDist) {
|
||||||
|
currDist = nearestDist
|
||||||
|
const nearestNode = this.nodes.get(nearestId)
|
||||||
|
if (!nearestNode) {
|
||||||
|
console.error(`Nearest node with ID ${nearestId} not found in addItem`)
|
||||||
|
// Keep the current object as is
|
||||||
|
} else {
|
||||||
|
currObj = nearestNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update max level and entry point if needed
|
||||||
|
if (nodeLevel > this.maxLevel) {
|
||||||
|
this.maxLevel = nodeLevel
|
||||||
|
this.entryPointId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add node to the index
|
||||||
|
this.nodes.set(id, node)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for nearest neighbors
|
||||||
|
*/
|
||||||
|
public search(queryVector: Vector, k: number = 10): Array<[string, number]> {
|
||||||
|
if (this.nodes.size === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if query vector is defined
|
||||||
|
if (!queryVector) {
|
||||||
|
throw new Error('Query vector is undefined or null')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dimension !== null && queryVector.length !== this.dimension) {
|
||||||
|
throw new Error(
|
||||||
|
`Query vector dimension mismatch: expected ${this.dimension}, got ${queryVector.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start from the entry point
|
||||||
|
if (!this.entryPointId) {
|
||||||
|
console.error('Entry point ID is null')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPoint = this.nodes.get(this.entryPointId)
|
||||||
|
if (!entryPoint) {
|
||||||
|
console.error(`Entry point with ID ${this.entryPointId} not found`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let currObj = entryPoint
|
||||||
|
let currDist = this.distanceFunction(queryVector, currObj.vector)
|
||||||
|
|
||||||
|
// Traverse the graph from top to bottom to find the closest node
|
||||||
|
for (let level = this.maxLevel; level > 0; level--) {
|
||||||
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
changed = false
|
||||||
|
|
||||||
|
// Check all neighbors at current level
|
||||||
|
const connections = currObj.connections.get(level) || new Set<string>()
|
||||||
|
|
||||||
|
for (const neighborId of connections) {
|
||||||
|
const neighbor = this.nodes.get(neighborId)
|
||||||
|
if (!neighbor) {
|
||||||
|
console.error(`Neighbor with ID ${neighborId} not found in search`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const distToNeighbor = this.distanceFunction(
|
||||||
|
queryVector,
|
||||||
|
neighbor.vector
|
||||||
|
)
|
||||||
|
|
||||||
|
if (distToNeighbor < currDist) {
|
||||||
|
currDist = distToNeighbor
|
||||||
|
currObj = neighbor
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search at level 0 with ef = k
|
||||||
|
const nearestNodes = this.searchLayer(
|
||||||
|
queryVector,
|
||||||
|
currObj,
|
||||||
|
Math.max(this.config.efSearch, k),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert to array and sort by distance
|
||||||
|
return [...nearestNodes].slice(0, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item from the index
|
||||||
|
*/
|
||||||
|
public removeItem(id: string): boolean {
|
||||||
|
if (!this.nodes.has(id)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.nodes.get(id)!
|
||||||
|
|
||||||
|
// Remove connections to this node from all neighbors
|
||||||
|
for (const [level, connections] of node.connections.entries()) {
|
||||||
|
for (const neighborId of connections) {
|
||||||
|
const neighbor = this.nodes.get(neighborId)
|
||||||
|
if (!neighbor) {
|
||||||
|
console.error(`Neighbor with ID ${neighborId} not found in removeItem`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (neighbor.connections.has(level)) {
|
||||||
|
neighbor.connections.get(level)!.delete(id)
|
||||||
|
|
||||||
|
// Prune connections after removing this node to ensure consistency
|
||||||
|
this.pruneConnections(neighbor, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check all other nodes for references to this node and remove them
|
||||||
|
for (const [nodeId, otherNode] of this.nodes.entries()) {
|
||||||
|
if (nodeId === id) continue // Skip the node being removed
|
||||||
|
|
||||||
|
for (const [level, connections] of otherNode.connections.entries()) {
|
||||||
|
if (connections.has(id)) {
|
||||||
|
connections.delete(id)
|
||||||
|
|
||||||
|
// Prune connections after removing this reference
|
||||||
|
this.pruneConnections(otherNode, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the node
|
||||||
|
this.nodes.delete(id)
|
||||||
|
|
||||||
|
// If we removed the entry point, find a new one
|
||||||
|
if (this.entryPointId === id) {
|
||||||
|
if (this.nodes.size === 0) {
|
||||||
|
this.entryPointId = null
|
||||||
|
this.maxLevel = 0
|
||||||
|
} else {
|
||||||
|
// Find the node with the highest level
|
||||||
|
let maxLevel = 0
|
||||||
|
let newEntryPointId = null
|
||||||
|
|
||||||
|
for (const [nodeId, node] of this.nodes.entries()) {
|
||||||
|
if (node.connections.size === 0) continue // Skip nodes with no connections
|
||||||
|
|
||||||
|
const nodeLevel = Math.max(...node.connections.keys())
|
||||||
|
if (nodeLevel >= maxLevel) {
|
||||||
|
maxLevel = nodeLevel
|
||||||
|
newEntryPointId = nodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.entryPointId = newEntryPointId
|
||||||
|
this.maxLevel = maxLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all nodes in the index
|
||||||
|
*/
|
||||||
|
public getNodes(): Map<string, HNSWNode> {
|
||||||
|
return new Map(this.nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the index
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.nodes.clear()
|
||||||
|
this.entryPointId = null
|
||||||
|
this.maxLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of the index
|
||||||
|
*/
|
||||||
|
public size(): number {
|
||||||
|
return this.nodes.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search within a specific layer
|
||||||
|
* Returns a map of node IDs to distances, sorted by distance
|
||||||
|
*/
|
||||||
|
private searchLayer(
|
||||||
|
queryVector: Vector,
|
||||||
|
entryPoint: HNSWNode,
|
||||||
|
ef: number,
|
||||||
|
level: number
|
||||||
|
): Map<string, number> {
|
||||||
|
// Set of visited nodes
|
||||||
|
const visited = new Set<string>([entryPoint.id])
|
||||||
|
|
||||||
|
// Priority queue of candidates (closest first)
|
||||||
|
const candidates = new Map<string, number>()
|
||||||
|
candidates.set(
|
||||||
|
entryPoint.id,
|
||||||
|
this.distanceFunction(queryVector, entryPoint.vector)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Priority queue of nearest neighbors found so far (closest first)
|
||||||
|
const nearest = new Map<string, number>()
|
||||||
|
nearest.set(
|
||||||
|
entryPoint.id,
|
||||||
|
this.distanceFunction(queryVector, entryPoint.vector)
|
||||||
|
)
|
||||||
|
|
||||||
|
// While there are candidates to explore
|
||||||
|
while (candidates.size > 0) {
|
||||||
|
// Get closest candidate
|
||||||
|
const [closestId, closestDist] = [...candidates][0]
|
||||||
|
candidates.delete(closestId)
|
||||||
|
|
||||||
|
// If this candidate is farther than the farthest in our result set, we're done
|
||||||
|
const farthestInNearest = [...nearest][nearest.size - 1]
|
||||||
|
if (nearest.size >= ef && closestDist > farthestInNearest[1]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explore neighbors of the closest candidate
|
||||||
|
const node = this.nodes.get(closestId)
|
||||||
|
if (!node) {
|
||||||
|
console.error(`Node with ID ${closestId} not found in searchLayer`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const connections = node.connections.get(level) || new Set<string>()
|
||||||
|
|
||||||
|
for (const neighborId of connections) {
|
||||||
|
if (!visited.has(neighborId)) {
|
||||||
|
visited.add(neighborId)
|
||||||
|
|
||||||
|
const neighbor = this.nodes.get(neighborId)
|
||||||
|
if (!neighbor) {
|
||||||
|
console.error(`Neighbor with ID ${neighborId} not found in searchLayer`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const distToNeighbor = this.distanceFunction(
|
||||||
|
queryVector,
|
||||||
|
neighbor.vector
|
||||||
|
)
|
||||||
|
|
||||||
|
// If we haven't found ef nearest neighbors yet, or this neighbor is closer than the farthest one we've found
|
||||||
|
if (nearest.size < ef || distToNeighbor < farthestInNearest[1]) {
|
||||||
|
candidates.set(neighborId, distToNeighbor)
|
||||||
|
nearest.set(neighborId, distToNeighbor)
|
||||||
|
|
||||||
|
// If we have more than ef neighbors, remove the farthest one
|
||||||
|
if (nearest.size > ef) {
|
||||||
|
const sortedNearest = [...nearest].sort((a, b) => a[1] - b[1])
|
||||||
|
nearest.clear()
|
||||||
|
for (let i = 0; i < ef; i++) {
|
||||||
|
nearest.set(sortedNearest[i][0], sortedNearest[i][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort nearest by distance
|
||||||
|
return new Map([...nearest].sort((a, b) => a[1] - b[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select M nearest neighbors from the candidate set
|
||||||
|
*/
|
||||||
|
private selectNeighbors(
|
||||||
|
queryVector: Vector,
|
||||||
|
candidates: Map<string, number>,
|
||||||
|
M: number
|
||||||
|
): Map<string, number> {
|
||||||
|
if (candidates.size <= M) {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple heuristic: just take the M closest
|
||||||
|
const sortedCandidates = [...candidates].sort((a, b) => a[1] - b[1])
|
||||||
|
const result = new Map<string, number>()
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(M, sortedCandidates.length); i++) {
|
||||||
|
result.set(sortedCandidates[i][0], sortedCandidates[i][1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a node doesn't have too many connections at a given level
|
||||||
|
*/
|
||||||
|
private pruneConnections(node: HNSWNode, level: number): void {
|
||||||
|
const connections = node.connections.get(level)!
|
||||||
|
if (connections.size <= this.config.M) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate distances to all neighbors
|
||||||
|
const distances = new Map<string, number>()
|
||||||
|
const validNeighborIds = new Set<string>()
|
||||||
|
|
||||||
|
for (const neighborId of connections) {
|
||||||
|
const neighbor = this.nodes.get(neighborId)
|
||||||
|
if (!neighbor) {
|
||||||
|
console.error(`Neighbor with ID ${neighborId} not found in pruneConnections`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add valid neighbors to the distances map
|
||||||
|
distances.set(
|
||||||
|
neighborId,
|
||||||
|
this.distanceFunction(node.vector, neighbor.vector)
|
||||||
|
)
|
||||||
|
validNeighborIds.add(neighborId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only proceed if we have valid neighbors
|
||||||
|
if (distances.size === 0) {
|
||||||
|
// If no valid neighbors, clear connections at this level
|
||||||
|
node.connections.set(level, new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select M closest neighbors from valid ones
|
||||||
|
const selectedNeighbors = this.selectNeighbors(
|
||||||
|
node.vector,
|
||||||
|
distances,
|
||||||
|
this.config.M
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update connections with only valid neighbors
|
||||||
|
node.connections.set(level, new Set(selectedNeighbors.keys()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random level for a new node
|
||||||
|
* Uses the same distribution as in the original HNSW paper
|
||||||
|
*/
|
||||||
|
private getRandomLevel(): number {
|
||||||
|
const r = Math.random()
|
||||||
|
return Math.floor(-Math.log(r) * (1.0 / Math.log(this.config.M)))
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/index.ts
Normal file
61
src/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* OPFS BrainyData
|
||||||
|
* A vector database using HNSW indexing with Origin Private File System storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Export main BrainyData class and related types
|
||||||
|
import { BrainyData, BrainyDataConfig } from './brainyData.js'
|
||||||
|
export { BrainyData }
|
||||||
|
export type { BrainyDataConfig }
|
||||||
|
|
||||||
|
// Export distance functions for convenience
|
||||||
|
import {
|
||||||
|
euclideanDistance,
|
||||||
|
cosineDistance,
|
||||||
|
manhattanDistance,
|
||||||
|
dotProductDistance
|
||||||
|
} from './utils/index.js'
|
||||||
|
export {
|
||||||
|
euclideanDistance,
|
||||||
|
cosineDistance,
|
||||||
|
manhattanDistance,
|
||||||
|
dotProductDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export storage adapters
|
||||||
|
import {
|
||||||
|
OPFSStorage,
|
||||||
|
MemoryStorage,
|
||||||
|
createStorage
|
||||||
|
} from './storage/opfsStorage.js'
|
||||||
|
export {
|
||||||
|
OPFSStorage,
|
||||||
|
MemoryStorage,
|
||||||
|
createStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
import type {
|
||||||
|
Vector,
|
||||||
|
VectorDocument,
|
||||||
|
SearchResult,
|
||||||
|
DistanceFunction,
|
||||||
|
EmbeddingFunction,
|
||||||
|
EmbeddingModel,
|
||||||
|
HNSWNode,
|
||||||
|
Edge,
|
||||||
|
HNSWConfig,
|
||||||
|
StorageAdapter
|
||||||
|
} from './coreTypes.js'
|
||||||
|
export type {
|
||||||
|
Vector,
|
||||||
|
VectorDocument,
|
||||||
|
SearchResult,
|
||||||
|
DistanceFunction,
|
||||||
|
EmbeddingFunction,
|
||||||
|
EmbeddingModel,
|
||||||
|
HNSWNode,
|
||||||
|
Edge,
|
||||||
|
HNSWConfig,
|
||||||
|
StorageAdapter
|
||||||
|
}
|
||||||
432
src/storage/fileSystemStorage.ts
Normal file
432
src/storage/fileSystemStorage.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { Edge, HNSWNode, StorageAdapter } from '../coreTypes.js'
|
||||||
|
|
||||||
|
// Constants for directory and file names
|
||||||
|
const ROOT_DIR = 'brainy-data'
|
||||||
|
const NODES_DIR = 'nodes'
|
||||||
|
const EDGES_DIR = 'edges'
|
||||||
|
const METADATA_DIR = 'metadata'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File system storage adapter for Node.js environments
|
||||||
|
*/
|
||||||
|
export class FileSystemStorage implements StorageAdapter {
|
||||||
|
private rootDir: string
|
||||||
|
private nodesDir: string
|
||||||
|
private edgesDir: string
|
||||||
|
private metadataDir: string
|
||||||
|
private isInitialized = false
|
||||||
|
|
||||||
|
constructor(rootDirectory?: string) {
|
||||||
|
// Use the provided root directory or default to the current working directory
|
||||||
|
this.rootDir = rootDirectory ? path.resolve(rootDirectory, ROOT_DIR) : path.resolve(process.cwd(), ROOT_DIR)
|
||||||
|
this.nodesDir = path.join(this.rootDir, NODES_DIR)
|
||||||
|
this.edgesDir = path.join(this.rootDir, EDGES_DIR)
|
||||||
|
this.metadataDir = path.join(this.rootDir, METADATA_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the storage adapter
|
||||||
|
*/
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create directories if they don't exist
|
||||||
|
await this.ensureDirectoryExists(this.rootDir)
|
||||||
|
await this.ensureDirectoryExists(this.nodesDir)
|
||||||
|
await this.ensureDirectoryExists(this.edgesDir)
|
||||||
|
await this.ensureDirectoryExists(this.metadataDir)
|
||||||
|
|
||||||
|
this.isInitialized = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize file system storage:', error)
|
||||||
|
throw new Error(`Failed to initialize file system storage: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a node to storage
|
||||||
|
*/
|
||||||
|
public async saveNode(node: HNSWNode): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert connections Map to a serializable format
|
||||||
|
const serializableNode = {
|
||||||
|
...node,
|
||||||
|
connections: this.mapToObject(node.connections, (set) => Array.from(set))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(this.nodesDir, `${node.id}.json`)
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(serializableNode, null, 2),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save node ${node.id}:`, error)
|
||||||
|
throw new Error(`Failed to save node ${node.id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a node from storage
|
||||||
|
*/
|
||||||
|
public async getNode(id: string): Promise<HNSWNode | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.nodesDir, `${id}.json`)
|
||||||
|
|
||||||
|
// Check if a file exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath)
|
||||||
|
} catch {
|
||||||
|
return null // File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fs.promises.readFile(filePath, 'utf8')
|
||||||
|
const parsedNode = JSON.parse(data)
|
||||||
|
|
||||||
|
// Convert serialized connections back to Map<number, Set<string>>
|
||||||
|
const connections = new Map<number, Set<string>>()
|
||||||
|
for (const [level, nodeIds] of Object.entries(parsedNode.connections)) {
|
||||||
|
connections.set(Number(level), new Set(nodeIds as string[]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parsedNode.id,
|
||||||
|
vector: parsedNode.vector,
|
||||||
|
connections
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get node ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all nodes from storage
|
||||||
|
*/
|
||||||
|
public async getAllNodes(): Promise<HNSWNode[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.promises.readdir(this.nodesDir)
|
||||||
|
const nodePromises = files
|
||||||
|
.filter(file => file.endsWith('.json'))
|
||||||
|
.map(file => {
|
||||||
|
const id = path.basename(file, '.json')
|
||||||
|
return this.getNode(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodes = await Promise.all(nodePromises)
|
||||||
|
return nodes.filter((node): node is HNSWNode => node !== null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get all nodes:', error)
|
||||||
|
throw new Error(`Failed to get all nodes: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a node from storage
|
||||||
|
*/
|
||||||
|
public async deleteNode(id: string): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.nodesDir, `${id}.json`)
|
||||||
|
|
||||||
|
// Check if a file exists before attempting to delete
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath)
|
||||||
|
} catch {
|
||||||
|
return // File doesn't exist, nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.unlink(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete node ${id}:`, error)
|
||||||
|
throw new Error(`Failed to delete node ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an edge to storage
|
||||||
|
*/
|
||||||
|
public async saveEdge(edge: Edge): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert connections Map to a serializable format
|
||||||
|
const serializableEdge = {
|
||||||
|
...edge,
|
||||||
|
connections: this.mapToObject(edge.connections, (set) => Array.from(set))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(this.edgesDir, `${edge.id}.json`)
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(serializableEdge, null, 2),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save edge ${edge.id}:`, error)
|
||||||
|
throw new Error(`Failed to save edge ${edge.id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an edge from storage
|
||||||
|
*/
|
||||||
|
public async getEdge(id: string): Promise<Edge | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.edgesDir, `${id}.json`)
|
||||||
|
|
||||||
|
// Check if a file exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath)
|
||||||
|
} catch {
|
||||||
|
return null // File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fs.promises.readFile(filePath, 'utf8')
|
||||||
|
const parsedEdge = JSON.parse(data)
|
||||||
|
|
||||||
|
// Convert serialized connections back to Map<number, Set<string>>
|
||||||
|
const connections = new Map<number, Set<string>>()
|
||||||
|
for (const [level, nodeIds] of Object.entries(parsedEdge.connections)) {
|
||||||
|
connections.set(Number(level), new Set(nodeIds as string[]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parsedEdge.id,
|
||||||
|
vector: parsedEdge.vector,
|
||||||
|
connections,
|
||||||
|
sourceId: parsedEdge.sourceId,
|
||||||
|
targetId: parsedEdge.targetId,
|
||||||
|
type: parsedEdge.type,
|
||||||
|
weight: parsedEdge.weight,
|
||||||
|
metadata: parsedEdge.metadata
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edge ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all edges from storage
|
||||||
|
*/
|
||||||
|
public async getAllEdges(): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.promises.readdir(this.edgesDir)
|
||||||
|
const edgePromises = files
|
||||||
|
.filter(file => file.endsWith('.json'))
|
||||||
|
.map(file => {
|
||||||
|
const id = path.basename(file, '.json')
|
||||||
|
return this.getEdge(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const edges = await Promise.all(edgePromises)
|
||||||
|
return edges.filter((edge): edge is Edge => edge !== null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get all edges:', error)
|
||||||
|
throw new Error(`Failed to get all edges: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by source node ID
|
||||||
|
*/
|
||||||
|
public async getEdgesBySource(sourceId: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allEdges = await this.getAllEdges()
|
||||||
|
return allEdges.filter(edge => edge.sourceId === sourceId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by source ${sourceId}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by source ${sourceId}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by target node ID
|
||||||
|
*/
|
||||||
|
public async getEdgesByTarget(targetId: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allEdges = await this.getAllEdges()
|
||||||
|
return allEdges.filter(edge => edge.targetId === targetId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by target ${targetId}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by target ${targetId}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by type
|
||||||
|
*/
|
||||||
|
public async getEdgesByType(type: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allEdges = await this.getAllEdges()
|
||||||
|
return allEdges.filter(edge => edge.type === type)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by type ${type}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by type ${type}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an edge from storage
|
||||||
|
*/
|
||||||
|
public async deleteEdge(id: string): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.edgesDir, `${id}.json`)
|
||||||
|
|
||||||
|
// Check if a file exists before attempting to delete
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath)
|
||||||
|
} catch {
|
||||||
|
return // File doesn't exist, nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.unlink(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete edge ${id}:`, error)
|
||||||
|
throw new Error(`Failed to delete edge ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save metadata to storage
|
||||||
|
*/
|
||||||
|
public async saveMetadata(id: string, metadata: any): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.metadataDir, `${id}.json`)
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(metadata, null, 2),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save metadata for ${id}:`, error)
|
||||||
|
throw new Error(`Failed to save metadata for ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata from storage
|
||||||
|
*/
|
||||||
|
public async getMetadata(id: string): Promise<any | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.metadataDir, `${id}.json`)
|
||||||
|
|
||||||
|
// Check if a file exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath)
|
||||||
|
} catch {
|
||||||
|
return null // File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fs.promises.readFile(filePath, 'utf8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get metadata for ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data from storage
|
||||||
|
*/
|
||||||
|
public async clear(): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete and recreate the nodes, edges, and metadata directories
|
||||||
|
await this.deleteDirectory(this.nodesDir)
|
||||||
|
await this.deleteDirectory(this.edgesDir)
|
||||||
|
await this.deleteDirectory(this.metadataDir)
|
||||||
|
|
||||||
|
await this.ensureDirectoryExists(this.nodesDir)
|
||||||
|
await this.ensureDirectoryExists(this.edgesDir)
|
||||||
|
await this.ensureDirectoryExists(this.metadataDir)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear storage:', error)
|
||||||
|
throw new Error(`Failed to clear storage: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the storage adapter is initialized
|
||||||
|
*/
|
||||||
|
private async ensureInitialized(): Promise<void> {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a directory exists, creating it if necessary
|
||||||
|
*/
|
||||||
|
private async ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(dirPath)
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist, create it
|
||||||
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a directory and all its contents
|
||||||
|
*/
|
||||||
|
private async deleteDirectory(dirPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await fs.promises.readdir(dirPath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dirPath, file)
|
||||||
|
await fs.promises.unlink(filePath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If the directory doesn't exist, that's fine
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Map to a plain object for serialization
|
||||||
|
*/
|
||||||
|
private mapToObject<K extends string | number, V>(
|
||||||
|
map: Map<K, V>,
|
||||||
|
valueTransformer: (value: V) => any = (v) => v
|
||||||
|
): Record<string, any> {
|
||||||
|
const obj: Record<string, any> = {}
|
||||||
|
for (const [key, value] of map.entries()) {
|
||||||
|
obj[key.toString()] = valueTransformer(value)
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
680
src/storage/opfsStorage.ts
Normal file
680
src/storage/opfsStorage.ts
Normal file
|
|
@ -0,0 +1,680 @@
|
||||||
|
/**
|
||||||
|
* OPFS (Origin Private File System) Storage Adapter
|
||||||
|
* Provides persistent storage for the vector database using the Origin Private File System API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Edge, HNSWNode, StorageAdapter } from '../coreTypes.js'
|
||||||
|
|
||||||
|
// Directory and file names
|
||||||
|
const ROOT_DIR = 'opfs-vector-db'
|
||||||
|
const NODES_DIR = 'nodes'
|
||||||
|
const EDGES_DIR = 'edges'
|
||||||
|
const METADATA_DIR = 'metadata'
|
||||||
|
const DB_INFO_FILE = 'db-info.json'
|
||||||
|
|
||||||
|
export class OPFSStorage implements StorageAdapter {
|
||||||
|
private rootDir: FileSystemDirectoryHandle | null = null
|
||||||
|
private nodesDir: FileSystemDirectoryHandle | null = null
|
||||||
|
private edgesDir: FileSystemDirectoryHandle | null = null
|
||||||
|
private metadataDir: FileSystemDirectoryHandle | null = null
|
||||||
|
private isInitialized = false
|
||||||
|
private isAvailable = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Check if OPFS is available
|
||||||
|
this.isAvailable =
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
'storage' in navigator &&
|
||||||
|
'getDirectory' in navigator.storage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the storage adapter
|
||||||
|
*/
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isAvailable) {
|
||||||
|
throw new Error(
|
||||||
|
'Origin Private File System is not available in this environment'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the root directory
|
||||||
|
const root = await navigator.storage.getDirectory()
|
||||||
|
|
||||||
|
// Create or get our app's root directory
|
||||||
|
this.rootDir = await root.getDirectoryHandle(ROOT_DIR, { create: true })
|
||||||
|
|
||||||
|
// Create or get nodes directory
|
||||||
|
this.nodesDir = await this.rootDir.getDirectoryHandle(NODES_DIR, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create or get edges directory
|
||||||
|
this.edgesDir = await this.rootDir.getDirectoryHandle(EDGES_DIR, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create or get metadata directory
|
||||||
|
this.metadataDir = await this.rootDir.getDirectoryHandle(METADATA_DIR, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.isInitialized = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize OPFS storage:', error)
|
||||||
|
throw new Error(`Failed to initialize OPFS storage: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OPFS is available in the current environment
|
||||||
|
*/
|
||||||
|
public isOPFSAvailable(): boolean {
|
||||||
|
return this.isAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a node to storage
|
||||||
|
*/
|
||||||
|
public async saveNode(node: HNSWNode): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert connections Map to a serializable format
|
||||||
|
const serializableNode = {
|
||||||
|
...node,
|
||||||
|
connections: this.mapToObject(node.connections, (set) =>
|
||||||
|
Array.from(set)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or get the file for this node
|
||||||
|
const fileHandle = await this.nodesDir!.getFileHandle(node.id, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write the node data to the file
|
||||||
|
const writable = await fileHandle.createWritable()
|
||||||
|
await writable.write(JSON.stringify(serializableNode))
|
||||||
|
await writable.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save node ${node.id}:`, error)
|
||||||
|
throw new Error(`Failed to save node ${node.id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a node from storage
|
||||||
|
*/
|
||||||
|
public async getNode(id: string): Promise<HNSWNode | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the file handle for this node
|
||||||
|
const fileHandle = await this.nodesDir!.getFileHandle(id)
|
||||||
|
|
||||||
|
// Read the node data from the file
|
||||||
|
const file = await fileHandle.getFile()
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
// Convert serialized connections back to Map<number, Set<string>>
|
||||||
|
const connections = new Map<number, Set<string>>()
|
||||||
|
for (const [level, nodeIds] of Object.entries(data.connections)) {
|
||||||
|
connections.set(Number(level), new Set(nodeIds as string[]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
vector: data.vector,
|
||||||
|
connections
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If the file doesn't exist, return null
|
||||||
|
if ((error as any).name === 'NotFoundError') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Failed to get node ${id}:`, error)
|
||||||
|
throw new Error(`Failed to get node ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all nodes from storage
|
||||||
|
*/
|
||||||
|
public async getAllNodes(): Promise<HNSWNode[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nodes: HNSWNode[] = []
|
||||||
|
|
||||||
|
// Get all keys (filenames) in the nodes directory
|
||||||
|
// @ts-ignore - TypeScript doesn't recognize FileSystemDirectoryHandle.keys() properly
|
||||||
|
const keys = this.nodesDir!.keys()
|
||||||
|
|
||||||
|
// Iterate through all keys and get the corresponding nodes
|
||||||
|
for await (const name of keys) {
|
||||||
|
const node = await this.getNode(name)
|
||||||
|
if (node) {
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get all nodes:', error)
|
||||||
|
throw new Error(`Failed to get all nodes: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a node from storage
|
||||||
|
*/
|
||||||
|
public async deleteNode(id: string): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.nodesDir!.removeEntry(id)
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore if the file doesn't exist
|
||||||
|
if ((error as any).name !== 'NotFoundError') {
|
||||||
|
console.error(`Failed to delete node ${id}:`, error)
|
||||||
|
throw new Error(`Failed to delete node ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an edge to storage
|
||||||
|
*/
|
||||||
|
public async saveEdge(edge: Edge): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert connections Map to a serializable format
|
||||||
|
const serializableEdge = {
|
||||||
|
...edge,
|
||||||
|
connections: this.mapToObject(edge.connections, (set) =>
|
||||||
|
Array.from(set)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or get the file for this edge
|
||||||
|
const fileHandle = await this.edgesDir!.getFileHandle(edge.id, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write the edge data to the file
|
||||||
|
const writable = await fileHandle.createWritable()
|
||||||
|
await writable.write(JSON.stringify(serializableEdge))
|
||||||
|
await writable.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save edge ${edge.id}:`, error)
|
||||||
|
throw new Error(`Failed to save edge ${edge.id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an edge from storage
|
||||||
|
*/
|
||||||
|
public async getEdge(id: string): Promise<Edge | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the file handle for this edge
|
||||||
|
const fileHandle = await this.edgesDir!.getFileHandle(id)
|
||||||
|
|
||||||
|
// Read the edge data from the file
|
||||||
|
const file = await fileHandle.getFile()
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
// Convert serialized connections back to Map<number, Set<string>>
|
||||||
|
const connections = new Map<number, Set<string>>()
|
||||||
|
for (const [level, nodeIds] of Object.entries(data.connections)) {
|
||||||
|
connections.set(Number(level), new Set(nodeIds as string[]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
vector: data.vector,
|
||||||
|
connections,
|
||||||
|
sourceId: data.sourceId,
|
||||||
|
targetId: data.targetId,
|
||||||
|
type: data.type,
|
||||||
|
weight: data.weight,
|
||||||
|
metadata: data.metadata
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If the file doesn't exist, return null
|
||||||
|
if ((error as any).name === 'NotFoundError') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Failed to get edge ${id}:`, error)
|
||||||
|
throw new Error(`Failed to get edge ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all edges from storage
|
||||||
|
*/
|
||||||
|
public async getAllEdges(): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const edges: Edge[] = []
|
||||||
|
|
||||||
|
// Get all keys (filenames) in the edges directory
|
||||||
|
// @ts-ignore - TypeScript doesn't recognize FileSystemDirectoryHandle.keys() properly
|
||||||
|
const keys = this.edgesDir!.keys()
|
||||||
|
|
||||||
|
// Iterate through all keys and get the corresponding edges
|
||||||
|
for await (const name of keys) {
|
||||||
|
const edge = await this.getEdge(name)
|
||||||
|
if (edge) {
|
||||||
|
edges.push(edge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get all edges:', error)
|
||||||
|
throw new Error(`Failed to get all edges: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an edge from storage
|
||||||
|
*/
|
||||||
|
public async deleteEdge(id: string): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.edgesDir!.removeEntry(id)
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore if the file doesn't exist
|
||||||
|
if ((error as any).name !== 'NotFoundError') {
|
||||||
|
console.error(`Failed to delete edge ${id}:`, error)
|
||||||
|
throw new Error(`Failed to delete edge ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by source node ID
|
||||||
|
*/
|
||||||
|
public async getEdgesBySource(sourceId: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allEdges = await this.getAllEdges()
|
||||||
|
return allEdges.filter(edge => edge.sourceId === sourceId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by source ${sourceId}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by source ${sourceId}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by target node ID
|
||||||
|
*/
|
||||||
|
public async getEdgesByTarget(targetId: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allEdges = await this.getAllEdges()
|
||||||
|
return allEdges.filter(edge => edge.targetId === targetId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by target ${targetId}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by target ${targetId}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get edges by type
|
||||||
|
*/
|
||||||
|
public async getEdgesByType(type: string): Promise<Edge[]> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allEdges = await this.getAllEdges()
|
||||||
|
return allEdges.filter(edge => edge.type === type)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get edges by type ${type}:`, error)
|
||||||
|
throw new Error(`Failed to get edges by type ${type}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save metadata for a node
|
||||||
|
*/
|
||||||
|
public async saveMetadata(id: string, metadata: any): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create or get the file for this metadata
|
||||||
|
const fileHandle = await this.metadataDir!.getFileHandle(id, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write the metadata to the file
|
||||||
|
const writable = await fileHandle.createWritable()
|
||||||
|
await writable.write(JSON.stringify(metadata))
|
||||||
|
await writable.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save metadata for ${id}:`, error)
|
||||||
|
throw new Error(`Failed to save metadata for ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata for a node
|
||||||
|
*/
|
||||||
|
public async getMetadata(id: string): Promise<any | null> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the file handle for this metadata
|
||||||
|
const fileHandle = await this.metadataDir!.getFileHandle(id)
|
||||||
|
|
||||||
|
// Read the metadata from the file
|
||||||
|
const file = await fileHandle.getFile()
|
||||||
|
const text = await file.text()
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch (error) {
|
||||||
|
// If the file doesn't exist, return null
|
||||||
|
if ((error as any).name === 'NotFoundError') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Failed to get metadata for ${id}:`, error)
|
||||||
|
throw new Error(`Failed to get metadata for ${id}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data from storage
|
||||||
|
*/
|
||||||
|
public async clear(): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete and recreate the nodes directory
|
||||||
|
await this.rootDir!.removeEntry(NODES_DIR, { recursive: true })
|
||||||
|
this.nodesDir = await this.rootDir!.getDirectoryHandle(NODES_DIR, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete and recreate the edges directory
|
||||||
|
await this.rootDir!.removeEntry(EDGES_DIR, { recursive: true })
|
||||||
|
this.edgesDir = await this.rootDir!.getDirectoryHandle(EDGES_DIR, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete and recreate the metadata directory
|
||||||
|
await this.rootDir!.removeEntry(METADATA_DIR, { recursive: true })
|
||||||
|
this.metadataDir = await this.rootDir!.getDirectoryHandle(METADATA_DIR, {
|
||||||
|
create: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear storage:', error)
|
||||||
|
throw new Error(`Failed to clear storage: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the storage adapter is initialized
|
||||||
|
*/
|
||||||
|
private async ensureInitialized(): Promise<void> {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Map to a plain object for serialization
|
||||||
|
*/
|
||||||
|
private mapToObject<K extends string | number, V>(
|
||||||
|
map: Map<K, V>,
|
||||||
|
valueTransformer: (value: V) => any = (v) => v
|
||||||
|
): Record<string, any> {
|
||||||
|
const obj: Record<string, any> = {}
|
||||||
|
for (const [key, value] of map.entries()) {
|
||||||
|
obj[key.toString()] = valueTransformer(value)
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory storage adapter for environments where OPFS is not available
|
||||||
|
*/
|
||||||
|
export class MemoryStorage implements StorageAdapter {
|
||||||
|
private nodes: Map<string, HNSWNode> = new Map()
|
||||||
|
private edges: Map<string, Edge> = new Map()
|
||||||
|
private metadata: Map<string, any> = new Map()
|
||||||
|
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
// Nothing to initialize for in-memory storage
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveNode(node: HNSWNode): Promise<void> {
|
||||||
|
// Create a deep copy to avoid reference issues
|
||||||
|
const nodeCopy: HNSWNode = {
|
||||||
|
id: node.id,
|
||||||
|
vector: [...node.vector],
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy connections
|
||||||
|
for (const [level, connections] of node.connections.entries()) {
|
||||||
|
nodeCopy.connections.set(level, new Set(connections))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodes.set(node.id, nodeCopy)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNode(id: string): Promise<HNSWNode | null> {
|
||||||
|
const node = this.nodes.get(id)
|
||||||
|
if (!node) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a deep copy to avoid reference issues
|
||||||
|
const nodeCopy: HNSWNode = {
|
||||||
|
id: node.id,
|
||||||
|
vector: [...node.vector],
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy connections
|
||||||
|
for (const [level, connections] of node.connections.entries()) {
|
||||||
|
nodeCopy.connections.set(level, new Set(connections))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAllNodes(): Promise<HNSWNode[]> {
|
||||||
|
const nodes: HNSWNode[] = []
|
||||||
|
|
||||||
|
for (const nodeId of this.nodes.keys()) {
|
||||||
|
const node = await this.getNode(nodeId)
|
||||||
|
if (node) {
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteNode(id: string): Promise<void> {
|
||||||
|
this.nodes.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveMetadata(id: string, metadata: any): Promise<void> {
|
||||||
|
this.metadata.set(id, JSON.parse(JSON.stringify(metadata)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMetadata(id: string): Promise<any | null> {
|
||||||
|
const metadata = this.metadata.get(id)
|
||||||
|
if (!metadata) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(JSON.stringify(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveEdge(edge: Edge): Promise<void> {
|
||||||
|
// Create a deep copy to avoid reference issues
|
||||||
|
const edgeCopy: Edge = {
|
||||||
|
id: edge.id,
|
||||||
|
vector: [...edge.vector],
|
||||||
|
connections: new Map(),
|
||||||
|
sourceId: edge.sourceId,
|
||||||
|
targetId: edge.targetId,
|
||||||
|
type: edge.type,
|
||||||
|
weight: edge.weight,
|
||||||
|
metadata: edge.metadata ? JSON.parse(JSON.stringify(edge.metadata)) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy connections
|
||||||
|
for (const [level, connections] of edge.connections.entries()) {
|
||||||
|
edgeCopy.connections.set(level, new Set(connections))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.edges.set(edge.id, edgeCopy)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEdge(id: string): Promise<Edge | null> {
|
||||||
|
const edge = this.edges.get(id)
|
||||||
|
if (!edge) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a deep copy to avoid reference issues
|
||||||
|
const edgeCopy: Edge = {
|
||||||
|
id: edge.id,
|
||||||
|
vector: [...edge.vector],
|
||||||
|
connections: new Map(),
|
||||||
|
sourceId: edge.sourceId,
|
||||||
|
targetId: edge.targetId,
|
||||||
|
type: edge.type,
|
||||||
|
weight: edge.weight,
|
||||||
|
metadata: edge.metadata ? JSON.parse(JSON.stringify(edge.metadata)) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy connections
|
||||||
|
for (const [level, connections] of edge.connections.entries()) {
|
||||||
|
edgeCopy.connections.set(level, new Set(connections))
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAllEdges(): Promise<Edge[]> {
|
||||||
|
const edges: Edge[] = []
|
||||||
|
|
||||||
|
for (const edgeId of this.edges.keys()) {
|
||||||
|
const edge = await this.getEdge(edgeId)
|
||||||
|
if (edge) {
|
||||||
|
edges.push(edge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEdgesBySource(sourceId: string): Promise<Edge[]> {
|
||||||
|
const edges: Edge[] = []
|
||||||
|
|
||||||
|
for (const edge of this.edges.values()) {
|
||||||
|
if (edge.sourceId === sourceId) {
|
||||||
|
const edgeCopy = await this.getEdge(edge.id)
|
||||||
|
if (edgeCopy) {
|
||||||
|
edges.push(edgeCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEdgesByTarget(targetId: string): Promise<Edge[]> {
|
||||||
|
const edges: Edge[] = []
|
||||||
|
|
||||||
|
for (const edge of this.edges.values()) {
|
||||||
|
if (edge.targetId === targetId) {
|
||||||
|
const edgeCopy = await this.getEdge(edge.id)
|
||||||
|
if (edgeCopy) {
|
||||||
|
edges.push(edgeCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEdgesByType(type: string): Promise<Edge[]> {
|
||||||
|
const edges: Edge[] = []
|
||||||
|
|
||||||
|
for (const edge of this.edges.values()) {
|
||||||
|
if (edge.type === type) {
|
||||||
|
const edgeCopy = await this.getEdge(edge.id)
|
||||||
|
if (edgeCopy) {
|
||||||
|
edges.push(edgeCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteEdge(id: string): Promise<void> {
|
||||||
|
this.edges.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clear(): Promise<void> {
|
||||||
|
this.nodes.clear()
|
||||||
|
this.edges.clear()
|
||||||
|
this.metadata.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create the appropriate storage adapter based on the environment
|
||||||
|
*/
|
||||||
|
export async function createStorage(): Promise<StorageAdapter> {
|
||||||
|
// Check if we're in a Node.js environment
|
||||||
|
const isNode = typeof process !== 'undefined' &&
|
||||||
|
process.versions != null &&
|
||||||
|
process.versions.node != null
|
||||||
|
|
||||||
|
if (isNode) {
|
||||||
|
// In Node.js, use FileSystemStorage
|
||||||
|
try {
|
||||||
|
const fileSystemModule = await import('./fileSystemStorage.js')
|
||||||
|
return new fileSystemModule.FileSystemStorage()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load FileSystemStorage, falling back to in-memory storage:', error)
|
||||||
|
return new MemoryStorage()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In browser, try OPFS first
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
if (opfsStorage.isOPFSAvailable()) {
|
||||||
|
return opfsStorage
|
||||||
|
} else {
|
||||||
|
console.warn('OPFS is not available, falling back to in-memory storage')
|
||||||
|
return new MemoryStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
283
src/types/augmentations.ts
Normal file
283
src/types/augmentations.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
/** Common types for augmentation system */
|
||||||
|
type WebSocketConnection = {
|
||||||
|
connectionId: string
|
||||||
|
url: string
|
||||||
|
status: 'connected' | 'disconnected' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataCallback<T> = (data: T) => void
|
||||||
|
type AugmentationResponse<T> = Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for all Brainy augmentations.
|
||||||
|
* All augmentations must implement these core properties.
|
||||||
|
*/
|
||||||
|
interface IAugmentation {
|
||||||
|
/** A unique identifier for the augmentation (e.g., "my-reasoner-v1") */
|
||||||
|
readonly name: string
|
||||||
|
/** A human-readable description of the augmentation's purpose */
|
||||||
|
readonly description: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the augmentation. This method is called when Brainy starts up.
|
||||||
|
* @returns A Promise that resolves when initialization is complete
|
||||||
|
*/
|
||||||
|
initialize(): Promise<void>
|
||||||
|
|
||||||
|
shutDown(): Promise<void>
|
||||||
|
|
||||||
|
getStatus(): Promise<'active' | 'inactive' | 'error'>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for WebSocket support.
|
||||||
|
* Augmentations that implement this interface can communicate via WebSockets.
|
||||||
|
*/
|
||||||
|
interface IWebSocketSupport {
|
||||||
|
/**
|
||||||
|
* Establishes a WebSocket connection.
|
||||||
|
* @param url The WebSocket server URL to connect to
|
||||||
|
* @param protocols Optional subprotocols
|
||||||
|
* @returns A Promise resolving to a connection handle or status
|
||||||
|
*/
|
||||||
|
connectWebSocket(url: string, protocols?: string | string[]): Promise<WebSocketConnection>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends data through an established WebSocket connection.
|
||||||
|
* @param connectionId The identifier of the established connection
|
||||||
|
* @param data The data to send (will be serialized if not a string)
|
||||||
|
*/
|
||||||
|
sendWebSocketMessage(connectionId: string, data: unknown): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback for incoming WebSocket messages.
|
||||||
|
* @param connectionId The identifier of the established connection
|
||||||
|
* @param callback The function to call when a message is received
|
||||||
|
*/
|
||||||
|
onWebSocketMessage(connectionId: string, callback: DataCallback<unknown>): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes an established WebSocket connection.
|
||||||
|
* @param connectionId The identifier of the established connection
|
||||||
|
* @param code Optional close code
|
||||||
|
* @param reason Optional close reason
|
||||||
|
*/
|
||||||
|
closeWebSocket(connectionId: string, code?: number, reason?: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace BrainyAugmentations {
|
||||||
|
/**
|
||||||
|
* Interface for Cognitions augmentations.
|
||||||
|
* These augmentations enable advanced reasoning, inference, and logical operations.
|
||||||
|
*/
|
||||||
|
export interface ICognitionAugmentation extends IAugmentation {
|
||||||
|
/**
|
||||||
|
* Performs a reasoning operation based on current knowledge.
|
||||||
|
* @param query The specific reasoning task or question
|
||||||
|
* @param context Optional additional context for the reasoning
|
||||||
|
*/
|
||||||
|
reason(query: string, context?: Record<string, unknown>): AugmentationResponse<{
|
||||||
|
inference: string
|
||||||
|
confidence: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers relationships or new facts from existing data.
|
||||||
|
* @param dataSubset A subset of data to infer from
|
||||||
|
*/
|
||||||
|
infer(dataSubset: Record<string, unknown>): AugmentationResponse<Record<string, unknown>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a logical operation or rule set.
|
||||||
|
* @param ruleId The identifier of the rule or logic to apply
|
||||||
|
* @param input Data to apply the logic to
|
||||||
|
*/
|
||||||
|
executeLogic(ruleId: string, input: Record<string, unknown>): AugmentationResponse<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Senses augmentations.
|
||||||
|
* These augmentations ingest and process raw, unstructured data into nouns and verbs.
|
||||||
|
*/
|
||||||
|
export interface ISenseAugmentation extends IAugmentation {
|
||||||
|
/**
|
||||||
|
* Processes raw input data into structured nouns and verbs.
|
||||||
|
* @param rawData The raw, unstructured data (e.g., text, image buffer, audio stream)
|
||||||
|
* @param dataType The type of raw data (e.g., 'text', 'image', 'audio')
|
||||||
|
*/
|
||||||
|
processRawData(rawData: Buffer | string, dataType: string): AugmentationResponse<{
|
||||||
|
nouns: string[]
|
||||||
|
verbs: string[]
|
||||||
|
}>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener for real-time data feeds.
|
||||||
|
* @param feedUrl The URL or identifier of the real-time feed
|
||||||
|
* @param callback A function to call with processed data
|
||||||
|
*/
|
||||||
|
listenToFeed(
|
||||||
|
feedUrl: string,
|
||||||
|
callback: DataCallback<{ nouns: string[]; verbs: string[] }>
|
||||||
|
): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Perceptions augmentations.
|
||||||
|
* These augmentations interpret, contextualize, and visualize identified nouns and verbs.
|
||||||
|
*/
|
||||||
|
export interface IPerceptionAugmentation extends IAugmentation {
|
||||||
|
/**
|
||||||
|
* Interprets and contextualizes processed nouns and verbs.
|
||||||
|
* @param nouns The list of identified nouns
|
||||||
|
* @param verbs The list of identified verbs
|
||||||
|
* @param context Optional additional context for interpretation
|
||||||
|
*/
|
||||||
|
interpret(
|
||||||
|
nouns: string[],
|
||||||
|
verbs: string[],
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<Record<string, unknown>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizes and filters information.
|
||||||
|
* @param data The data to organize (e.g., interpreted perceptions)
|
||||||
|
* @param criteria Optional criteria for filtering/prioritization
|
||||||
|
*/
|
||||||
|
organize(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
criteria?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<Record<string, unknown>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a visualization based on the provided data.
|
||||||
|
* @param data The data to visualize (e.g., interpreted patterns)
|
||||||
|
* @param visualizationType The desired type of visualization (e.g., 'graph', 'chart')
|
||||||
|
*/
|
||||||
|
generateVisualization(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
visualizationType: string
|
||||||
|
): AugmentationResponse<string | Buffer | Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Activations augmentations.
|
||||||
|
* These augmentations dictate how Brainy initiates actions, responses, or data manipulations.
|
||||||
|
*/
|
||||||
|
export interface IActivationAugmentation extends IAugmentation {
|
||||||
|
/**
|
||||||
|
* Triggers an action based on a processed command or internal state.
|
||||||
|
* @param actionName The name of the action to trigger
|
||||||
|
* @param parameters Optional parameters for the action
|
||||||
|
*/
|
||||||
|
triggerAction(
|
||||||
|
actionName: string,
|
||||||
|
parameters?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an expressive output or response from Brainy.
|
||||||
|
* @param knowledgeId The identifier of the knowledge to express
|
||||||
|
* @param format The desired output format (e.g., 'text', 'json')
|
||||||
|
*/
|
||||||
|
generateOutput(knowledgeId: string, format: string): AugmentationResponse<string | Record<string, unknown>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interacts with an external system or API.
|
||||||
|
* @param systemId The identifier of the external system
|
||||||
|
* @param payload The data to send to the external system
|
||||||
|
*/
|
||||||
|
interactExternal(systemId: string, payload: Record<string, unknown>): AugmentationResponse<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Dialogs augmentations.
|
||||||
|
* These augmentations facilitate natural language understanding and generation for conversational interaction.
|
||||||
|
*/
|
||||||
|
export interface IDialogAugmentation extends IAugmentation {
|
||||||
|
/**
|
||||||
|
* Processes a user's natural language input (query).
|
||||||
|
* @param naturalLanguageQuery The raw text query from the user
|
||||||
|
* @param sessionId An optional session ID for conversational context
|
||||||
|
*/
|
||||||
|
processUserInput(naturalLanguageQuery: string, sessionId?: string): AugmentationResponse<{
|
||||||
|
intent: string
|
||||||
|
nouns: string[]
|
||||||
|
verbs: string[]
|
||||||
|
context: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a natural language response based on Brainy's knowledge and interpreted input.
|
||||||
|
* @param interpretedInput The output from `processUserInput` or similar
|
||||||
|
* @param knowledgeContext Relevant knowledge retrieved from Brainy
|
||||||
|
* @param sessionId An optional session ID for conversational context
|
||||||
|
*/
|
||||||
|
generateResponse(
|
||||||
|
interpretedInput: Record<string, unknown>,
|
||||||
|
knowledgeContext: Record<string, unknown>,
|
||||||
|
sessionId?: string
|
||||||
|
): AugmentationResponse<string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages and updates conversational context.
|
||||||
|
* @param sessionId The session ID
|
||||||
|
* @param contextUpdate The data to update the context with
|
||||||
|
*/
|
||||||
|
manageContext(sessionId: string, contextUpdate: Record<string, unknown>): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Conduits augmentations.
|
||||||
|
* These augmentations establish and manage high-bandwidth, dedicated channels for structured, programmatic two-way data exchange.
|
||||||
|
*/
|
||||||
|
export interface IConduitAugmentation extends IAugmentation {
|
||||||
|
/**
|
||||||
|
* Establishes a connection for programmatic data exchange.
|
||||||
|
* @param targetSystemId The identifier of the external system to connect to
|
||||||
|
* @param config Configuration details for the connection (e.g., API keys, endpoints)
|
||||||
|
*/
|
||||||
|
establishConnection(
|
||||||
|
targetSystemId: string,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): AugmentationResponse<WebSocketConnection>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads structured data directly from Brainy's knowledge graph.
|
||||||
|
* @param query A structured query (e.g., graph query language, object path)
|
||||||
|
* @param options Optional query options (e.g., depth, filters)
|
||||||
|
*/
|
||||||
|
readData(
|
||||||
|
query: Record<string, unknown>,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes or updates structured data directly into Brainy's knowledge graph.
|
||||||
|
* @param data The structured data to write/update
|
||||||
|
* @param options Optional write options (e.g., merge, overwrite)
|
||||||
|
*/
|
||||||
|
writeData(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
): AugmentationResponse<unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitors a specific data stream or event within Brainy for external systems.
|
||||||
|
* @param streamId The identifier of the data stream or event
|
||||||
|
* @param callback A function to call when new data/events occur
|
||||||
|
*/
|
||||||
|
monitorStream(streamId: string, callback: DataCallback<unknown>): Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebSocket-enabled augmentation interfaces */
|
||||||
|
type IWebSocketCognitionAugmentation = BrainyAugmentations.ICognitionAugmentation & IWebSocketSupport
|
||||||
|
type IWebSocketSenseAugmentation = BrainyAugmentations.ISenseAugmentation & IWebSocketSupport
|
||||||
|
type IWebSocketPerceptionAugmentation = BrainyAugmentations.IPerceptionAugmentation & IWebSocketSupport
|
||||||
|
type IWebSocketActivationAugmentation = BrainyAugmentations.IActivationAugmentation & IWebSocketSupport
|
||||||
|
type IWebSocketDialogAugmentation = BrainyAugmentations.IDialogAugmentation & IWebSocketSupport
|
||||||
|
type IWebSocketConduitAugmentation = BrainyAugmentations.IConduitAugmentation & IWebSocketSupport
|
||||||
12
src/types/fileSystemTypes.ts
Normal file
12
src/types/fileSystemTypes.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* Type declarations for the File System Access API
|
||||||
|
* Extends the FileSystemDirectoryHandle interface to include the [Symbol.asyncIterator] method
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Extend the FileSystemDirectoryHandle interface
|
||||||
|
interface FileSystemDirectoryHandle {
|
||||||
|
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export something to make this a module
|
||||||
|
export const fileSystemTypesLoaded = true;
|
||||||
129
src/types/graphTypes.ts
Normal file
129
src/types/graphTypes.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Common metadata types
|
||||||
|
/**
|
||||||
|
* Represents a high-precision timestamp with seconds and nanoseconds
|
||||||
|
* Used for tracking creation and update times of graph elements
|
||||||
|
*/
|
||||||
|
interface Timestamp {
|
||||||
|
seconds: number
|
||||||
|
nanoseconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata about the creator/source of a graph noun
|
||||||
|
* Tracks which augmentation and model created the element
|
||||||
|
*/
|
||||||
|
interface CreatorMetadata {
|
||||||
|
augmentation: string // Name of the augmentation that created this element
|
||||||
|
version: string // Version of the augmentation
|
||||||
|
model: string // Model identifier used in creation
|
||||||
|
modelVersion: string // Version of the model
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for nodes (nouns) in the graph
|
||||||
|
* Represents entities like people, places, things, etc.
|
||||||
|
*/
|
||||||
|
export interface GraphNoun {
|
||||||
|
id: string // Unique identifier for the noun
|
||||||
|
createdBy: CreatorMetadata // Information about what created this noun
|
||||||
|
noun: NounType // Type classification of the noun
|
||||||
|
createdAt: Timestamp // When the noun was created
|
||||||
|
updatedAt: Timestamp // When the noun was last updated
|
||||||
|
data?: Record<string, unknown> // Additional flexible data storage
|
||||||
|
embedding?: number[] // Vector representation of the noun
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for edges (verbs) in the graph
|
||||||
|
* Represents relationships between nouns
|
||||||
|
*/
|
||||||
|
export interface GraphVerb {
|
||||||
|
id: string // Unique identifier for the verb
|
||||||
|
source: string // ID of the source noun
|
||||||
|
target: string // ID of the target noun
|
||||||
|
label?: string // Optional descriptive label
|
||||||
|
verb: VerbType // Type of relationship
|
||||||
|
createdAt: Timestamp // When the verb was created
|
||||||
|
updatedAt: Timestamp // When the verb was last updated
|
||||||
|
data?: Record<string, unknown> // Additional flexible data storage
|
||||||
|
embedding?: number[] // Vector representation of the relationship
|
||||||
|
confidence?: number // Confidence score (0-1)
|
||||||
|
weight?: number // Strength/importance of the relationship
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version of GraphVerb for embedded relationships
|
||||||
|
* Used when the source is implicit from the parent document
|
||||||
|
*/
|
||||||
|
export type EmbeddedGraphVerb = Omit<GraphVerb, 'source'>
|
||||||
|
|
||||||
|
// Proper Noun interfaces - extend GraphNoun with specific noun types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a person entity in the graph
|
||||||
|
*/
|
||||||
|
export interface Person extends GraphNoun {
|
||||||
|
noun: typeof NounType.Person
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a physical location in the graph
|
||||||
|
*/
|
||||||
|
export interface Place extends GraphNoun {
|
||||||
|
noun: typeof NounType.Place
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a physical or virtual object in the graph
|
||||||
|
*/
|
||||||
|
export interface Thing extends GraphNoun {
|
||||||
|
noun: typeof NounType.Thing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an event or occurrence in the graph
|
||||||
|
*/
|
||||||
|
export interface Event extends GraphNoun {
|
||||||
|
noun: typeof NounType.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an abstract concept or idea in the graph
|
||||||
|
*/
|
||||||
|
export interface Concept extends GraphNoun {
|
||||||
|
noun: typeof NounType.Concept
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents content (text, media, etc.) in the graph
|
||||||
|
*/
|
||||||
|
export interface Content extends GraphNoun {
|
||||||
|
noun: typeof NounType.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines valid noun types for graph entities
|
||||||
|
* Used for categorizing different types of nodes
|
||||||
|
*/
|
||||||
|
export const NounType = {
|
||||||
|
Person: 'person', // Person entities
|
||||||
|
Place: 'place', // Physical locations
|
||||||
|
Thing: 'thing', // Physical or virtual objects
|
||||||
|
Event: 'event', // Events or occurrences
|
||||||
|
Concept: 'concept', // Abstract concepts or ideas
|
||||||
|
Content: 'content' // Content items
|
||||||
|
} as const
|
||||||
|
export type NounType = (typeof NounType)[keyof typeof NounType]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines valid verb types for relationships
|
||||||
|
* Used for categorizing different types of connections
|
||||||
|
*/
|
||||||
|
export const VerbType = {
|
||||||
|
AttributedTo: 'attributedTo', // Indicates attribution or authorship
|
||||||
|
Controls: 'controls', // Indicates control or ownership
|
||||||
|
Created: 'created', // Indicates creation or authorship
|
||||||
|
Earned: 'earned', // Indicates achievement or acquisition
|
||||||
|
Owns: 'owns' // Indicates ownership
|
||||||
|
} as const
|
||||||
|
export type VerbType = (typeof VerbType)[keyof typeof VerbType]
|
||||||
14
src/types/tensorflowTypes.ts
Normal file
14
src/types/tensorflowTypes.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Type declarations for TensorFlow.js models
|
||||||
|
|
||||||
|
// This file is a placeholder for TensorFlow.js model types
|
||||||
|
// We're using type assertions in the code instead of module declarations
|
||||||
|
|
||||||
|
// Define some basic types that might be useful
|
||||||
|
export interface TensorflowModel {
|
||||||
|
load(): Promise<any>;
|
||||||
|
embed(data: string[]): any;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a dummy constant to make this a proper module
|
||||||
|
export const tensorflowModelsLoaded = true;
|
||||||
88
src/utils/distance.ts
Normal file
88
src/utils/distance.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* Distance functions for vector similarity calculations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DistanceFunction, Vector } from '../coreTypes.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the Euclidean distance between two vectors
|
||||||
|
* Lower values indicate higher similarity
|
||||||
|
*/
|
||||||
|
export const euclideanDistance: DistanceFunction = (a: Vector, b: Vector): number => {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
throw new Error('Vectors must have the same dimensions')
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const diff = a[i] - b[i]
|
||||||
|
sum += diff * diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.sqrt(sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the cosine distance between two vectors
|
||||||
|
* Lower values indicate higher similarity
|
||||||
|
* Range: 0 (identical) to 2 (opposite)
|
||||||
|
*/
|
||||||
|
export const cosineDistance: DistanceFunction = (a: Vector, b: Vector): number => {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
throw new Error('Vectors must have the same dimensions')
|
||||||
|
}
|
||||||
|
|
||||||
|
let dotProduct = 0
|
||||||
|
let normA = 0
|
||||||
|
let normB = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dotProduct += a[i] * b[i]
|
||||||
|
normA += a[i] * a[i]
|
||||||
|
normB += b[i] * b[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normA === 0 || normB === 0) {
|
||||||
|
return 2 // Maximum distance for zero vectors
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||||
|
// Convert cosine similarity (-1 to 1) to distance (0 to 2)
|
||||||
|
return 1 - similarity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the Manhattan (L1) distance between two vectors
|
||||||
|
* Lower values indicate higher similarity
|
||||||
|
*/
|
||||||
|
export const manhattanDistance: DistanceFunction = (a: Vector, b: Vector): number => {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
throw new Error('Vectors must have the same dimensions')
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
sum += Math.abs(a[i] - b[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the dot product similarity between two vectors
|
||||||
|
* Higher values indicate higher similarity
|
||||||
|
* Converted to a distance metric (lower is better)
|
||||||
|
*/
|
||||||
|
export const dotProductDistance: DistanceFunction = (a: Vector, b: Vector): number => {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
throw new Error('Vectors must have the same dimensions')
|
||||||
|
}
|
||||||
|
|
||||||
|
let dotProduct = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dotProduct += a[i] * b[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to a distance metric (lower is better)
|
||||||
|
return -dotProduct
|
||||||
|
}
|
||||||
168
src/utils/embedding.ts
Normal file
168
src/utils/embedding.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* Embedding functions for converting data to vectors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbeddingFunction, EmbeddingModel, Vector } from '../coreTypes.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple character-based embedding function
|
||||||
|
* This is a very basic implementation for demo purposes
|
||||||
|
*/
|
||||||
|
export class SimpleEmbedding implements EmbeddingModel {
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the embedding model
|
||||||
|
*/
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
this.initialized = true
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed text into a vector using character frequencies
|
||||||
|
* @param data Text to embed
|
||||||
|
*/
|
||||||
|
public async embed(data: string): Promise<Vector> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle string data
|
||||||
|
if (typeof data !== 'string') {
|
||||||
|
throw new Error('SimpleEmbedding only supports string data')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the text
|
||||||
|
const normalizedText = data.toLowerCase().trim()
|
||||||
|
|
||||||
|
// Create a simple 4-dimensional vector based on character frequencies
|
||||||
|
const vector: Vector = [0, 0, 0, 0]
|
||||||
|
|
||||||
|
// Count vowels, consonants, numbers, and special characters
|
||||||
|
for (let i = 0; i < normalizedText.length; i++) {
|
||||||
|
const char = normalizedText[i]
|
||||||
|
if ('aeiou'.includes(char)) {
|
||||||
|
vector[0] += 0.1 // Vowels affect first dimension
|
||||||
|
} else if ('bcdfghjklmnpqrstvwxyz'.includes(char)) {
|
||||||
|
vector[1] += 0.1 // Consonants affect second dimension
|
||||||
|
} else if ('0123456789'.includes(char)) {
|
||||||
|
vector[2] += 0.1 // Numbers affect third dimension
|
||||||
|
} else {
|
||||||
|
vector[3] += 0.1 // Special chars affect fourth dimension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the vector
|
||||||
|
const magnitude = Math.sqrt(
|
||||||
|
vector.reduce((sum, val) => sum + val * val, 0)
|
||||||
|
)
|
||||||
|
if (magnitude > 0) {
|
||||||
|
return vector.map((val) => val / magnitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the model resources
|
||||||
|
*/
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
this.initialized = false
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TensorFlow Universal Sentence Encoder embedding model
|
||||||
|
* Requires @tensorflow/tfjs and @tensorflow-models/universal-sentence-encoder to be installed
|
||||||
|
*/
|
||||||
|
export class UniversalSentenceEncoder implements EmbeddingModel {
|
||||||
|
private model: any = null
|
||||||
|
private initialized = false
|
||||||
|
private tf: any = null
|
||||||
|
private use: any = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the embedding model
|
||||||
|
*/
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamically import TensorFlow.js and Universal Sentence Encoder
|
||||||
|
// Use type assertions to tell TypeScript these modules exist
|
||||||
|
this.tf = await import('@tensorflow/tfjs/dist/tf.esm.js' as any)
|
||||||
|
this.use = await import('@tensorflow-models/universal-sentence-encoder/dist/universal-sentence-encoder.esm.js' as any)
|
||||||
|
|
||||||
|
// Load the model
|
||||||
|
this.model = await this.use.load()
|
||||||
|
this.initialized = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Universal Sentence Encoder:', error)
|
||||||
|
throw new Error(`Failed to initialize Universal Sentence Encoder: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed text into a vector using Universal Sentence Encoder
|
||||||
|
* @param data Text to embed
|
||||||
|
*/
|
||||||
|
public async embed(data: string | string[]): Promise<Vector> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle different input types
|
||||||
|
let textToEmbed: string[]
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
textToEmbed = [data]
|
||||||
|
} else if (Array.isArray(data) && data.every(item => typeof item === 'string')) {
|
||||||
|
textToEmbed = data
|
||||||
|
} else {
|
||||||
|
throw new Error('UniversalSentenceEncoder only supports string or string[] data')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get embeddings
|
||||||
|
const embeddings = await this.model.embed(textToEmbed)
|
||||||
|
|
||||||
|
// Convert to array and return the first embedding
|
||||||
|
const embeddingArray = await embeddings.array()
|
||||||
|
return embeddingArray[0]
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to embed text with Universal Sentence Encoder:', error)
|
||||||
|
throw new Error(`Failed to embed text with Universal Sentence Encoder: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the model resources
|
||||||
|
*/
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
if (this.model && this.tf) {
|
||||||
|
try {
|
||||||
|
// Dispose of the model and tensors
|
||||||
|
this.model.dispose()
|
||||||
|
this.tf.disposeVariables()
|
||||||
|
this.initialized = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to dispose Universal Sentence Encoder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an embedding function from an embedding model
|
||||||
|
* @param model Embedding model to use
|
||||||
|
*/
|
||||||
|
export function createEmbeddingFunction(model: EmbeddingModel): EmbeddingFunction {
|
||||||
|
return async (data: any): Promise<Vector> => {
|
||||||
|
return await model.embed(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default embedding function using UniversalSentenceEncoder
|
||||||
|
*/
|
||||||
|
export const defaultEmbeddingFunction: EmbeddingFunction = createEmbeddingFunction(new UniversalSentenceEncoder())
|
||||||
2
src/utils/index.ts
Normal file
2
src/utils/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './distance.js'
|
||||||
|
export * from './embedding.js'
|
||||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"noEmit": false,
|
||||||
|
"preserveConstEnums": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue