Tuning HNSW: what the recall/latency curve looks like
The full recall@10/QPS sweep on SIFT-small, and why efSearch is a per-query parameter.
efSearch controls how large a candidate list HNSW keeps while searching. A higher ef means better recall at the cost of latency. NuclaDB exposes it as a per-query parameter (ef_search in the API, -ef on the CLI) instead of a fixed server-wide setting. Here's the curve that decision is based on.
The sweep, against Qdrant on the same dataset
10,000 base vectors, 100 queries, dim=128, SIFT-small. Each ef gets one warm-up pass, then 5 measured passes; QPS is the median pass, and p50/p95 latency is over all 500 measured queries.
| ef | NuclaDB recall@10 | Qdrant recall@10 | NuclaDB QPS | Qdrant QPS | NuclaDB p50 / p95 | Qdrant p50 / p95 |
|---|---|---|---|---|---|---|
| 10 | 0.932 | 0.959 | 13914 | 7624 | 0.07 / 0.09 ms | 0.13 / 0.17 ms |
| 20 | 0.981 | 0.989 | 13966 | 7451 | 0.07 / 0.09 ms | 0.13 / 0.15 ms |
| 50 | 0.996 | 0.998 | 10653 | 7051 | 0.09 / 0.11 ms | 0.14 / 0.16 ms |
| 100 | 0.998 | 1.000 | 7542 | 6465 | 0.13 / 0.16 ms | 0.15 / 0.17 ms |
| 200 | 1.000 | 1.000 | 5822 | 5512 | 0.17 / 0.21 ms | 0.18 / 0.22 ms |
RSS during the sweep: NuclaDB 45.8 to 45.9 MB, Qdrant 116.1 to 116.6 MB.
Recall saturates by ef=50
Going from ef=50 (0.996 recall) to ef=200 (1.000 recall) buys +0.4% recall for about 1.8x the latency. Past ef=50, it's mostly diminishing returns.
How the two compare
NuclaDB has higher QPS at every ef tested: 13914 vs. 7624 at ef=10, narrowing to 5822 vs. 5512 at ef=200. Its RSS stays well under half of Qdrant's. Qdrant's recall is a little higher at low ef (0.959 vs. 0.932 at ef=10), and both reach 1.000 by ef=200.
NuclaDB picks neighbors with the HNSW paper's diversity heuristic. That raised ef=10 recall from 0.84 to 0.91 (it moved with the random level seed) to about 0.93.
Two methodology bugs the benchmark caught
The first run showed Qdrant at a suspiciously perfect 1.0 recall at every ef, including ef=10, which isn't how HNSW behaves. The cause: Qdrant's default full_scan_threshold (10,000 KB) sits above this dataset's raw size (about 5,120 KB), so it was silently doing exact brute-force search. It was fixed by forcing the threshold down.
Later it turned out Qdrant still wasn't building an index at all: it skips HNSW indexing for segments under its indexing_threshold, also 10,000 KB by default. The benchmark now forces indexing and waits until every vector is indexed before searching. Earlier versions of this page also used a single pass per ef, and Qdrant's QPS jumped around between runs; the warm-up pass and median of 5 fixed that.
Why efSearch is per-query, not fixed
There's no single right ef. It depends on the dataset and the recall/latency tradeoff a given query needs. A server-wide default would force every caller onto the same tradeoff, so it's exposed per-query instead. ef_search: 0 falls back to the default of 64, and ef is always raised to at least top_k.
For the compression side of this tradeoff, see Product quantization cost.