NuclaDB
Design Decisions

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.

efNuclaDB recall@10Qdrant recall@10NuclaDB QPSQdrant QPSNuclaDB p50 / p95Qdrant p50 / p95
100.9320.9591391476240.07 / 0.09 ms0.13 / 0.17 ms
200.9810.9891396674510.07 / 0.09 ms0.13 / 0.15 ms
500.9960.9981065370510.09 / 0.11 ms0.14 / 0.16 ms
1000.9981.000754264650.13 / 0.16 ms0.15 / 0.17 ms
2001.0001.000582255120.17 / 0.21 ms0.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.

On this page