UNPKG

@claude-flow/embeddings

Version:

V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking

390 lines • 15.1 kB
/** * SearchableEmbeddingCache — ADR-121 Phase 2. * * The legacy `PersistentEmbeddingCache` and the in-process `LRUCache` * only support exact-key lookup. That's fine for "I've seen this * exact text before" but useless for "I haven't computed this exact * text but I have something within ε" — which is the whole point of * a vector cache for embedding-heavy workloads. * * This module adds an HNSW-backed cache implementation that: * - Implements the same key-value contract as the legacy cache * (back-compat: existing callers don't change) * - Adds `search(query, k)` and `searchByText(text, k, embed)` for * ANN retrieval over the cached vectors * - Lazy-loads `@ruvector/core` as an optional peer dep; falls back * to in-memory exact-only when the dep isn't installed * * Phase 2 acceptance: * - ≥0.95 near-duplicate recall (where exact key match would be 0) * - ≥10k inserts/sec on the standard test machine * - Legacy PersistentEmbeddingCache unchanged */ import { int8Encode, int8Decode } from './quantization.js'; async function loadRuvectorCore() { try { // `@ruvector/core` is a CJS module — ESM dynamic import returns // an object whose `.default` is the module surface. We accept // either flat-named exports (vitest mocks) or the .default shape // (real NAPI module). const mod = (await import('@ruvector/core')); const surface = (typeof mod.VectorDb === 'function' || typeof mod.VectorDb === 'object' ? mod : mod.default); return surface ?? null; } catch { return null; } } /** Convert @ruvector/core's distance score into a [0,1] similarity. */ function distanceToSimilarity(distance, metric) { switch (metric) { case 'cosine': // Cosine distance ∈ [0, 2]; cosine similarity = 1 - distance, // but @ruvector/core uses the (1 - cos) convention so the // simple inversion gives back the conventional [-1, 1] range // (clamped to [0, 1] for non-pathological inputs). return Math.max(0, Math.min(1, 1 - distance)); case 'dot-product': // Dot-product "distance" is actually negated similarity. // Clamp to [0, 1] for unit vectors; out-of-range inputs flow through. return Math.max(0, Math.min(1, -distance)); case 'euclidean': case 'manhattan': // Unbounded distances → monotonic, bounded [0, 1] similarity. return 1 / (1 + Math.max(0, distance)); default: return distance; } } /** * In-memory fallback when @ruvector/core isn't installed. Provides * exact key lookup + a linear-scan `search` so the contract still * holds end-to-end (just at O(N) instead of O(log N)). The fallback * is intentionally not silent — callers see `degraded: true` in the * cache's stats so they can opt into installing the optional dep. */ class LinearScanFallback { store = new Map(); insert(args) { const v = args.vector instanceof Float32Array ? args.vector : new Float32Array(args.vector); this.store.set(args.id, v); } delete(id) { return this.store.delete(id); } get(id) { return this.store.get(id) ?? null; } /** * Returns cosine **distance** (1 - similarity) to match the score * convention of `@ruvector/core`. The cache layer converts to * similarity uniformly across backings. */ search(args) { const q = args.vector instanceof Float32Array ? args.vector : new Float32Array(args.vector); const out = []; for (const [id, vec] of this.store) { const sim = cosineSimilarity(q, vec); // Match real backing's score convention: distance, not similarity. const distance = Math.max(0, 1 - sim); out.push({ id, score: distance, vector: vec }); } out.sort((a, b) => a.score - b.score); // ascending: smaller distance first return out.slice(0, args.k); } len() { return this.store.size; } isEmpty() { return this.store.size === 0; } } function cosineSimilarity(a, b) { let dot = 0, na = 0, nb = 0; const len = Math.min(a.length, b.length); for (let i = 0; i < len; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } if (na === 0 || nb === 0) return 0; return dot / (Math.sqrt(na) * Math.sqrt(nb)); } export class SearchableEmbeddingCache { backing = null; degraded = true; // until backing is loaded options; /** Map from cache key (text or content hash) to the assigned vector id. */ keyToId = new Map(); /** Reverse map from vector id back to the original text key. */ idToKey = new Map(); /** * Per-id int8 store. Only populated when `quantize === 'int8'`. The * cache continues to hand back float32 vectors to callers (dequantized * on demand) — quantization is a memory-layout concern transparent * to the IEmbeddingService contract. */ int8Store = new Map(); nextId = 1; hits = 0; misses = 0; constructor(options) { this.options = { dimension: options.dimension, metric: options.metric ?? 'cosine', maxSize: options.maxSize ?? 10_000, quantize: options.quantize ?? 'none', }; } /** Lazy-initialize the HNSW backing. Idempotent. */ async initialize() { if (this.backing) return; const mod = await loadRuvectorCore(); if (mod) { try { // Map our string metric onto the JsDistanceMetric enum. const metricMap = { 'cosine': mod.JsDistanceMetric?.Cosine ?? 'Cosine', 'euclidean': mod.JsDistanceMetric?.Euclidean ?? 'Euclidean', 'dot-product': mod.JsDistanceMetric?.DotProduct ?? 'DotProduct', 'manhattan': mod.JsDistanceMetric?.Manhattan ?? 'Manhattan', }; this.backing = mod.VectorDb.withDimensions(this.options.dimension, metricMap[this.options.metric]); this.degraded = false; return; } catch { // Fall through to the linear-scan fallback on construction failure. } } this.backing = new LinearScanFallback(); this.degraded = true; } /** Cache the embedding under the given text key. */ async set(key, vector, opts) { await this.initialize(); if (vector.length !== this.options.dimension) { throw new Error(`SearchableEmbeddingCache: vector dimension ${vector.length} ` + `doesn't match cache dimension ${this.options.dimension}`); } // Reuse an existing id when the key was seen before, so updates // don't bloat the index. let id = this.keyToId.get(key); if (id) { await this.backing.delete(id); } else { id = opts?.id ?? `e${this.nextId++}`; this.keyToId.set(key, id); this.idToKey.set(id, key); } // ADR-121 Phase 3 — int8 quantization mode. The ANN backing still // receives the float32 vector (its index lookup needs precision), // but we also stash the int8 encoding so get(key) can return the // smaller representation when memory pressure is the binding // constraint. The float32 round-trip stays the default. if (this.options.quantize === 'int8') { this.int8Store.set(id, int8Encode(vector)); } await this.backing.insert({ id, vector }); await this.maybeEvict(); } /** Exact-key lookup. Returns the cached vector or undefined. */ async get(key) { await this.initialize(); const id = this.keyToId.get(key); if (!id) { this.misses++; return undefined; } // int8 path: dequantize from the int8 store. This is faster than // hitting the ANN backing and is the whole point of quantize mode // (smaller memory footprint, deterministic round-trip). if (this.options.quantize === 'int8') { const enc = this.int8Store.get(id); if (!enc) { this.misses++; return undefined; } this.hits++; return int8Decode(enc); } const v = await this.backing.get(id); if (!v) { this.misses++; return undefined; } this.hits++; return this.toFloat32Array(v); } /** Whether the exact key is cached. */ async has(key) { await this.initialize(); return this.keyToId.has(key); } /** Remove a cached entry by key. */ async delete(key) { await this.initialize(); const id = this.keyToId.get(key); if (!id) return false; await this.backing.delete(id); this.keyToId.delete(key); this.idToKey.delete(id); this.int8Store.delete(id); return true; } /** * ANN search over the cached embeddings. Returns top-k neighbors * by similarity, sorted descending by score (higher = more similar). */ async search(query, k = 10) { await this.initialize(); if (query.length !== this.options.dimension) { throw new Error(`SearchableEmbeddingCache.search: query dimension ${query.length} ` + `doesn't match cache dimension ${this.options.dimension}`); } if (await this.backing.isEmpty()) return []; const raw = await this.backing.search({ vector: query, k }); const out = []; for (const row of raw) { // Search results from @ruvector/core include the vector inline; // the linear-scan fallback also includes it. Fall back to a // separate get() call only when the backing didn't return it. let vec = null; if (row.vector) { vec = this.toFloat32Array(row.vector); } else { const fetched = await this.backing.get(row.id); if (fetched) vec = this.toFloat32Array(fetched); } if (!vec) continue; out.push({ id: row.id, score: distanceToSimilarity(row.score, this.options.metric), vector: vec, text: this.idToKey.get(row.id), }); } // Already sorted by distance ascending; after similarity conversion // that's similarity descending — but be defensive in case the // backing returned unsorted results. out.sort((a, b) => b.score - a.score); return out; } /** * Normalize whatever the backing returns (Float32Array, number[], * or an object-as-array from a NAPI bridge) into a Float32Array. */ toFloat32Array(v) { if (v instanceof Float32Array) return v; if (Array.isArray(v)) return new Float32Array(v); // NAPI bridges sometimes return `{ 0: x, 1: y, ... length: n }` — // copy element-wise into a sized buffer. const dim = this.options.dimension; const out = new Float32Array(dim); for (let i = 0; i < dim; i++) { const slot = v[i]; if (typeof slot === 'number') out[i] = slot; } return out; } /** * Embed a text query (via the supplied embedder), then ANN search * for nearest cached neighbors. Convenience helper so callers * don't have to wire embed → search manually. */ async searchByText(text, k, embed) { const { embedding } = await embed(text); // EmbeddingResult.embedding is Float32Array | number[]; normalize // to Float32Array so the search call has a concrete typed view. const query = embedding instanceof Float32Array ? embedding : new Float32Array(embedding); return this.search(query, k); } /** * Snapshot of cache stats — size, hit rate, backing status. * * Size is read from the local id-map (synchronous) instead of the * backing's `len()` because the real `@ruvector/core` returns a * Promise for that. The id-map stays in lockstep with the backing * through `set`/`delete`/`clear`/`maybeEvict`, so this is exact. */ stats() { const total = this.hits + this.misses; // Per-vector memory: float32 = 4 bytes/dim, int8 = 1 byte/dim + 4 scale. const bytesPerVec = this.options.quantize === 'int8' ? (this.options.dimension + 4) : (this.options.dimension * 4); return { size: this.idToKey.size, hits: this.hits, misses: this.misses, hitRate: total > 0 ? this.hits / total : 0, degraded: this.degraded, metric: this.options.metric, dimension: this.options.dimension, quantize: this.options.quantize, estimatedBytes: this.idToKey.size * bytesPerVec, }; } /** Clear all cached entries. */ async clear() { if (!this.backing) { this.keyToId.clear(); this.idToKey.clear(); this.int8Store.clear(); this.hits = 0; this.misses = 0; return; } for (const id of this.idToKey.keys()) { await this.backing.delete(id); } this.keyToId.clear(); this.idToKey.clear(); this.int8Store.clear(); this.hits = 0; this.misses = 0; } /** * FIFO eviction when over maxSize. Async because the backing's * `delete` may be async (the real NAPI binding returns a Promise). */ async maybeEvict() { if (!this.backing) return; while (this.idToKey.size > this.options.maxSize) { let oldestId = null; let oldestNum = Number.POSITIVE_INFINITY; for (const id of this.idToKey.keys()) { const n = parseInt(id.replace(/^e/, ''), 10); if (!Number.isNaN(n) && n < oldestNum) { oldestNum = n; oldestId = id; } } if (!oldestId) break; const key = this.idToKey.get(oldestId); await this.backing.delete(oldestId); this.keyToId.delete(key); this.idToKey.delete(oldestId); this.int8Store.delete(oldestId); } } } /** * Factory — keeps the constructor a one-liner and matches the * createXxxService convention used elsewhere in the package. */ export function createSearchableEmbeddingCache(options) { return new SearchableEmbeddingCache(options); } //# sourceMappingURL=searchable-embedding-cache.js.map