@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
230 lines • 9.59 kB
JavaScript
/**
* ADR-121 Phase 8 — `AnnRouter` composition layer.
*
* The package now ships three ANN backings, each with a different
* sweet spot:
*
* `SearchableEmbeddingCache` (HNSW + `@ruvector/core`)
* - in-memory, streaming insert + delete, ANN search
* - sweet spot: <100k vectors, frequently mutated, low-latency
*
* `RabitqSnapshot` (1-bit quantization + `@ruvector/rabitq-wasm`)
* - in-memory, batch-build only, 32× memory reduction with
* exact-L2² rerank
* - sweet spot: 100k-1M vectors, mostly read, memory-tight
*
* `DiskannSnapshot` (DiskANN/Vamana + `@ruvector/diskann`)
* - on-disk via `storagePath`, batch-build (with streaming insert
* before build), survives process restarts
* - sweet spot: >1M vectors, persistent, agent-fleet-scale
*
* Choosing between them requires understanding the workload —
* something a downstream caller often doesn't want to think about.
* `AnnRouter` takes a workload descriptor (`{ corpusSize, persistent,
* mutable }`) and routes to the right backing automatically, behind a
* single unified `search(query, k)` interface.
*
* Routing rules (documented + enumerable, see `decideBacking()`):
*
* persistent=true → DiskANN (only on-disk option)
* corpusSize ≥ 1_000_000 → DiskANN (memory pressure)
* corpusSize ≥ 100_000 + !mutable → RaBitQ (batch, compressed)
* default (small, mutable, in-RAM)→ HNSW cache
*
* When the preferred backing's peer dep isn't installed, the router
* falls back to the next-best available one and reports `degraded:
* true` + a `degradationReason`. Operators always get *something*
* that works (even if it's the linear-scan fallback inside the HNSW
* cache) rather than a hard failure.
*/
import { createSearchableEmbeddingCache } from './searchable-embedding-cache.js';
import { RabitqSnapshot } from './quantization.js';
import { DiskannSnapshot, diskannAvailable } from './diskann-snapshot.js';
/**
* Pure function exposing the routing decision. Doesn't actually
* instantiate anything — useful for previewing what `AnnRouter.create`
* would do, and for tests that assert routing without booting NAPI.
*/
export function decideBacking(workload) {
if (workload.persistent) {
return { backing: 'diskann', reason: 'persistent=true forces an on-disk backing (DiskANN)' };
}
if (workload.corpusSize >= 1_000_000) {
return { backing: 'diskann', reason: `corpusSize=${workload.corpusSize} ≥1M; DiskANN preserves recall under memory pressure` };
}
if (workload.corpusSize >= 100_000 && !workload.mutable) {
return { backing: 'rabitq', reason: `corpusSize=${workload.corpusSize} in 100k-1M batch range; RaBitQ gives 32× compression` };
}
return { backing: 'hnsw', reason: `corpusSize=${workload.corpusSize} small or mutable=true; HNSW handles streaming + low-latency` };
}
/**
* Unified ANN-router handle. Always exposes the same `add`/`build`/
* `search` surface regardless of which backing is live. Callers can
* inspect `decision` to see what was picked + why.
*/
export class AnnRouter {
decision;
workload;
hnsw = null;
rabitq = null;
diskann = null;
// Buffered entries before `build()` for batch backings.
pending = [];
constructor(workload, decision) {
this.workload = workload;
this.decision = decision;
}
/**
* Pick a backing, instantiate it, return the handle. The router
* never throws on missing peer deps — it falls back to a less-
* preferred backing and reports `degraded: true`.
*/
static async create(workload) {
if (workload.persistent && !workload.storagePath) {
throw new Error('AnnRouter.create: persistent=true requires storagePath');
}
const initial = decideBacking(workload);
let backing = initial.backing;
let reason = initial.reason;
let degraded = false;
let degradationReason;
// Try to instantiate the preferred backing; degrade if peer dep
// isn't reachable. The HNSW cache has its own internal linear-
// scan fallback so it always succeeds — that's the floor.
const router = new AnnRouter(workload, { backing, reason, degraded, degradationReason });
if (backing === 'diskann') {
const ok = await diskannAvailable();
if (ok) {
try {
router.diskann = await DiskannSnapshot.create({
dimension: workload.dimension,
storagePath: workload.storagePath,
});
}
catch (err) {
degraded = true;
degradationReason = `DiskANN create failed: ${err instanceof Error ? err.message : String(err)}`;
backing = 'hnsw';
reason = 'DiskANN unavailable; falling back to HNSW';
}
}
else {
degraded = true;
degradationReason = '@ruvector/diskann not installed';
backing = 'hnsw';
reason = 'DiskANN unavailable; falling back to HNSW';
}
}
if (backing === 'rabitq') {
// RabitqSnapshot is batch-build — we defer instantiation until
// build() is called (it needs the corpus to construct).
// No peer-dep probe here; the build() call will surface the
// named error if the wasm module isn't installable.
}
if (backing === 'hnsw') {
router.hnsw = createSearchableEmbeddingCache({
dimension: workload.dimension,
// The HNSW backing caps at maxSize; pass corpusSize as a
// generous upper bound.
maxSize: Math.max(1000, workload.corpusSize),
});
}
// Re-write the decision now that we know the final routing.
router.decision = {
backing,
reason,
degraded,
degradationReason,
};
return router;
}
/**
* Insert vectors. For HNSW this is immediate. For RaBitQ this
* buffers until `build()`. For DiskANN this calls `add()` on the
* underlying snapshot (also requires `build()` before search).
*/
async add(entries) {
if (this.hnsw) {
for (const e of entries) {
await this.hnsw.set(e.id, e.vector);
}
return;
}
if (this.diskann) {
this.diskann.add(entries);
return;
}
// RaBitQ path — buffer for build().
for (const e of entries)
this.pending.push(e);
}
/**
* Build the index. No-op for HNSW (streaming); required for
* RaBitQ + DiskANN before the first `search`.
*/
async build() {
if (this.hnsw)
return; // streaming, no build pass
if (this.diskann) {
await this.diskann.build();
return;
}
// RaBitQ — construct from buffered entries.
if (this.pending.length === 0) {
throw new Error('AnnRouter.build: no entries buffered for the RaBitQ snapshot');
}
this.rabitq = await RabitqSnapshot.build(this.pending, { dimension: this.workload.dimension });
this.pending = []; // free the buffer
}
/**
* Search the active backing. Always returns `AnnRouterHit[]` sorted
* such that the more-relevant result comes first — HNSW returns
* similarity-descending; RaBitQ + DiskANN return distance-ascending
* (the lower the score, the better the match). Consumers reading
* `score` should interpret it relative to `decision.backing`.
*/
async search(query, k) {
if (this.hnsw) {
const raw = await this.hnsw.search(query, k);
// The HNSW cache returns its own internal id in `h.id` (auto-
// generated as e1/e2/... by SearchableEmbeddingCache) and the
// original caller-supplied key in `h.text`. The AnnRouter
// contract says callers see what they put in, so map .text → .id.
return raw.map(h => ({ id: h.text ?? h.id, score: h.score, vector: h.vector }));
}
if (this.rabitq) {
const raw = await this.rabitq.search(query, k);
return raw.map(h => ({ id: h.id, score: h.distance, vector: h.vector }));
}
if (this.diskann) {
const raw = await this.diskann.search(query, k);
return raw.map(h => ({ id: h.id, score: h.distance }));
}
throw new Error('AnnRouter.search: backing not initialized — call build() first?');
}
/**
* Current vector count. Differs slightly by backing (HNSW returns
* cache size; RaBitQ returns batch size; DiskANN includes tombstones).
*/
count() {
if (this.hnsw)
return this.hnsw.stats().size;
if (this.rabitq)
return this.rabitq.size;
if (this.diskann)
return this.diskann.count();
return this.pending.length;
}
/**
* Persist the snapshot. Only meaningful for DiskANN (the other
* backings are in-memory). No-op + returns false otherwise.
*/
save() {
if (this.diskann && this.workload.storagePath) {
this.diskann.save(this.workload.storagePath);
return true;
}
return false;
}
}
//# sourceMappingURL=ann-router.js.map