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