UNPKG

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