diff --git a/CHANGELOG.md b/CHANGELOG.md
index f59212d..0a35031 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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,
diff --git a/README.md b/README.md
index 1f279bb..e5bfae8 100644
--- a/README.md
+++ b/README.md
@@ -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). |
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 38faf54..2db9a75 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -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`).
@@ -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()`)
diff --git a/docs/features.md b/docs/features.md
index eef3e16..5d5e73a 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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:
diff --git a/examples/20-ivf-autotune.html b/examples/20-ivf-autotune.html
new file mode 100644
index 0000000..c16ca68
--- /dev/null
+++ b/examples/20-ivf-autotune.html
@@ -0,0 +1,104 @@
+
+
+
Instead of hand-picking nprobe, pass targetRecall: 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 stats().nprobe / stats().tunedRecall. GPU-only, like all IVF.