diff --git a/crates/ruvector-router-core/src/storage.rs b/crates/ruvector-router-core/src/storage.rs index 5c5110b58..61bdb4a40 100644 --- a/crates/ruvector-router-core/src/storage.rs +++ b/crates/ruvector-router-core/src/storage.rs @@ -180,7 +180,11 @@ impl Storage { // Read from database let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(VECTORS_TABLE)?; + let table = match read_txn.open_table(VECTORS_TABLE) { + Ok(t) => t, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None), + Err(e) => return Err(e.into()), + }; if let Some(bytes) = table.get(id)? { let (vector, _): (Vec, usize) = @@ -201,7 +205,11 @@ impl Storage { /// Get metadata for a vector pub fn get_metadata(&self, id: &str) -> Result>> { let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(METADATA_TABLE)?; + let table = match read_txn.open_table(METADATA_TABLE) { + Ok(t) => t, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None), + Err(e) => return Err(e.into()), + }; if let Some(bytes) = table.get(id)? { let metadata: HashMap = @@ -241,7 +249,11 @@ impl Storage { /// Get all vector IDs pub fn get_all_ids(&self) -> Result> { let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(VECTORS_TABLE)?; + let table = match read_txn.open_table(VECTORS_TABLE) { + Ok(t) => t, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; let mut ids = Vec::new(); let iter = table.iter()?; @@ -256,8 +268,11 @@ impl Storage { /// Count total vectors pub fn count(&self) -> Result { let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(VECTORS_TABLE)?; - Ok(table.len()? as usize) + match read_txn.open_table(VECTORS_TABLE) { + Ok(table) => Ok(table.len()? as usize), + Err(redb::TableError::TableDoesNotExist(_)) => Ok(0), + Err(e) => Err(e.into()), + } } /// Store index data diff --git a/crates/ruvector-router-ffi/src/lib.rs b/crates/ruvector-router-ffi/src/lib.rs index f7285c6d1..2dd74df50 100644 --- a/crates/ruvector-router-ffi/src/lib.rs +++ b/crates/ruvector-router-ffi/src/lib.rs @@ -9,8 +9,11 @@ use ruvector_router_core::{ VectorEntry as CoreVectorEntry, }; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +static INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); + #[napi] pub enum DistanceMetric { Euclidean, @@ -74,6 +77,12 @@ impl VectorDB { if let Some(path) = options.storage_path { builder = builder.storage_path(path); + } else { + // Use a unique temp path per instance to avoid file lock conflicts + let id = INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + let tmp = std::env::temp_dir().join(format!("ruvector-{}-{}.db", pid, id)); + builder = builder.storage_path(tmp); } let db = builder diff --git a/npm/packages/router-darwin-arm64/package.json b/npm/packages/router-darwin-arm64/package.json index 0f449c7cd..6266811e1 100644 --- a/npm/packages/router-darwin-arm64/package.json +++ b/npm/packages/router-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/router-darwin-arm64", - "version": "0.1.27", + "version": "0.1.28", "description": "macOS ARM64 (Apple Silicon) native bindings for @ruvector/router", "main": "ruvector-router.darwin-arm64.node", "files": [ diff --git a/npm/packages/router-darwin-x64/package.json b/npm/packages/router-darwin-x64/package.json index 6381c0f64..571f3f8e7 100644 --- a/npm/packages/router-darwin-x64/package.json +++ b/npm/packages/router-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/router-darwin-x64", - "version": "0.1.27", + "version": "0.1.28", "description": "macOS x64 native bindings for @ruvector/router", "main": "ruvector-router.darwin-x64.node", "files": [ diff --git a/npm/packages/router-linux-arm64-gnu/package.json b/npm/packages/router-linux-arm64-gnu/package.json index 378c964e2..714145fd0 100644 --- a/npm/packages/router-linux-arm64-gnu/package.json +++ b/npm/packages/router-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/router-linux-arm64-gnu", - "version": "0.1.27", + "version": "0.1.28", "description": "Linux ARM64 (glibc) native bindings for @ruvector/router", "main": "ruvector-router.linux-arm64-gnu.node", "files": [ diff --git a/npm/packages/router-linux-x64-gnu/package.json b/npm/packages/router-linux-x64-gnu/package.json index 91694aa59..9f410102a 100644 --- a/npm/packages/router-linux-x64-gnu/package.json +++ b/npm/packages/router-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/router-linux-x64-gnu", - "version": "0.1.27", + "version": "0.1.28", "description": "Linux x64 (glibc) native bindings for @ruvector/router", "main": "ruvector-router.linux-x64-gnu.node", "files": [ diff --git a/npm/packages/router-win32-x64-msvc/package.json b/npm/packages/router-win32-x64-msvc/package.json index e50690726..349c4b62c 100644 --- a/npm/packages/router-win32-x64-msvc/package.json +++ b/npm/packages/router-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/router-win32-x64-msvc", - "version": "0.1.27", + "version": "0.1.28", "description": "Windows x64 native bindings for @ruvector/router", "main": "ruvector-router.win32-x64-msvc.node", "files": [ diff --git a/npm/packages/router/index.js b/npm/packages/router/index.js index 4adaa9d05..d9198e62a 100644 --- a/npm/packages/router/index.js +++ b/npm/packages/router/index.js @@ -84,9 +84,10 @@ class SemanticRouter { 'manhattan': native.DistanceMetric.Manhattan }; + this._metric = config.metric || 'cosine'; this._db = new native.VectorDb({ dimensions: config.dimension, - distanceMetric: metricMap[config.metric] || native.DistanceMetric.Cosine, + distanceMetric: metricMap[this._metric] || native.DistanceMetric.Cosine, hnswM: config.m || 16, hnswEfConstruction: config.efConstruction || 200, hnswEfSearch: config.efSearch || 100 @@ -160,7 +161,7 @@ class SemanticRouter { embedding: null }); - // Compute embedding if we have an embedder + // Compute embedding if we have an embedder, otherwise require explicit embedding if (this._embedder && !intent.embedding) { // Compute centroid from all utterances const embeddings = await Promise.all( @@ -183,6 +184,10 @@ class SemanticRouter { : new Float32Array(intent.embedding); this._intents.get(intent.name).embedding = vector; this._db.insert(intent.name, vector); + } else { + throw new Error( + `Intent "${intent.name}" requires either an embedder (setEmbedder) or a pre-computed embedding` + ); } } @@ -223,12 +228,24 @@ class SemanticRouter { const results = this._db.search(embedding, k); return results - .filter(r => r.score >= this._threshold) + .map(r => { + // Convert distance to similarity based on metric + let similarity; + if (this._metric === 'cosine') { + similarity = 1 - r.score; // cosine distance = 1 - cosine_similarity + } else if (this._metric === 'dot') { + similarity = -r.score; // dot distance = -dot_product + } else { + similarity = 1 / (1 + r.score); // euclidean/manhattan: inverse distance + } + return { ...r, similarity }; + }) + .filter(r => r.similarity >= this._threshold) .map(r => { const intentInfo = this._intents.get(r.id); return { intent: r.id, - score: r.score, + score: r.similarity, metadata: intentInfo ? intentInfo.metadata : {} }; }); @@ -321,6 +338,13 @@ class SemanticRouter { const content = await fs.readFile(filePath, 'utf8'); const data = JSON.parse(content); + if (data.dimension && data.dimension !== this._dimension) { + throw new Error( + `Dimension mismatch: router has ${this._dimension} but saved state has ${data.dimension}. ` + + `Create a new SemanticRouter with dimension: ${data.dimension} before loading.` + ); + } + this.clear(); this._threshold = data.threshold || 0.7; diff --git a/npm/packages/router/package.json b/npm/packages/router/package.json index 238be75ec..2144b267b 100644 --- a/npm/packages/router/package.json +++ b/npm/packages/router/package.json @@ -32,11 +32,11 @@ "@napi-rs/cli": "^2.18.0" }, "optionalDependencies": { - "@ruvector/router-linux-x64-gnu": "0.1.27", - "@ruvector/router-linux-arm64-gnu": "0.1.27", - "@ruvector/router-darwin-x64": "0.1.27", - "@ruvector/router-darwin-arm64": "0.1.27", - "@ruvector/router-win32-x64-msvc": "0.1.27" + "@ruvector/router-linux-x64-gnu": "0.1.28", + "@ruvector/router-linux-arm64-gnu": "0.1.28", + "@ruvector/router-darwin-x64": "0.1.28", + "@ruvector/router-darwin-arm64": "0.1.28", + "@ruvector/router-win32-x64-msvc": "0.1.28" }, "publishConfig": { "access": "public" diff --git a/npm/packages/ruvector/src/core/router-wrapper.d.ts b/npm/packages/ruvector/src/core/router-wrapper.d.ts index 391ba360c..b87c1dd12 100644 --- a/npm/packages/ruvector/src/core/router-wrapper.d.ts +++ b/npm/packages/ruvector/src/core/router-wrapper.d.ts @@ -22,25 +22,38 @@ export declare class SemanticRouter { private inner; private routes; constructor(options?: { - dimensions?: number; + dimension?: number; threshold?: number; }); /** - * Add a route with example utterances + * Set the embedder function for converting text to vectors. + * Required before match() can accept string input. */ - addRoute(name: string, utterances: string[], metadata?: Record): void; + setEmbedder(embedder: (text: string) => Promise): void; + /** + * Add a route with example utterances (sync, requires pre-computed embedding) + */ + addRoute(name: string, utterances: string[], metadata?: Record, embedding?: Float32Array | number[]): void; + /** + * Add a route with automatic embedding computation (requires setEmbedder) + */ + addRouteAsync(name: string, utterances: string[], metadata?: Record): Promise; /** * Add multiple routes at once */ addRoutes(routes: Route[]): void; /** - * Match input to best route + * Match input to best route (async, accepts string if embedder is set, or Float32Array) + */ + match(input: string | Float32Array): Promise; + /** + * Get top-k route matches (async) */ - match(input: string): RouteMatch | null; + matchTopK(input: string | Float32Array, k?: number): Promise; /** - * Get top-k route matches + * Match with a pre-computed embedding (synchronous) */ - matchTopK(input: string, k?: number): RouteMatch[]; + matchWithEmbedding(embedding: Float32Array, k?: number): RouteMatch[]; /** * Get all registered routes */ diff --git a/npm/packages/ruvector/src/core/router-wrapper.js b/npm/packages/ruvector/src/core/router-wrapper.js index be57c2f1d..bad6afe61 100644 --- a/npm/packages/ruvector/src/core/router-wrapper.js +++ b/npm/packages/ruvector/src/core/router-wrapper.js @@ -43,16 +43,39 @@ class SemanticRouter { this.routes = new Map(); const router = getRouterModule(); this.inner = new router.SemanticRouter({ - dimensions: options.dimensions ?? 384, + dimension: options.dimension ?? 384, threshold: options.threshold ?? 0.7, }); } /** - * Add a route with example utterances + * Set the embedder function for converting text to vectors. + * Required before match() can accept string input. */ - addRoute(name, utterances, metadata) { + setEmbedder(embedder) { + this.inner.setEmbedder(embedder); + } + /** + * Add a route with example utterances (sync, requires pre-computed embedding) + */ + addRoute(name, utterances, metadata, embedding) { + this.routes.set(name, { name, utterances, metadata }); + this.inner.addIntent({ + name, + utterances, + metadata, + embedding, + }); + } + /** + * Add a route with automatic embedding computation (requires setEmbedder) + */ + async addRouteAsync(name, utterances, metadata) { this.routes.set(name, { name, utterances, metadata }); - this.inner.addRoute(name, utterances, metadata ? JSON.stringify(metadata) : undefined); + await this.inner.addIntentAsync({ + name, + utterances, + metadata, + }); } /** * Add multiple routes at once @@ -63,27 +86,38 @@ class SemanticRouter { } } /** - * Match input to best route + * Match input to best route (async, accepts string if embedder is set, or Float32Array) */ - match(input) { - const result = this.inner.match(input); - if (!result) + async match(input) { + const results = await this.inner.route(input, 1); + if (!results || results.length === 0) return null; return { - route: result.route, - score: result.score, - metadata: result.metadata ? JSON.parse(result.metadata) : undefined, + route: results[0].intent, + score: results[0].score, + metadata: results[0].metadata, }; } /** - * Get top-k route matches + * Get top-k route matches (async) + */ + async matchTopK(input, k = 3) { + const results = await this.inner.route(input, k); + return (results || []).map((r) => ({ + route: r.intent, + score: r.score, + metadata: r.metadata, + })); + } + /** + * Match with a pre-computed embedding (synchronous) */ - matchTopK(input, k = 3) { - const results = this.inner.matchTopK(input, k); - return results.map((r) => ({ - route: r.route, + matchWithEmbedding(embedding, k = 1) { + const results = this.inner.routeWithEmbedding(embedding, k); + return (results || []).map((r) => ({ + route: r.intent, score: r.score, - metadata: r.metadata ? JSON.parse(r.metadata) : undefined, + metadata: r.metadata, })); } /** @@ -99,7 +133,7 @@ class SemanticRouter { if (!this.routes.has(name)) return false; this.routes.delete(name); - return this.inner.removeRoute(name); + return this.inner.removeIntent(name); } /** * Clear all routes diff --git a/npm/packages/ruvector/src/core/router-wrapper.ts b/npm/packages/ruvector/src/core/router-wrapper.ts index b0d74a89c..66da7ee05 100644 --- a/npm/packages/ruvector/src/core/router-wrapper.ts +++ b/npm/packages/ruvector/src/core/router-wrapper.ts @@ -52,20 +52,45 @@ export class SemanticRouter { private inner: any; private routes: Map = new Map(); - constructor(options: { dimensions?: number; threshold?: number } = {}) { + constructor(options: { dimension?: number; threshold?: number } = {}) { const router = getRouterModule(); this.inner = new router.SemanticRouter({ - dimensions: options.dimensions ?? 384, + dimension: options.dimension ?? 384, threshold: options.threshold ?? 0.7, }); } /** - * Add a route with example utterances + * Set the embedder function for converting text to vectors. + * Required before match() can accept string input. */ - addRoute(name: string, utterances: string[], metadata?: Record): void { + setEmbedder(embedder: (text: string) => Promise): void { + this.inner.setEmbedder(embedder); + } + + /** + * Add a route with example utterances (sync, requires pre-computed embedding) + */ + addRoute(name: string, utterances: string[], metadata?: Record, embedding?: Float32Array | number[]): void { this.routes.set(name, { name, utterances, metadata }); - this.inner.addRoute(name, utterances, metadata ? JSON.stringify(metadata) : undefined); + this.inner.addIntent({ + name, + utterances, + metadata, + embedding, + }); + } + + /** + * Add a route with automatic embedding computation (requires setEmbedder) + */ + async addRouteAsync(name: string, utterances: string[], metadata?: Record): Promise { + this.routes.set(name, { name, utterances, metadata }); + await this.inner.addIntentAsync({ + name, + utterances, + metadata, + }); } /** @@ -78,28 +103,40 @@ export class SemanticRouter { } /** - * Match input to best route + * Match input to best route (async, accepts string if embedder is set, or Float32Array) */ - match(input: string): RouteMatch | null { - const result = this.inner.match(input); - if (!result) return null; + async match(input: string | Float32Array): Promise { + const results = await this.inner.route(input, 1); + if (!results || results.length === 0) return null; return { - route: result.route, - score: result.score, - metadata: result.metadata ? JSON.parse(result.metadata) : undefined, + route: results[0].intent, + score: results[0].score, + metadata: results[0].metadata, }; } /** - * Get top-k route matches + * Get top-k route matches (async) + */ + async matchTopK(input: string | Float32Array, k: number = 3): Promise { + const results = await this.inner.route(input, k); + return (results || []).map((r: any) => ({ + route: r.intent, + score: r.score, + metadata: r.metadata, + })); + } + + /** + * Match with a pre-computed embedding (synchronous) */ - matchTopK(input: string, k: number = 3): RouteMatch[] { - const results = this.inner.matchTopK(input, k); - return results.map((r: any) => ({ - route: r.route, + matchWithEmbedding(embedding: Float32Array, k: number = 1): RouteMatch[] { + const results = this.inner.routeWithEmbedding(embedding, k); + return (results || []).map((r: any) => ({ + route: r.intent, score: r.score, - metadata: r.metadata ? JSON.parse(r.metadata) : undefined, + metadata: r.metadata, })); } @@ -116,7 +153,7 @@ export class SemanticRouter { removeRoute(name: string): boolean { if (!this.routes.has(name)) return false; this.routes.delete(name); - return this.inner.removeRoute(name); + return this.inner.removeIntent(name); } /**