Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ the public API may still shift between minor versions).
## [Unreleased]

### Added
- IVF auto-tuning: `ann: { targetRecall: 0.95 }` replaces hand-picking
`nprobe`. After each k-means build the index estimates recall@10 on ≤ 32
sample queries drawn from the corpus (one exact scan each) and adopts the
smallest `nprobe` whose estimated recall meets the target — one scan per
query prices every candidate `nprobe` at once via the rank of each true
neighbour's cluster (`src/index/ivfTune.ts`). Works on fp32 IVF and the
IVF × quant combo (tuned in the quantized space the index scans; the exact
fp32 re-rank still covers quantization loss). An explicit `nprobe` disables
tuning; per-query `{ nprobe }` overrides still apply. `stats()` now reports
`nprobe` (the effective default) and `tunedRecall` (the tuner's estimate).
New example: `examples/20-ivf-autotune.html`.
- Metadata filtering (FR-7): `query()`/`queryText()`/`queryBatch()` accept a
Mongo-ish `filter` in `QueryOptions` — bare values for equality plus `$eq`,
`$ne`, `$in`, `$gt`/`$gte`/`$lt`/`$lte` (AND across fields; unknown operators
Expand Down Expand Up @@ -49,6 +60,9 @@ the public API may still shift between minor versions).
benchmarks can't drift apart on what "GPU time" or "recall" means.

### Fixed
- A per-query `{ nprobe }` override is now truly consumed by that query alone;
previously it silently stuck as the default for all subsequent queries on
the same IVF store.
- `transformersEmbedder()` now sets `env.allowLocalModels = false` before
loading a model. transformers.js defaults this to `true` and probes a local
`/models/...` path first; since this library never ships local model files,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Each links to a short guide with runnable code:
| [Persistence](./docs/features.md#persistence-m2) | Versioned binary snapshots to OPFS (or IndexedDB), auto-load on `create()`, export/import as a `Blob`. |
| [Encryption at rest](./docs/features.md#encryption-at-rest-m6) | AES-256-GCM + PBKDF2 passphrase envelope for persisted/exported snapshots. |
| [Quantization (TurboQuant)](./docs/features.md#quantization--turboquant-int8-m3a) | int8/int4/1-bit codes via randomized Hadamard rotation + exact fp32 re-rank — ~4×/8×/32× less memory. |
| [Approximate search (IVF)](./docs/features.md#approximate-search--ivf-m4) | GPU-assisted k-means clustering; queries scan only the nearest `nprobe` clusters. Combines with quantization for the ~1M-row path. |
| [Approximate search (IVF)](./docs/features.md#approximate-search--ivf-m4) | GPU-assisted k-means clustering; queries scan only the nearest `nprobe` clusters. Combines with quantization for the ~1M-row path. Set `targetRecall: 0.95` instead of `nprobe` and the index auto-tunes itself against recall measured on your own data. |
| [Graph search (HNSW)](./docs/features.md#graph-search--hnsw-m7) | Layered proximity graph with O(log N) beam search — incremental inserts (no rebuild), all metrics, works without WebGPU. Graph persists in snapshots, so loads skip the rebuild (~180×). |
| [GPU graph search](./docs/features.md#gpu-graph-search-m7b) | CAGRA-style WGSL kernel: the whole beam search in one dispatch, one workgroup per query — `queryBatch()` searches many queries concurrently (~4× the CPU walk at 60k×768×128). |
| [Text retrieval / embedder](./docs/features.md#text-retrieval--on-device-embedder-m5) | `addText`/`queryText` via a zero-dep hashing embedder or an optional real semantic model (transformers.js). |
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ linked from each entry below.

A discriminated union on `type`. Omitting `type` (or `'ivf'`) selects IVF, so pre-M7 configs are unchanged.

**`IVFConfig`** — `type?: 'ivf'`, `nlist` (default ≈ `sqrt(count)`, clamped `[16, 4096]`), `nprobe` (default ≈ 5% of `nlist`), `sampleSize` (default `50_000`, reservoir sample for k-means training), `iters` (default `8` Lloyd iterations), `seed`.
**`IVFConfig`** — `type?: 'ivf'`, `nlist` (default ≈ `sqrt(count)`, clamped `[16, 4096]`), `nprobe` (default: the auto-tuned value when `targetRecall` is set, else ≈ 5% of `nlist`; setting it explicitly disables auto-tuning), `targetRecall` (0–1, e.g. `0.95` — auto-tune `nprobe`: after each build, recall@10 is estimated on ≤ 32 sample queries drawn from the corpus, one exact scan each, and the smallest `nprobe` meeting the target becomes the default; the result surfaces as `stats().nprobe` / `stats().tunedRecall`), `sampleSize` (default `50_000`, reservoir sample for k-means training), `iters` (default `8` Lloyd iterations), `seed`.

**`HNSWConfig`** — `type: 'hnsw'`, `M` (graph out-degree per layer, layer 0 keeps 2·M; default `16`), `efConstruction` (build beam width; default `200`), `efSearch` (query beam width, clamped ≥ k; default `64`), `seed` (level RNG, reproducible builds), `search` (`'cpu' | 'gpu'`, default `'cpu'` — `'gpu'` runs the single-dispatch beam-search kernel; needs WebGPU, `M ≤ 32`, `efSearch ≤ 256`, corpus within one storage buffer, and shines on `queryBatch`).

Expand Down Expand Up @@ -135,7 +135,7 @@ Execution picks a strategy per query:

### `Stats` (from `stats()`)

`count`, `deleted?`, `dimension`, `metric`, `device: 'webgpu' | 'wasm'`, `lastQueryMs?`, `lastQueryGpuMs?` (GPU kernel portion), `lastQueryCpuMs?` (CPU re-rank/filtering portion), `persist?: 'opfs' | 'indexeddb'`, `quantBits`, `nlist?` (IVF cluster count once built), `maxLevel?` (HNSW top graph layer once non-empty), `graphSearch?: 'gpu' | 'cpu'` (which engine answers HNSW queries), `chunks?` (>1 once the corpus spans multiple GPU buffers), `ingest?: 'worker' | 'main-thread'` (where quantized rotate+quantize ran), `train?: 'worker' | 'main-thread'` (where the ANN index build ran — IVF k-means updates, or HNSW graph construction).
`count`, `deleted?`, `dimension`, `metric`, `device: 'webgpu' | 'wasm'`, `lastQueryMs?`, `lastQueryGpuMs?` (GPU kernel portion), `lastQueryCpuMs?` (CPU re-rank/filtering portion), `persist?: 'opfs' | 'indexeddb'`, `quantBits`, `nlist?` (IVF cluster count once built), `nprobe?` (IVF default clusters-per-query — explicit, auto-tuned, or the 5% heuristic — once built), `tunedRecall?` (estimated recall@10 at the auto-tuned `nprobe`; only present when `targetRecall` tuning ran), `maxLevel?` (HNSW top graph layer once non-empty), `graphSearch?: 'gpu' | 'cpu'` (which engine answers HNSW queries), `chunks?` (>1 once the corpus spans multiple GPU buffers), `ingest?: 'worker' | 'main-thread'` (where quantized rotate+quantize ran), `train?: 'worker' | 'main-thread'` (where the ANN index build ran — IVF k-means updates, or HNSW graph construction).

### `SupportInfo` (from `isSupported()`)

Expand Down
26 changes: 26 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,32 @@ Real embeddings cluster well, so recall@10 ≥ 0.95 is reachable at a small

> **Try it:** [`examples/03-ivf-index.html`](../examples/03-ivf-index.html)

**Auto-tuning (`targetRecall`).** Rather than hand-picking `nprobe`, state the
recall you want:

```ts
const db = await BrowserVec.create({
dimension: 768,
metric: 'cosine',
ann: { targetRecall: 0.95 }, // auto-tune nprobe to ≈95% recall@10
});
```

After each k-means build the index estimates recall on ≤ 32 sample queries drawn
from the corpus itself and adopts the smallest `nprobe` whose estimated recall
meets the target. One exact scan per sample query prices *every* candidate
`nprobe` at once: a true neighbour is found at `nprobe = p` exactly when its
cluster ranks among the query's `p` nearest centroids, so recall-vs-nprobe falls
out of a histogram of those ranks — no per-`nprobe` re-querying. Easy corpora
get a faster index, hard ones a more thorough one. The choice surfaces as
`stats().nprobe` and `stats().tunedRecall`; an explicit `nprobe` disables
tuning, and per-query `{ nprobe }` overrides still apply. Works on fp32 IVF and
the IVF × quant combo (there the tuner measures the clustering loss in the
quantized space the index scans; quantization loss itself is still covered by
the exact fp32 re-rank). Tuning cost: ≤ 32 exact GPU scans per rebuild.

> **Try it:** [`examples/20-ivf-autotune.html`](../examples/20-ivf-autotune.html)

**IVF × int8 (the 1M path).** Add `quantBits: 8` to an `ann` store and the corpus
is both clustered *and* int8-quantized — so ~1M×768 fits in a single ~1 GB buffer
*and* each query scans only the probed lists:
Expand Down
104 changes: 104 additions & 0 deletions examples/20-ivf-autotune.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BrowserVec — 20. IVF auto-tuning</title>
<style>.bv-nav{font-size:.78rem;margin:0 0 1rem;color:#888;}.bv-nav a{color:#888;text-decoration:none;}.bv-nav a:hover{color:#1a73e8;text-decoration:underline;}body{font:14px/1.5 ui-monospace,monospace;margin:2rem;max-width:780px}pre{background:#111;color:#b9f;padding:1rem;border-radius:6px;overflow:auto}button{font:inherit;padding:.4rem .8rem;cursor:pointer}.ok{color:#5c5}.bad{color:#f55}.dim{color:#888}.hl{color:#fe9}h2{font-size:1.1rem;border-bottom:1px solid #333;padding-bottom:.25rem}</style>
</head>
<body>
<p class="bv-nav"><a href="./">&larr; All examples</a> &middot; <a href="../">Home</a></p>
<h1>20. IVF auto-tuning (targetRecall)</h1>
<p class="dim">Instead of hand-picking <b>nprobe</b>, pass <b>targetRecall</b>: after each k-means build the index estimates recall@10 on sample queries drawn from your own data (one exact scan each, ≤32 queries) and adopts the smallest nprobe whose estimated recall meets the target. The choice surfaces as <b>stats().nprobe</b> / <b>stats().tunedRecall</b>. GPU-only, like all IVF.</p>

<pre id="out">ready.</pre>

<p>
<label>vectors <input id="n" type="number" value="50000" /></label>
<label>dim <input id="dim" type="number" value="768" /></label>
<label>target recall <input id="target" type="number" value="0.95" step="0.01" min="0.5" max="1" /></label>
<button id="run">Run auto-tune benchmark</button>
</p>

<script type="module">
import { BrowserVec } from '../src/index.ts';

const $ = (id) => document.getElementById(id);
const out = $('out');
const log = (msg, cls = '') => (out.innerHTML += `\n${cls ? `<span class="${cls}">${msg}</span>` : msg}`);
const rand = (dim) => { const v = new Float32Array(dim); for (let i = 0; i < dim; i++) v[i] = Math.random() * 2 - 1; return v; };

$('run').onclick = async () => {
out.textContent = '';
if (!BrowserVec.isSupported().webgpu) {
log('This example is GPU-only — IVF has no CPU fallback (see docs/features.md#cpu-fallback).', 'bad');
return;
}

const n = +$('n').value;
const dim = +$('dim').value;
const target = +$('target').value;
const k = 10;
const queries = 30;

// Clustered corpus — real embeddings cluster, and IVF needs that structure.
const centers = Array.from({ length: 100 }, () => rand(dim));
const corpus = Array.from({ length: n }, () => {
const c = centers[(Math.random() * centers.length) | 0];
const v = new Float32Array(dim);
for (let d = 0; d < dim; d++) v[d] = c[d] + (Math.random() * 2 - 1) * 0.35;
return v;
});
const records = corpus.map((v, i) => ({ id: `v${i}`, vector: v }));
const qs = Array.from({ length: queries }, () => corpus[(Math.random() * n) | 0]);

// 1. fp32 flat ground truth
log(`--- fp32 flat (exact ground truth) ---`);
const f = await BrowserVec.create({ dimension: dim, metric: 'cosine' });
await f.addBatch(records);
await f.query(qs[0], { k });
let t = performance.now();
const truth = [];
for (const q of qs) truth.push(new Set((await f.query(q, { k })).map((h) => h.id)));
const flatMs = (performance.now() - t) / queries;
log(`fp32 flat: ${flatMs.toFixed(3)} ms/query (exact)`, 'ok');
f.destroy();

const bench = async (db, label) => {
t = performance.now();
await db.addBatch(records);
await db.query(qs[0], { k }); // triggers build (+ tuning, if configured)
const buildMs = performance.now() - t;
const s = db.stats();
await db.query(qs[0], { k }); // warm
t = performance.now();
let hit = 0;
for (let i = 0; i < queries; i++) {
const got = await db.query(qs[i], { k });
hit += got.filter((h) => truth[i].has(h.id)).length;
}
const ms = (performance.now() - t) / queries;
const rec = hit / (queries * k);
const cls = rec >= target ? 'ok' : rec >= target - 0.05 ? 'hl' : 'bad';
log(`\n--- ${label} ---`);
log(`build ${buildMs.toFixed(0)} ms · nlist=${s.nlist} · nprobe=${s.nprobe}${s.tunedRecall !== undefined ? ` · estimated recall ${(s.tunedRecall * 100).toFixed(1)}%` : ' (5% heuristic)'}`, 'dim');
log(`measured recall@${k} ${(rec * 100).toFixed(1)}% · ${ms.toFixed(3)} ms/q · ${(flatMs / ms).toFixed(1)}x vs flat · scans ${(s.nprobe / s.nlist * 100).toFixed(1)}% of clusters`, cls);
db.destroy();
return rec;
};

// 2. Old default: nprobe = 5% heuristic (no tuning).
await bench(await BrowserVec.create({ dimension: dim, metric: 'cosine', ann: {} }), 'IVF, untuned 5% heuristic');

// 3. Auto-tuned: just state the recall you want.
await bench(
await BrowserVec.create({ dimension: dim, metric: 'cosine', ann: { targetRecall: target } }),
`IVF, auto-tuned to targetRecall=${target}`,
);

log(`\nThe tuner picks the smallest nprobe meeting the target on YOUR data —`, 'dim');
log(`easy corpora get a faster index, hard ones a more thorough one, no hand-tuning.`, 'dim');
};
</script>
</body>
</html>
1 change: 1 addition & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ <h2>Core concepts</h2>
<li><a href="02-quantization.html"><span class="tag m3">M3</span> TurboQuant quantization <small>int8 / int4 quantized storage with asymmetric query + exact re-rank</small></a></li>
<li><a href="03-ivf-index.html"><span class="tag m4">M4</span> IVF approximate index <small>cluster-based ANN, nprobe sweep, recall vs speed trade-off</small></a></li>
<li><a href="04-ivf-quant-combo.html"><span class="tag m4">M4</span> IVF × quant combo <small>clustered + quantized — the 1M-vector path with int8 or int4</small></a></li>
<li><a href="20-ivf-autotune.html"><span class="tag m4">M4</span> IVF auto-tuning <small>targetRecall picks nprobe for you — recall estimated on your own data at build time</small></a></li>
<li><a href="18-hnsw-index.html"><span class="tag m6">M7</span> HNSW graph index <small>graph-based ANN, efSearch sweep, incremental inserts, works without WebGPU</small></a></li>
<li><a href="19-hnsw-gpu.html"><span class="tag m6">M7b</span> GPU graph search <small>CAGRA-style beam search in one dispatch — queryBatch, CPU vs GPU crossover</small></a></li>
<li><a href="16-react-hooks.html"><span class="tag" style="background:#e8eaf6;color:#283593;">React</span> React hooks <small>useVectorStore, useSimilaritySearch, useEmbedding, useRetriever — reusable React patterns</small></a></li>
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,14 @@ export class BrowserVec {
const nlist = (this.index as { nlist: number }).nlist;
if (nlist > 0) out.nlist = nlist;
}
if ('nprobe' in this.index) {
const nprobe = (this.index as { nprobe: number }).nprobe;
if (nprobe > 0) out.nprobe = nprobe;
}
if ('tunedRecall' in this.index) {
const recall = (this.index as { tunedRecall: number | undefined }).tunedRecall;
if (recall !== undefined) out.tunedRecall = recall;
}
if ('maxLevel' in this.index) {
const maxLevel = (this.index as { maxLevel: number }).maxLevel;
if (maxLevel >= 0) out.maxLevel = maxLevel;
Expand Down
Loading
Loading