ANN Vector Indexes (HNSW) — Recall Tuning
Recall Is a Dial, Not a Guarantee
AI engineeringAI engineering / vector search
The myth
"My vector DB returns the top 5 nearest chunks. If retrieval is bad, my embeddings or my chunking must be bad."
Actually…
Past a few hundred thousand vectors, your DB is not doing nearest-neighbour search at all. It's doing APPROXIMATE nearest-neighbour (ANN). It returns 5 pretty-close vectors — and silently drops the true best match some fraction of the time. That fraction is a config value you probably never set.
pgvector, Qdrant, Weaviate, Pinecone, Milvus: all HNSW (or IVF) by default.
Quick proof — run this on your own data
Take 200 real queries. Get ground truth with an exact scan:
SET LOCAL enable_indexscan = off; -- force brute force
SELECT id FROM chunks ORDER BY emb <=> :q LIMIT 5;
Then the normal indexed query. Overlap of the two id sets ÷ 5 = your recall@5.
Typical result on 1M vectors with default settings: 0.85–0.93. So ~1 query in 10 is missing its best chunk before the LLM ever sees the prompt. No error, no log line. Just a slightly worse answer.
Why: HNSW is a skip-list of neighbourhoods
Vectors form a multi-layer graph. Search enters at a sparse top layer, greedily walks toward the query, drops a layer, repeats. Greedy walks get stuck in local minima — so you keep a candidate list of size ef_search and explore from all of them.
Bigger ef_search = more of the graph visited = higher recall, more latency. Linear-ish cost, diminishing returns.
The three knobs
- ef_search (query time) — THE dial. Cheap to change, no rebuild. Default is often 40; try 100–200.
SET LOCAL hnsw.ef_search = 120; -- pgvector, per-transaction
- m (build time) — edges per node. 16 default; 32–48 for high-dim or 10M+. Costs RAM.
- ef_construction (build time) — graph quality. 64 → 200 gives better recall at every ef_search, but slower index builds.
Rule of thumb: ef_search must be ≥ your LIMIT. If you fetch top-50 for a reranker with ef_search=40, recall collapses — and that's a very common setup.
Three things practitioners get wrong
- Benchmarking recall on 10k rows. ANN ≈ exact at small scale; the gap only opens at 100k+. Your staging DB lies.
- Filtered search.
WHERE tenant_id = 7 AND ...+ ANN is the danger zone. Post-filtering walks the graph, then throws matches away — a selective filter can return 2 rows out of 5, or zero. Use your DB's native filtered-HNSW, or partition per tenant. - Measuring latency after a rebuild but recall never. Index rebuild + a new embedding model = a different graph. Recall moves silently.
Shape of the tradeoff (1M vecs, 768-dim, one core):
ef_search=40 → p95 3ms recall@10 0.88
ef_search=120 → p95 7ms recall@10 0.97
ef_search=400 → p95 22ms recall@10 0.995
7ms is nothing next to a 900ms LLM call. Most RAG systems are tuned for a latency budget they aren't actually spending.
Takeaway
Recall is a product decision, not a database default. Pick a target (0.95+ for RAG), measure it, set ef_search to hit it.
Your exercise
Measure recall@10 on your own index with 50 real queries using the brute-force comparison above. Then raise ef_search until recall crosses 0.97 and record what it cost you in p95 ms. If the answer is "under 15ms" — why are you still running the default?