UNPKG

@claude-flow/embeddings

Version:

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

148 lines • 5.41 kB
/** * ADR-121 Phase 21 — Content-addressed embed cache (BEYOND SOTA). * * The Phase 20 lazy router cuts feature-extraction cost by 24% on a * mixed workload — but it still pays the question-embed cost on * EVERY query, even when the same question was asked moments ago. * Production RAG has heavy repeated-query patterns (FAQs, common * queries hit thousands of times). The right fix is a * content-addressed cache: hash the (normalized) input text, look * up the cached vector, skip the embed entirely on a hit. * * This module ships: * - `EmbedCache` — in-memory LRU cache with hit/miss stats * - `wrapWithCache(embedFn, cache)` — transparent adapter that * turns any embed function into a cache-checking one * * Cache key = sha256-hex of (normalized text + dim). The dim is * mixed in so two providers with different output sizes don't * collide. Normalization: lowercase + trim + collapse internal * whitespace — matches what production cache lookups would do. * * Composes with the Phase 20 lazy router by passing * `wrapWithCache(embedFn, cache)` as the `embed` adapter. */ import { createHash } from 'node:crypto'; /** * In-memory LRU cache for embeddings. The cache key is the sha256 * hex of the canonicalized text + dim — so two distinct text bodies * never collide and the same text always lands on the same key. * * Cache state is process-local. For cross-process sharing use * `PersistentEmbeddingCache` from `persistent-cache.ts`. */ export class EmbedCache { maxEntries; dim; // LRU implementation via a Map (which preserves insertion order // in JS) — re-insert on hit to bump to most-recent. store = new Map(); hits = 0; misses = 0; constructor(options = {}) { this.maxEntries = options.maxEntries ?? 1000; if (this.maxEntries < 1) throw new Error('maxEntries must be >= 1'); this.dim = options.dimension; } /** * Compute the cache key for a text + dim pair. Exposed for * testing + so the lazy router can pre-key lookups. */ keyFor(text, dimension) { const normalized = canonicalizeText(text); return createHash('sha256').update(`${dimension}|${normalized}`).digest('hex'); } get(text) { if (this.dim === undefined) { this.misses++; return undefined; } const key = this.keyFor(text, this.dim); const existing = this.store.get(key); if (existing) { this.hits++; // LRU bump — delete + re-insert moves to MRU position. this.store.delete(key); this.store.set(key, existing); return existing; } this.misses++; return undefined; } set(text, vector) { const v = vector instanceof Float32Array ? vector : new Float32Array(vector); if (this.dim === undefined) this.dim = v.length; if (v.length !== this.dim) { throw new Error(`EmbedCache: vector dim ${v.length} != cache dim ${this.dim}`); } const key = this.keyFor(text, this.dim); // Replace existing key OR add new — both bump to MRU. if (this.store.has(key)) this.store.delete(key); this.store.set(key, v); // LRU evict if over capacity. while (this.store.size > this.maxEntries) { const oldestKey = this.store.keys().next().value; if (oldestKey === undefined) break; this.store.delete(oldestKey); } } has(text) { if (this.dim === undefined) return false; return this.store.has(this.keyFor(text, this.dim)); } /** Clear all entries; reset stats. */ reset() { this.store.clear(); this.hits = 0; this.misses = 0; } stats() { const entries = this.store.size; const lookups = this.hits + this.misses; const bytesPerVec = (this.dim ?? 0) * 4; // Float32 = 4 bytes return { entries, maxEntries: this.maxEntries, hits: this.hits, misses: this.misses, lookups, hitRate: lookups === 0 ? 0 : this.hits / lookups, approxByteSize: entries * bytesPerVec, }; } } /** * Canonicalize text for stable cache key derivation: trim, collapse * internal whitespace, lowercase. Identical to what production cache * lookups should do — "how does AUTH work?" and "how does auth work?" * should hit the same cache entry. */ export function canonicalizeText(text) { return text.trim().replace(/\s+/g, ' ').toLowerCase(); } /** * Wrap an async embed function with a cache-checking adapter. On * cache hit, returns the cached vector without calling the wrapped * embedder. On miss, calls the wrapped embedder and stores the * result. * * The returned function has the same signature as the input function * so it can be passed to any router as a drop-in replacement. */ export function wrapWithCache(embedder, cache) { return async (text) => { const hit = cache.get(text); if (hit) return hit; const v = await embedder(text); const f32 = v instanceof Float32Array ? v : new Float32Array(v); cache.set(text, f32); return f32; }; } //# sourceMappingURL=embed-cache.js.map