@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
201 lines • 8.91 kB
JavaScript
/**
* RuvectorOnnxEmbeddingService — ADR-121 Phase 1.
*
* Drop-in replacement for the legacy `TransformersEmbeddingService` that
* uses `ruvector-onnx-embeddings-wasm` instead of the retired
* `@xenova/transformers@2.x`. Matches the existing IEmbeddingService
* contract so consumers don't migrate: opt in via
* `provider: 'ruvector-onnx'` on `createEmbeddingService`.
*
* Why this exists:
* - `@xenova/transformers` was renamed upstream to
* `@huggingface/transformers@4.x` and the v2 line is unmaintained.
* Carrying it in the runtime tree of every embedding install is a
* growing liability.
* - `ruvector-onnx-embeddings-wasm` is pure WASM with SIMD + parallel
* workers, smaller install footprint (no native bindings, no
* Python-style model-conversion tooling shipped), and runs
* identically in Node, browsers, Workers, and Deno.
* - Same model catalog (all-MiniLM-L6-v2, BGE, E5, GTE) so output
* parity with the Xenova path is achievable to >0.999 cosine
* correlation on a fixed test corpus — that's the Phase 1
* acceptance gate.
*
* Optional peer dep: when `ruvector-onnx-embeddings-wasm` isn't
* installed, construction throws a clear error naming the dep and the
* single install command. Callers that don't opt into `ruvector-onnx`
* are unaffected.
*/
import { BaseEmbeddingService } from './embedding-service-base.js';
/**
* Lazy loader for `ruvector-onnx-embeddings-wasm/loader`. Direct
* dynamic `import()` so vitest / mocks can intercept; wrapped in
* try/catch so the module-not-found case is a clean named error.
*/
async function loadRuvectorOnnxLoader() {
try {
return (await import('ruvector-onnx-embeddings-wasm/loader'));
}
catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error('ruvector-onnx-embeddings-wasm is not installed. Run: ' +
'npm install ruvector-onnx-embeddings-wasm (or add it to your ' +
'project\'s optional peer deps). Original error: ' + detail);
}
}
export class RuvectorOnnxEmbeddingService extends BaseEmbeddingService {
provider = 'ruvector-onnx';
embedder = null;
modelName;
normalizeOutput;
dimensionCache = null;
constructor(config) {
super(config);
this.modelName = config.model ?? 'all-MiniLM-L6-v2';
this.normalizeOutput = config.normalize ?? true;
}
async initialize() {
if (this.embedder)
return;
const loader = await loadRuvectorOnnxLoader();
try {
// createEmbedder handles model download (~23MB for MiniLM-L6),
// WASM init, tokenizer load, and WasmEmbedder construction.
this.embedder = await loader.createEmbedder(this.modelName);
this.dimensionCache = this.embedder.dimension();
}
catch (err) {
const detail = err instanceof Error ? err.message : String(err);
// Distinguish between missing-package and broken-package paths
// so operators don't waste time `npm install`ing something
// that's already there. The most common runtime breakage is the
// wasm-bindgen ESM/CJS mismatch — surface the dep name + the
// upstream issue path so the operator can find it.
throw new Error(`ruvector-onnx-embeddings-wasm runtime initialization failed for ` +
`model '${this.modelName}'. The package is installed but createEmbedder ` +
`threw. Most common cause: WASM/ESM packaging mismatch in the published ` +
`tarball (the .js wrapper references CommonJS \`module\` from an ESM ` +
`context). Check ruvector-onnx-embeddings-wasm@latest for fixes, or ` +
`fall back to provider: 'transformers' until upstream is patched. ` +
`Original error: ${detail}`);
}
}
async embed(text) {
await this.initialize();
// Cache check first — same shape as TransformersEmbeddingService so
// the cache hit-rate metrics stay comparable between providers.
const cached = this.cache.get(text);
if (cached) {
this.emitEvent({ type: 'cache_hit', text });
return { embedding: cached, latencyMs: 0, cached: true };
}
this.emitEvent({ type: 'embed_start', text });
const startTime = performance.now();
try {
// The WASM API only exposes embedBatch — single-text embedding is
// a one-element batch. The returned Float32Array is flat
// (length === N_texts * dimension); we slice the first row out.
const flat = this.embedder.embedBatch([text]);
const dim = this.dimensionCache;
const embedding = this.normalizeOutput
? this.l2NormalizeView(flat.subarray(0, dim))
: new Float32Array(flat.subarray(0, dim));
this.cache.set(text, embedding);
const latencyMs = performance.now() - startTime;
this.emitEvent({ type: 'embed_complete', text, latencyMs });
return { embedding, latencyMs };
}
catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.emitEvent({ type: 'embed_error', text, error: message });
throw new Error(`ruvector-onnx embedding failed: ${message}`);
}
}
async embedBatch(texts) {
await this.initialize();
this.emitEvent({ type: 'batch_start', count: texts.length });
const startTime = performance.now();
// Split into cached vs uncached. Uncached texts go through the
// WASM batch call once, which is the whole reason for using this
// backend — the WasmEmbedder is dramatically faster batched than
// one-at-a-time.
const embeddings = new Array(texts.length);
const uncachedIndices = [];
const uncachedTexts = [];
let cacheHits = 0;
for (let i = 0; i < texts.length; i++) {
const cached = this.cache.get(texts[i]);
if (cached) {
embeddings[i] = cached;
cacheHits++;
this.emitEvent({ type: 'cache_hit', text: texts[i] });
}
else {
uncachedIndices.push(i);
uncachedTexts.push(texts[i]);
}
}
if (uncachedTexts.length > 0) {
const flat = this.embedder.embedBatch(uncachedTexts);
const dim = this.dimensionCache;
for (let j = 0; j < uncachedTexts.length; j++) {
const row = flat.subarray(j * dim, (j + 1) * dim);
const embedding = this.normalizeOutput
? this.l2NormalizeView(row)
: new Float32Array(row);
const text = uncachedTexts[j];
const idx = uncachedIndices[j];
this.cache.set(text, embedding);
embeddings[idx] = embedding;
}
}
const totalLatencyMs = performance.now() - startTime;
this.emitEvent({ type: 'batch_complete', count: texts.length, latencyMs: totalLatencyMs });
return {
embeddings,
totalLatencyMs,
avgLatencyMs: totalLatencyMs / texts.length,
cacheStats: { hits: cacheHits, misses: texts.length - cacheHits },
};
}
/**
* L2-normalize a Float32Array view into a freshly-allocated
* Float32Array. The WASM embedder can normalize internally via
* WasmEmbedderConfig, but we do it here so the cached vector is
* always normalized regardless of model defaults — keeps similarity
* scoring predictable across providers.
*/
l2NormalizeView(view) {
let sum = 0;
for (let i = 0; i < view.length; i++)
sum += view[i] * view[i];
const norm = Math.sqrt(sum);
if (norm === 0)
return new Float32Array(view);
const out = new Float32Array(view.length);
for (let i = 0; i < view.length; i++)
out[i] = view[i] / norm;
return out;
}
/**
* Release the WASM embedder. Idempotent; safe to call multiple times.
*/
async close() {
if (this.embedder) {
try {
this.embedder.free();
}
catch { /* best-effort */ }
this.embedder = null;
this.dimensionCache = null;
}
}
}
/**
* Factory helper — mirrors the createEmbeddingService convention so
* callers can opt in without instantiating directly.
*/
export function createRuvectorOnnxEmbeddingService(config = {}) {
return new RuvectorOnnxEmbeddingService({ ...config, provider: 'ruvector-onnx' });
}
//# sourceMappingURL=ruvector-onnx-embedding-service.js.map