@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
481 lines (451 loc) • 18.5 kB
text/typescript
/**
* 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 type { EmbeddingResult } from './types.js';
import { int8Encode, int8Decode, type Int8EncodedVector } from './quantization.js';
/**
* Search result row.
*
* `score` is a **similarity** score: higher = more similar, identical
* vectors → 1.0. The cache normalizes the raw distance metric returned
* by `@ruvector/core` (which returns cosine *distance* and Euclidean
* *distance*) into the similarity convention used elsewhere in this
* package (e.g. `cosineSimilarity()`).
*
* Conversion table:
* cosine : similarity = 1 - distance
* dot-product: similarity = -distance (already in dot-product space)
* euclidean : similarity = 1 / (1 + distance) (monotonic, bounded [0,1])
* manhattan : similarity = 1 / (1 + distance)
*/
export interface CacheSearchHit {
/** Stable opaque id assigned to the cached entry. */
readonly id: string;
/** The cached embedding vector. */
readonly vector: Float32Array;
/** The original text the vector embeds, when available. */
readonly text?: string;
/** Similarity score in [0, 1] (higher = more similar). */
readonly score: number;
}
export interface SearchableCacheOptions {
/** Embedding dimension. Required — must match the producer service. */
readonly dimension: number;
/** Distance metric (default 'cosine'). */
readonly metric?: 'cosine' | 'euclidean' | 'dot-product' | 'manhattan';
/** Max in-memory entries (default 10,000). */
readonly maxSize?: number;
/**
* Quantization mode for cached vectors (ADR-121 Phase 3, alpha.21):
* 'none' (default) — full float32 in memory
* 'int8' — per-vector int8 + scale, 4× memory reduction,
* recall ≥0.98 on unit-normalized vectors
* RaBitQ (1-bit, 32× reduction) is a separate batch API; see
* `RabitqSnapshot` for that path — it doesn't fit the streaming
* insert/get/delete contract this cache exposes.
*/
readonly quantize?: 'none' | 'int8';
}
/**
* Minimal structural type for the @ruvector/core VectorDb we use.
*
* The real `@ruvector/core` API (probed against v0.1.31) is all-async
* with NAPI binding bridges:
* - `insert({ id, vector })` — object arg, returns Promise<void>
* - `search({ vector, k })` — object arg, returns Promise<Array<{ id, score, vector }>>
* - `get(id)` — Promise<Float32Array | null>
* - `delete(id)` — Promise<boolean>
* - `len()` — Promise<number>
* - `isEmpty()` — Promise<boolean>
*
* `score` from search is a **distance** (cosine distance for the
* 'Cosine' metric — identical vectors → ~0, orthogonal → 1). We
* convert to similarity at the cache layer (see `distanceToSimilarity`).
*/
interface VectorDbSearchResult {
readonly id: string;
readonly score: number; // distance, not similarity
readonly vector?: Float32Array | Record<number, number>;
}
interface VectorDbLike {
insert(args: { id: string; vector: Float32Array | number[] }): Promise<void> | void;
delete(id: string): Promise<boolean> | boolean;
get(id: string): Promise<Float32Array | number[] | null | undefined> | Float32Array | number[] | null | undefined;
search(args: { vector: Float32Array | number[]; k: number }): Promise<VectorDbSearchResult[]> | VectorDbSearchResult[];
len(): Promise<number> | number;
isEmpty(): Promise<boolean> | boolean;
}
interface RuvectorCoreModule {
VectorDb: {
withDimensions(dim: number, metric?: string): VectorDbLike;
};
JsDistanceMetric: Record<string, string>;
}
async function loadRuvectorCore(): Promise<RuvectorCoreModule | null> {
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')) as unknown as Record<string, unknown>;
const surface = (typeof (mod as { VectorDb?: unknown }).VectorDb === 'function' || typeof (mod as { VectorDb?: unknown }).VectorDb === 'object'
? mod
: (mod.default as Record<string, unknown> | undefined)) as RuvectorCoreModule | undefined;
return surface ?? null;
} catch {
return null;
}
}
/** Convert @ruvector/core's distance score into a [0,1] similarity. */
function distanceToSimilarity(distance: number, metric: string): number {
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 implements VectorDbLike {
private readonly store = new Map<string, Float32Array>();
insert(args: { id: string; vector: Float32Array | number[] }): void {
const v = args.vector instanceof Float32Array ? args.vector : new Float32Array(args.vector);
this.store.set(args.id, v);
}
delete(id: string): boolean {
return this.store.delete(id);
}
get(id: string): Float32Array | null {
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: { vector: Float32Array | number[]; k: number }): VectorDbSearchResult[] {
const q = args.vector instanceof Float32Array ? args.vector : new Float32Array(args.vector);
const out: VectorDbSearchResult[] = [];
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(): number { return this.store.size; }
isEmpty(): boolean { return this.store.size === 0; }
}
function cosineSimilarity(a: Float32Array, b: Float32Array): number {
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 {
private backing: VectorDbLike | null = null;
private degraded = true; // until backing is loaded
private readonly options: Required<SearchableCacheOptions>;
/** Map from cache key (text or content hash) to the assigned vector id. */
private readonly keyToId = new Map<string, string>();
/** Reverse map from vector id back to the original text key. */
private readonly idToKey = new Map<string, string>();
/**
* 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.
*/
private readonly int8Store = new Map<string, Int8EncodedVector>();
private nextId = 1;
private hits = 0;
private misses = 0;
constructor(options: SearchableCacheOptions) {
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(): Promise<void> {
if (this.backing) return;
const mod = await loadRuvectorCore();
if (mod) {
try {
// Map our string metric onto the JsDistanceMetric enum.
const metricMap: Record<string, string> = {
'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: string, vector: Float32Array, opts?: { id?: string }): Promise<void> {
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: string): Promise<Float32Array | undefined> {
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: string): Promise<boolean> {
await this.initialize();
return this.keyToId.has(key);
}
/** Remove a cached entry by key. */
async delete(key: string): Promise<boolean> {
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: Float32Array, k: number = 10): Promise<CacheSearchHit[]> {
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: CacheSearchHit[] = [];
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: Float32Array | null = 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.
*/
private toFloat32Array(v: Float32Array | number[] | Record<number, number>): Float32Array {
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 as Record<number, number>)[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: string,
k: number,
embed: (text: string) => Promise<EmbeddingResult>,
): Promise<CacheSearchHit[]> {
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(): { size: number; hits: number; misses: number; hitRate: number; degraded: boolean; metric: string; dimension: number; quantize: 'none' | 'int8'; estimatedBytes: number } {
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(): Promise<void> {
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).
*/
private async maybeEvict(): Promise<void> {
if (!this.backing) return;
while (this.idToKey.size > this.options.maxSize) {
let oldestId: string | null = 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: SearchableCacheOptions,
): SearchableEmbeddingCache {
return new SearchableEmbeddingCache(options);
}