@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
JavaScript
/**
* 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