UNPKG

@claude-flow/embeddings

Version:

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

236 lines • 8.9 kB
/** * Embedding quantization — ADR-121 Phase 3. * * Two opt-in quantization paths: * * 1. **int8** — per-vector linear quantization. 4× memory * reduction (float32 → int8). Works with streaming inserts * because each vector encodes independently. Used by * `SearchableEmbeddingCache` when `quantize: 'int8'` is set. * * 2. **RaBitQ** — 1-bit-per-dimension quantization via * `@ruvector/rabitq-wasm`. 32× memory reduction. Build-once * batch API (not streaming) — exposed here as the * `RabitqSnapshot` class for callers that want to compress * a fixed corpus and query it. Recall recovered via an * exact-L2² rerank pool (default 20× the requested k). * * Each path is opt-in + has a measurable acceptance gate: * int8 → ≥0.98 cosine-similarity recall after dequantize * RaBitQ → ≥0.95 recall after rerank, 32× memory reduction * * For int8 the recall acceptance is measured per-vector * (cosine(original, dequant(quant(original)))). For RaBitQ * recall is measured against the top-k from a brute-force baseline. */ /** * Encode a float32 vector as int8. Returns the byte buffer + a per-vector * scale that maps int8 back to ~float32 on decode. * * Algorithm: find max-abs value, scale so max-abs maps to 127, round * to nearest int8. This is min-max scaling clamped to [-127, 127] and * is the cheapest path that preserves direction (which is what cosine * similarity cares about for unit-normalized embeddings). */ export function int8Encode(vec) { let maxAbs = 0; for (let i = 0; i < vec.length; i++) { const v = Math.abs(vec[i]); if (v > maxAbs) maxAbs = v; } // All-zero vector → emit zero bytes with a unit scale so decode // round-trips to the same zeros without a divide-by-zero. if (maxAbs === 0) { return { bytes: new Int8Array(vec.length), scale: 1 }; } const scale = maxAbs / 127; const invScale = 127 / maxAbs; const bytes = new Int8Array(vec.length); for (let i = 0; i < vec.length; i++) { // Round toward zero with .5 -> away-from-zero (matches the // banker's-round-style behavior of typical quantization libs; // doesn't matter for the recall metric but matters for // determinism across platforms). const q = Math.round(vec[i] * invScale); // Clamp to int8 range — should be redundant given the scale // derivation but defensive against numeric drift. bytes[i] = Math.max(-127, Math.min(127, q)); } return { bytes, scale }; } /** * Decode an int8-encoded vector back to float32. Round-trips with * recall ≥0.98 on unit-normalized vectors (the ADR-121 Phase 3 gate). */ export function int8Decode(encoded) { const out = new Float32Array(encoded.bytes.length); const { bytes, scale } = encoded; for (let i = 0; i < bytes.length; i++) { out[i] = bytes[i] * scale; } return out; } /** * Cosine similarity between two vectors. Used by the quantization * recall test; duplicated here (rather than importing from * embedding-service.ts) to keep this module's dependency graph * minimal — quantization should never depend on the service layer. */ function cosineSimilarity(a, b) { 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)); } /** * Measure round-trip recall for int8 encoding on a corpus. * Recall here = mean cosine similarity between `vec` and * `int8Decode(int8Encode(vec))` across all corpus vectors. * * Used by the CI benchmark to assert the ADR-121 Phase 3 acceptance * gate (≥0.98 recall for int8). */ export function measureInt8RoundTripRecall(corpus) { if (corpus.length === 0) { return { meanRecall: 1, minRecall: 1, maxRecall: 1, count: 0 }; } let sum = 0, min = 1, max = 0; for (const vec of corpus) { const round = int8Decode(int8Encode(vec)); const sim = cosineSimilarity(vec, round); sum += sim; if (sim < min) min = sim; if (sim > max) max = sim; } return { meanRecall: sum / corpus.length, minRecall: min, maxRecall: max, count: corpus.length }; } async function loadRabitqWasm() { try { const mod = (await import('@ruvector/rabitq-wasm')); // Either flat-named export or a `.default` shape (CJS bridge). const surface = (typeof mod.RabitqIndex === 'function' ? mod : mod.default); return surface && typeof surface.RabitqIndex?.build === 'function' ? surface : null; } catch { return null; } } /** * Batch RaBitQ snapshot — compress a corpus of vectors once, then * query against the 1-bit index with exact-L2² rerank for recall. * * Lifecycle: * const snap = await RabitqSnapshot.build(entries, { dimension: 384 }); * const hits = await snap.search(queryVec, 10); * snap.dispose(); * * Memory profile: * raw = N × dim × 4 bytes (float32) * rabitq = N × dim / 8 bytes (1 bit per dim) → 32× smaller * (Plus a per-snapshot id-map for string → numeric position; small.) * * If `@ruvector/rabitq-wasm` isn't installed, `build` throws a * clear named error so callers know which optional peer to install. */ export class RabitqSnapshot { index; idMap; vectors; disposed = false; constructor(index, idMap, vectors) { this.index = index; this.idMap = idMap; this.vectors = vectors; } static async build(entries, options) { const mod = await loadRabitqWasm(); if (!mod) { throw new Error('@ruvector/rabitq-wasm is not installed or did not load cleanly. ' + 'Run: npm install @ruvector/rabitq-wasm. ' + 'Falls back to keeping a plain float32 cache when not installed.'); } if (entries.length === 0) { throw new Error('RabitqSnapshot.build: cannot build an empty index'); } const dim = options.dimension; const seed = options.seed ?? 0n; const rerankFactor = options.rerankFactor ?? 20; // Flatten the entries into one big Float32Array of length N*dim. // The RabitqIndex assigns ids by position, so we keep a parallel // id-map for the string → position lookup on search results. const flat = new Float32Array(entries.length * dim); const idMap = new Array(entries.length); const vectors = new Array(entries.length); for (let i = 0; i < entries.length; i++) { const e = entries[i]; if (e.vector.length !== dim) { throw new Error(`RabitqSnapshot.build: entry ${i} ("${e.id}") has dim ${e.vector.length}, ` + `expected ${dim}`); } flat.set(e.vector, i * dim); idMap[i] = e.id; vectors[i] = e.vector; } const index = mod.RabitqIndex.build(flat, dim, seed, rerankFactor); return new RabitqSnapshot(index, idMap, vectors); } /** Query the snapshot for the `k` nearest neighbors of `query`. */ async search(query, k) { if (this.disposed) throw new Error('RabitqSnapshot: already disposed'); const raw = this.index.search(query, k); const out = []; for (const row of raw) { const id = this.idMap[row.id]; const vec = this.vectors[row.id]; if (id === undefined || !vec) continue; out.push({ id, distance: row.distance, vector: vec }); } return out; } /** Number of vectors in the snapshot. */ get size() { return this.idMap.length; } /** * Release the WASM index. Idempotent. Memory held by the JS-side * id-map and vectors is GC'd normally. */ dispose() { if (this.disposed) return; try { this.index.free?.(); } catch { /* best-effort */ } this.disposed = true; } } /** * Measure the *theoretical* memory reduction for a corpus when * encoded with RaBitQ vs raw float32. Returns the byte counts so * callers can publish the metric to the tracker. Doesn't actually * build the index (cheap to compute, deterministic). */ export function rabitqMemoryReduction(corpusSize, dimension) { const rawBytes = corpusSize * dimension * 4; // float32 const rabitqBytes = Math.ceil(corpusSize * dimension / 8); // 1 bit per dim return { rawBytes, rabitqBytes, reductionFactor: rawBytes / Math.max(1, rabitqBytes), }; } //# sourceMappingURL=quantization.js.map