@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
171 lines • 6.64 kB
JavaScript
/**
* ADR-121 Phase 5b — `@ruvector/diskann` snapshot wrapper.
*
* `RabitqSnapshot` (Phase 3) gives 32× memory reduction via 1-bit
* quantization, batch-build-only, in-memory. That covers the
* common case (≤100k cached vectors, fits in RAM, ANN search needed).
*
* Once the corpus grows past what fits in RAM — agent fleets where
* each agent owns a slice of a shared memory namespace, multi-million
* cached embeddings across long-running sessions — the index needs
* to live on disk. `@ruvector/diskann` implements DiskANN/Vamana
* with optional product quantization for SSD-friendly billion-scale
* ANN search. This module is the thin adapter.
*
* Lifecycle differs from RabitqSnapshot:
* - DiskANN supports **streaming insert** (then a build pass to
* finalize the Vamana graph), so callers can grow the index
* incrementally before building
* - **Persistence** via `save(dir)` / `static load(dir)` —
* surviving restarts is what makes the on-SSD story useful
*
* Optional peer dep. When `@ruvector/diskann` isn't installed,
* `DiskannSnapshot.create` throws a clear named error pointing at
* the install command + the `RabitqSnapshot` in-memory alternative.
*/
async function loadDiskAnn() {
try {
const mod = (await import('@ruvector/diskann'));
const surface = (typeof mod.DiskAnn === 'function'
? mod
: mod.default);
return surface && typeof surface.DiskAnn === 'function' ? surface : null;
}
catch {
return null;
}
}
/**
* Disk-friendly ANN snapshot over a corpus of vectors via DiskANN/Vamana.
*
* Workflow:
* const snap = await DiskannSnapshot.create({ dimension: 384 });
* snap.add([{ id: 'a', vector: vec1 }, { id: 'b', vector: vec2 }, ...]);
* await snap.build();
* const hits = await snap.search(query, 10);
* snap.save('./cache/diskann');
*
* Reload after restart:
* const snap = await DiskannSnapshot.load('./cache/diskann', { dimension: 384 });
* const hits = await snap.search(query, 10);
*/
export class DiskannSnapshot {
index;
dimension;
built = false;
constructor(index, dimension, alreadyBuilt = false) {
this.index = index;
this.dimension = dimension;
this.built = alreadyBuilt;
}
/**
* Create a new empty snapshot. Add vectors with `add()`, then call
* `build()` before the first `search()`.
*
* @throws when `@ruvector/diskann` isn't installed — error message
* names the package + recommends `RabitqSnapshot` as the
* in-memory alternative.
*/
static async create(options) {
const mod = await loadDiskAnn();
if (!mod) {
throw new Error('@ruvector/diskann is not installed. Run: npm install @ruvector/diskann. ' +
'For in-memory ANN without the disk story, use RabitqSnapshot ' +
'(no extra peer dep beyond @ruvector/rabitq-wasm).');
}
const idx = new mod.DiskAnn({
dim: options.dimension,
maxDegree: options.maxDegree,
pqSubspaces: options.pqSubspaces,
storagePath: options.storagePath,
});
return new DiskannSnapshot(idx, options.dimension);
}
/**
* Load a previously-saved snapshot from disk. The directory must
* contain a snapshot produced by `save()`.
*/
static async load(dir, options) {
const mod = await loadDiskAnn();
if (!mod) {
throw new Error('@ruvector/diskann is not installed; cannot load snapshot');
}
const idx = mod.DiskAnn.load(dir);
return new DiskannSnapshot(idx, options.dimension, true);
}
/**
* Add vectors to the snapshot. Streaming — can be called multiple
* times before `build()`. Each vector's dimension must match the
* snapshot's configured dimension.
*/
add(entries) {
if (entries.length === 0)
return;
// Validate dimensions first so we fail before mutating the index.
for (const e of entries) {
if (e.vector.length !== this.dimension) {
throw new Error(`DiskannSnapshot.add: entry "${e.id}" has dim ${e.vector.length}, ` +
`expected ${this.dimension}`);
}
}
// Note: the published @ruvector/diskann@0.1.0 declares
// `insertBatch(entries: Array<[string, number[]]>)` in its .d.ts,
// but the NAPI binding rejects every documented shape with
// "Failed to convert JavaScript value ... into rust type String".
// Until upstream fixes the binding contract, route through the
// single-insert API which works. End result is identical from the
// caller's perspective; the per-call overhead is small relative
// to the build pass that follows.
for (const e of entries) {
this.index.insert(e.id, e.vector);
}
this.built = false; // require re-build after new inserts
}
/**
* Build (or rebuild) the Vamana graph. Async — yields to the
* event loop during construction so callers can show progress.
* Required before `search()`.
*/
async build() {
await this.index.buildAsync();
this.built = true;
}
/**
* Query for the `k` nearest neighbors. `build()` must have been
* called at least once after the most recent `add()`.
*/
async search(query, k) {
if (!this.built) {
throw new Error('DiskannSnapshot.search: index has unbuilt inserts — call build() first');
}
if (query.length !== this.dimension) {
throw new Error(`DiskannSnapshot.search: query dim ${query.length} doesn't match index dim ${this.dimension}`);
}
const raw = await this.index.searchAsync(query, k);
return raw.map((r) => ({ id: r.id, distance: r.distance }));
}
/** Remove a vector by id. */
delete(id) {
return this.index.delete(id);
}
/** Current vector count. */
count() {
return this.index.count();
}
/**
* Persist the snapshot to a directory. Reload with
* `DiskannSnapshot.load(dir, options)`.
*/
save(dir) {
this.index.save(dir);
}
}
/**
* Detect whether `@ruvector/diskann` is reachable. Useful for
* `ruflo doctor` / observability reporting alongside the existing
* `hyperbolicAttentionAvailable` and `probeRuvectorSidecar` probes.
*/
export async function diskannAvailable() {
return (await loadDiskAnn()) !== null;
}
//# sourceMappingURL=diskann-snapshot.js.map