tidaldb/docs/profiling/usearch-tuning.md
jx12n bb21e69ae6 feat(m12): vector retrieval G1/G2 — recall harness, ANN in RETRIEVE, index tuning
m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe +
POST /vector_search (standalone + region node, merge-by-distance) +
tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force
cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit).
Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs.
Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force.

m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference
vector, related=seed embedding (similar_to), graceful scan-fallback. Cached
per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so
trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains
--feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms.

m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion,
shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover
usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs.
Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears
G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%).
Recall corpus is now clustered (Gaussian mixture) in grid + harness.
2026-06-14 11:07:09 -06:00

187 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# USearch Parameter Tuning
## Summary
The production default (`VectorIndexConfig::default()`) is **M=16,
ef_construction=400, ef_search=200, F16**. m12p3 re-ran the grid search at the
**production shape (1536-D)** — not the historical 128-D — with a real exact
brute-force oracle, and validated the F16/Int8 recall and memory frontier there.
> **What changed in m12p3.** The earlier version of this doc reported an
> *extrapolated* 128-D table (values from published ANN-Benchmarks, not measured
> here). That is replaced below by **measured** 1536-D numbers from
> `cargo run --release --example ann_grid_search`. The example builds a
> `UsearchIndex` and a `BruteForceIndex` oracle over the same deterministic,
> id-keyed corpus and reports recall@10 vs the exact oracle, single-thread
> mean/p99 search latency, build time, and the true `memory_usage()` footprint.
## Grid Search Setup (m12p3, measured)
| Parameter | Values |
|-----------|--------|
| M (connectivity) | 16, 24, 32 |
| ef_construction | 400 |
| ef_search (per-query) | 128, 200, 400, 600 |
| Quantization | F32, F16, Int8 |
| Dataset size | 100,000 vectors (and 1M on the k3s node) |
| Dimensionality | **1536D** (production embedding width) |
| Corpus shape | **clustered (Gaussian mixture)** — see below for why not uniform-random |
| Distance metric | L2 (L2-normalized → equivalent to cosine) |
| Recall metric | recall@10 vs exact brute-force cosine (200-query average) |
| Hardware | Apple Silicon laptop (release build) |
## Method
1. Build a deterministic, id-keyed **clustered** corpus (SplitMix64 Gaussian
mixture — see below) so the run is reproducible, the recall numbers are
comparable across machines, and recall@k is a meaningful metric at scale.
2. Build a `BruteForceIndex` (F32) over the same vectors and compute the exact
top-10 for every query **once** — the ground truth the HNSW approximates.
3. For each `(M, ef_construction)` build the graph once and sweep `ef_search`
(a per-query knob — no rebuild). For quantization, build F32/F16/Int8 at the
recall-frontier graph.
4. Record recall@10, mean + p99 search latency (µs), build time (s), and the true
in-memory footprint, extrapolated to 1M vectors.
### The corpus must be clustered, not uniform-random
A subtle but decisive measurement point: **uniform-random high-dimensional
vectors are a pathological ANN benchmark.** By concentration of measure, every
pair of random unit vectors in 1536-D sits at cosine ≈ 0, so beyond a tiny
perturbation a query's true top-10 is an arbitrary draw from a thick equidistant
shell — recall@10 then measures impossible tie-breaking, not index quality, and
it gets **worse as the corpus grows** (the shell thickens). Measured on
uniform-random data, recall@10 fell from ~0.97 at 10k to **~0.54 at 100k** — a
corpus-size artifact, not an index regression.
Real text/image embeddings instead live on a low-dimensional manifold with
clusters: a point's neighbours are its cluster-mates, distinctly closer than the
bulk. The harness therefore builds a **Gaussian mixture** (`--clusters`,
`--spread-milli`; default ~100 points/cluster, spread 0.5 ⇒ intra-cluster cosine
≈ 0.89, inter ≈ 0) — the shape `related`/`for_you` queries actually run against,
where recall@10 is a meaningful, scale-stable metric. All numbers below use it.
## Results — HNSW parameter sweep (1536D, F16, 100k clustered corpus, measured)
| M / ef_c / ef_s | recall@10 | mean (µs) | p99 (µs) | build (s) | mem/1M (GB) |
|---|---|---|---|---|---|
| **16 / 400 / 128** | **0.9970** | **670** | **1018** | 19.7 | 5.21 |
| 16 / 400 / 200 | 0.9970 | 1129 | 1394 | 19.7 | 5.21 |
| 16 / 400 / 400 | 0.9970 | 2292 | 2719 | 19.7 | 5.21 |
| 16 / 400 / 600 | 0.9970 | 3477 | 4255 | 19.7 | 5.21 |
| 24 / 400 / 128 | 0.9985 | 1074 | 1299 | 39.0 | 5.53 |
| 24 / 400 / 200 | 0.9985 | 1812 | 2239 | 39.0 | 5.53 |
| 24 / 400 / 400 | 0.9985 | 3536 | 4088 | 39.0 | 5.53 |
| 32 / 400 / 200 | 0.9975 | 2331 | 2857 | 42.5 | 5.53 |
Every point clears recall@10 ≥ 0.95 **and** p99 ≤ 10ms (raw single-thread ANN
cost). Two readings:
- **ef_search is the latency lever, and recall saturates early.** At M=16, recall
is already 0.997 at ef_s=128 (**p99 1.0ms**) and does not improve with a wider
beam — only latency grows (ef_s=600 → p99 4.3ms). So the cheap operating point
is ef_s≈128; the [per-query override](#per-query-ef_search-m12p3-now-honored)
lets a latency-sensitive caller pick it without rebuilding.
- **M=24 buys ~0.15% recall for ~1.6× latency and 2× build** — worth it only when
the last fraction of recall matters. M=32 is not better than M=24 here.
## Results — quantization sweep (1536D, M=24, ef_c=400, ef_s=400, 100k, measured)
| quantization | recall@10 | mean (µs) | p99 (µs) | mem/1M (GB) |
|---|---|---|---|---|
| F32 | 0.9985 | 3642 | 5126 | 10.53 |
| **F16** | **0.9960** | 3569 | 4680 | **5.53** |
| Int8 | 0.7150 | 1389 | 1678 | 3.03 |
- **F16 is the right default:** it costs only **0.25%** recall vs F32 (0.9960 vs
0.9985) for **half the memory** — validating the "<1% recall loss" claim *at
1536-D*, not just the 128-D it was previously checked at.
- **Int8 is NOT viable at 1536-D as a drop-in:** recall collapses to **0.715**
(a 28% loss). The memory saving (3.0 vs 5.5 GB/1M) is real but does not justify
losing a quarter of the neighbours; Int8 would need quantization-aware scaling
before it is usable. The roadmap's "Int8 1.8 GB/1M as a RAM fallback" option
is therefore **rejected** at this dim on accuracy grounds.
> **Memory note.** The per-1M figures are the *true* `USearch` footprint
> (`memory_usage()`, graph links + quantized vectors), not a vectors-only
> estimate. F16 ≈ **5.2 GB/1M**, not the ~3.4 GB the roadmap projected from
> `1536 × 2 bytes` alone — the ~1.8 GB difference is the HNSW proximity graph
> (M=16). Size pods at ≈ 5.5 GB/1M for F16, ≈ 10.5 GB/1M for F32.
## Live cross-check (real engine, HTTP)
The standalone grid above measures the index in isolation. The authoritative
end-to-end number is `tidal-stress --verify-recall` against a real server: at
20k/1536-D (clustered), the engine's `/vector_search` returned **recall@10 =
1.0000** vs the brute-force cosine oracle at the default beam and at
`--recall-ef-search 400` confirming both the index quality and that the
per-query `ef_search` is honored over the wire. (The harness's end-to-end p99 of
~19 ms there is HTTP round-trip + 1536-float (de)serialization on one laptop, not
ANN the raw search is ~1 ms per the grid; it is a single-node wire cost, not an
index limit.)
## Decision
**Default stays M=16, ef_construction=400, ef_search=200, F16** the grid
*validates* it at the production shape: recall@10 0.997, p99 1.4 ms raw ANN at
100k/1536-D clustered, 5.2 GB/1M. No change is warranted.
- **Latency-sensitive reads** can drop to `ef_search=128` per request (recall
still 0.997, p99 1.0 ms) via the per-query override.
- **Maximum recall** (e.g. an offline eval) can use M=24, recall 0.9985, at ~1.6×
latency but M=24 is not the default because M=16 already clears G2 (≥0.95) and
G1 (≤10 ms) with wide margin.
- **F32** only if an application cannot tolerate the 0.25% F16 gap; doubles RAM.
- **Int8 rejected** at 1536-D (recall 0.715).
## Per-query `ef_search` (m12p3: now honored)
`M` and `ef_construction` are graph properties fixed at build time. `ef_search` is
a **per-query** knob: a low-latency query can pass `ef_search=64` and a
high-recall query `ef_search=400` against the *same shared index*, concurrently.
Before m12p3, `UsearchIndex` accepted `ef_search` for trait compliance but
**ignored it** (logging a warning) because `USearch` 2.24 has no per-call beam
argument the only knob is the index-global `change_expansion_search`. m12p3
makes the override real and race-free via an `RwLock` epoch guard
(`UsearchIndex::with_expansion`): searches that agree on `ef_search` run in
parallel under a shared guard; only a query that changes the live beam width
takes the exclusive guard for its `(set, search)` window. The override reaches the
wire through `vector_search_items(.., ef_search)` and the `/vector_search`
`ef_search` field, and `tidal-stress --verify-recall --recall-ef-search` sweeps it
open-loop.
The `ef_search` axis in the table above is the recall/latency trade this knob now
controls per request higher `ef_search` recovers more true neighbours at a
latency cost, exactly as the HNSW theory predicts (spec
[07-vector-retrieval.md](../specs/07-vector-retrieval.md)).
## Brute-force → HNSW crossover (m12p3: dimension-aware)
The brute-force backend (exact, used for small slots) scans every vector under a
read lock, at `count × dim` cost. A single fixed crossover is wrong: 10,000
vectors is a 1.3M-FMA scan at 128-D (sub-ms) but a 15.4M-FMA scan at 1536-D (tens
of ms, under a lock that also blocks writers). `usearch_min_vectors(dim)` now
scales the crossover so a brute-force scan stays within ~4M FMAs: 10,000 at/under
128-D (unchanged), 2,600 at 1536-D. Above the crossover a slot is the production
HNSW; below it stays exact. See `storage/vector/registry.rs`.
## Regression Guard
`tidal/tests/vector_usearch.rs`:
- `recall_at_10_above_threshold` default config (M=16, ef_c=400) recall@10 > 0.95.
- `usearch_per_query_ef_search_is_honored` — a wide per-query `ef_search` recovers
strictly more true neighbours than a starved beam (proves the override is live).
## Reproduce
```bash
# Production shape on one laptop (oracle ≈ 0.6 GB at 100k/1536D):
cargo run --release --example ann_grid_search -- \
--corpus 100000 --dim 1536 --queries 200 --k 10
# 1M shape (oracle ≈ 6 GB; run on the k3s node):
cargo run --release --example ann_grid_search -- \
--corpus 1000000 --dim 1536 --queries 200 --k 10
```