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