UNPKG

@claude-flow/embeddings

Version:

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

243 lines (220 loc) • 9.56 kB
/** * 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 type { EmbeddingProvider, RuvectorOnnxEmbeddingConfig, EmbeddingResult, BatchEmbeddingResult, } from './types.js'; import { BaseEmbeddingService } from './embedding-service-base.js'; // Minimal structural type for the embedder we get from the WASM module. // Mirrors the public surface of WasmEmbedder we use — we don't reach for // anything else so a future minor of the WASM package can extend the // class without breaking us. interface WasmEmbedderLike { embedBatch(texts: string[]): Float32Array; dimension(): number; maxLength(): number; similarity(text1: string, text2: string): number; free(): void; } // Structural type for the loader.js entry — the package exposes a // `createEmbedder(modelName)` helper that handles model download + // WASM init + WasmEmbedder construction. interface RuvectorLoaderModule { readonly DEFAULT_MODEL: string; readonly MODELS: Record<string, { dimension: number; maxLength: number }>; createEmbedder(modelName?: string): Promise<WasmEmbedderLike>; } /** * 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(): Promise<RuvectorLoaderModule> { try { return (await import('ruvector-onnx-embeddings-wasm/loader')) as unknown as RuvectorLoaderModule; } 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 { readonly provider: EmbeddingProvider = 'ruvector-onnx'; private embedder: WasmEmbedderLike | null = null; private readonly modelName: string; private readonly normalizeOutput: boolean; private dimensionCache: number | null = null; constructor(config: RuvectorOnnxEmbeddingConfig) { super(config); this.modelName = config.model ?? 'all-MiniLM-L6-v2'; this.normalizeOutput = config.normalize ?? true; } private async initialize(): Promise<void> { 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: string): Promise<EmbeddingResult> { 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: string[]): Promise<BatchEmbeddingResult> { 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: Float32Array[] = new Array(texts.length); const uncachedIndices: number[] = []; const uncachedTexts: string[] = []; 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. */ private l2NormalizeView(view: Float32Array): Float32Array { 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(): Promise<void> { 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: Omit<RuvectorOnnxEmbeddingConfig, 'provider'> = {}, ): RuvectorOnnxEmbeddingService { return new RuvectorOnnxEmbeddingService({ ...config, provider: 'ruvector-onnx' }); }