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.
187 lines
9.7 KiB
Markdown
187 lines
9.7 KiB
Markdown
# 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
|
||
```
|