Development

Tuning HNSW Parameters Against a Recall Target You Can Measure

Antonio Evans8 min read

A laptop on a workbench shows a small grid of m and ef_search values, with a few cells highlighted as passing a recall threshold.
Key points

Key points

  1. 1Build a golden set of a few hundred queries with exact nearest neighbors computed by brute force, then measure recall against it for every candidate setting.
  2. 2m sets the graph degree and drives memory almost linearly; ef_construction and ef_search buy recall with build time and query latency, not storage.
  3. 3Sweep m from 8 to 64 and ef_search from 16 to 512 with ef_construction at roughly four times m, and pick the cheapest cell that clears your threshold.
  4. 4Predict index size with the pgvector formula (4 * m + 8 * (m + 1)) bytes per vector before you build, so a failed sweep does not cost you a rebuild you cannot afford.

Define the target before you touch a parameter

HNSW tuning goes wrong when the target is a feeling rather than a number. The first deliverable is a golden set: a fixed list of queries, each with the true top-k neighbors computed by exhaustive search. Recall@k is then the fraction of those true neighbors the approximate index returns, which is the standard working definition used in production guidance from BigData Boutique and in the Qdrant recall tutorial.

A useful golden set for tuning has three properties:

  • Size. A few hundred queries is enough to separate 0.90 from 0.95 with acceptable noise. Qdrant's tutorial suggests 20 to 50 query vectors for a quick check, which is fine for spotting gross regressions but too small to pick between two close configurations Qdrant.
  • Provenance. Draw queries from real logs, support tickets or search analytics, not from the documents themselves. Queries written by the same model that produced the embeddings will flatter the index.
  • Fixed ground truth. Compute exact top-k once, store the ids, and reuse them for every experiment. Regenerating ground truth mid-sweep invalidates every earlier measurement.

This mirrors how teams maintain evaluation sets for LLM applications: a fixed test set run before every meaningful change, with cases added as failures are found Langfuse. The golden set is an asset, not a script.

Choose k to match your production retrieval. If your RAG pipeline feeds ten chunks to the model, measure recall@10. If a reranker takes the top 100 and prunes to five, measure recall@100, because a miss at rank 60 still costs you a document the reranker could have promoted.

Build ground truth and the harness

The harness needs two query paths against the same data: exact and approximate. In PostgreSQL with pgvector, exact search means forcing a sequential scan, and approximate search uses the index. The pattern below computes both and stores the recall per query.

-- Ground truth: force a sequential scan, keep the ids
SET LOCAL enable_indexscan = off;
CREATE TEMP TABLE golden AS
SELECT q.id AS query_id, n.doc_id, n.rank
FROM queries q
CROSS JOIN LATERAL (
  SELECT doc_id, row_number() OVER (ORDER BY embedding <-> q.embedding) AS rank
  FROM docs
  ORDER BY embedding <-> q.embedding
  LIMIT 10
) n;

Then measure the approximate side at a given ef_search:

SET hnsw.ef_search = 64;
WITH approx AS (
  SELECT q.id AS query_id, n.doc_id
  FROM queries q
  CROSS JOIN LATERAL (
    SELECT doc_id FROM docs ORDER BY embedding <-> q.embedding LIMIT 10
  ) n
)
SELECT count(*) FILTER (WHERE g.doc_id IS NOT NULL)::float
       / (count(DISTINCT g.query_id) * 10) AS recall_at_10
FROM golden g
LEFT JOIN approx a ON a.query_id = g.query_id AND a.doc_id = g.doc_id;

Two practical notes. First, verify the approximate query actually uses the index with EXPLAIN; pgvector guidance is explicit that some query shapes fall back to a scan, and a silent fallback makes recall look perfect while latency is missing Crunchy Data. Second, run the harness inside a transaction with SET LOCAL so a forgotten enable_indexscan = off does not poison later measurements.

If you use a dedicated vector database, the same logic applies. Qdrant exposes an exact mode via search_params=models.SearchParams(exact=True), and the tutorial includes a ready-made Python helper that returns average recall@k for a list of query vectors Qdrant.

What m and ef_construction actually buy

HNSW builds a layered proximity graph. Each vector is a node, and each node keeps a bounded list of neighbors per layer. m is that bound. ef_construction is the size of the candidate list maintained while inserting a node, so it controls how carefully the builder looks for good neighbors before committing edges.

The consequences are asymmetric:

ParameterBuild timeIndex memoryQuery latencyRecall ceiling
m uprises sharplyrises linearlyrises modestlyraises it
ef_construction uprisesunchangedunchangedraises it, with diminishing returns
ef_search upunchangedunchangedrises roughly linearlyapproaches the ceiling

m is the only parameter that costs memory, so it is the one to economize on. ef_construction is a pure build-time cost, and ef_search is a pure query-time cost. That asymmetry is the whole tuning strategy: spend on ef_construction once during a nightly build, spend on ef_search only as far as your latency budget allows, and keep m as small as the recall ceiling permits.

There is a blunt caution from practice. Build parameters "will slow the speed of index build drastically, while not improving performance" in the sense of speed, though they may raise answer accuracy, per Crunchy Data's write-up Crunchy Data. Treat every increment as something to justify with a measurement.

Build time is not a minor concern at scale. A pgvector user indexing partitions of roughly 40 million rows with m = 16, ef_construction = 200 saw the build stall after the graph outgrew maintenance_work_mem, and the maintainer's remedies included dropping back to the default ef_construction of 64 to build faster pgvector issue 822. Parallel builds help: pgvector's parallel index build was reported to improve HNSW build time by a factor of 30 in tests on a 10 million row, 1,536-dimension dataset Neon.

Predict the memory bill with one formula

pgvector's documented index size estimate is (4 * m + 8 * (m + 1)) bytes per vector for the graph, before the vector data and identifiers. Worked example with 768-dimensional float32 embeddings, 1 million rows:

  • Vector data: 768 × 4 bytes = 3,072 bytes per vector, or about 3.07 GB for 1 million rows.
  • Graph at m = 16: 4 × 16 + 8 × 17 = 64 + 136 = 200 bytes per vector, about 0.20 GB.
  • Graph at m = 32: 128 + 264 = 392 bytes per vector, about 0.39 GB.
  • Graph at m = 64: 256 + 520 = 776 bytes per vector, about 0.78 GB.

So moving from m = 16 to m = 64 adds roughly 0.58 GB on a graph that already sits under 1 GB, but the vector data dominates at 3 GB. The lesson: on unquantized float32 embeddings, m is a rounding error next to the vectors themselves. Choose a reasonable m and stop optimizing it. Where m starts to matter is after you shrink the vectors.

That is why the pgvector maintainer's advice for large datasets is to use halfvec or binary quantization with re-ranking to reduce index size pgvector issue 822. Halving the vector bytes does more for your memory budget than any m reduction. OpenSearch documents the same arithmetic from the other direction: a compression_level of 32x takes a 768-dimensional float32 vector from 3,072 bytes to 96 bytes OpenSearch. Compression is the big lever; m is the fine one.

For an order-of-magnitude sanity check on the algorithm family: HNSW is generally reported to reach 95 percent or higher recall out of the box while using two to five times more memory than IVFFlat BigData Boutique. If your sweep cannot approach 0.95 at any setting, suspicion should fall on embedding quality or chunking before it falls on the index.

Run the sweep and read the front

Keep one variable fixed to make the grid readable: set ef_construction to four times m. This is a heuristic, not a law, but it couples graph quality to graph degree so cells are comparable.

mef_constructionef_search valuesWhat you learn
83216, 32, 64, 128, 256Floor: how far a thin graph can go
166416, 32, 64, 128, 256Common default, usually near the sweet spot
3212832, 64, 128, 256, 512Headroom for hard queries
6425664, 128, 256, 512Diminishing returns, memory ceiling

Procedure:

  1. Build the index at the first m. Record build wall time and pg_relation_size for the index.
  2. For each ef_search, run the harness and record recall@k and p50/p95 query latency.
  3. Rebuild at the next m and repeat. Rebuilds are the expensive part; do them overnight.
  4. Plot recall against p95 latency. The frontier is the lower-right staircase of points; configurations below it are dominated.
  5. Pick the leftmost point whose recall clears the threshold by a comfortable margin, not exactly at it. A margin of a couple of points absorbs drift from new data.

Recall should rise with ef_search and then flatten. The flattening point is the ceiling that m and ef_construction set. If raising ef_search from 256 to 512 buys nothing, you are at the ceiling and must raise m to move it. Qdrant's documentation says the same thing: if hnsw_ef alone does not reach the target, m and ef_construct set the ceiling and changing them requires a rebuild Qdrant.

A worked decision. Suppose your threshold is recall@10 of 0.95 and your p95 budget is 40 ms. The sweep returns 0.93 at m = 16, ef_search = 128 with 18 ms, and 0.96 at m = 16, ef_search = 256 with 31 ms. The second cell wins: it clears the target with margin and leaves headroom in the latency budget. Now suppose the next cells show 0.96 at m = 32, ef_search = 256 with 44 ms. It is over budget and buys no recall, so it is dominated and discarded. The decision rule is always the same: among cells that clear the recall threshold, take the one with the lowest p95 that still leaves margin, and prefer the smaller m when latency ties.

One caveat about what recall@k measures. A paper on ANN evaluation argues that Recall@k counts identifier matches and overstates the cost of approximation, and that distance-based measures such as the inverse approximation ratio track downstream quality more faithfully, remaining stable even when Recall@k drops substantially arXiv. Practical reading: use recall@k as your tuning target because it is cheap and unambiguous, but if two configurations differ by half a point, break the tie with a downstream evaluation rather than with more recall math. Additional metric definitions are covered in this evaluation metrics overview.

Keep the target from drifting

An approximate index does not fail loudly. It returns k plausible neighbors, silently omitting some true ones, with no error and no log entry, which is why tooling like pg_recall_guard exists to compare a live index against approved ground truth and report drift. You can build the same loop with the harness above: run it on a schedule, store the recall figure, and alert when it drops below the margin you chose.

Two things cause drift in practice. Deletes leave dead entries that the graph still traverses, and growth shifts the neighbor structure. pgvector's HNSW does support update and delete, and the algorithm is designed for incremental addition rather than requiring a fully populated table before the build Jonathan Katz. That is a comfort, not a guarantee: schedule a recall check after bulk changes as well as on a calendar.

Wire the check into continuous integration. Fail the build when recall falls below the threshold, which catches regressions from an embedding model swap or an index configuration change before they reach users, exactly the pattern Qdrant recommends Qdrant. If you run hybrid retrieval with BM25 and reciprocal rank fusion alongside dense search, keep the ANN recall check separate from relevance scoring: rank fusion can mask a degraded vector index because the lexical arm keeps returning good results.

Finally, revisit m when the data changes character, not on a schedule. New document types, a different embedding model, or a shift in query length can move the recall ceiling. Re-run the sweep on the same golden set and compare.

When the tuning is done and you would rather spend the engineering time on retrieval quality than on running five embedding models behind five different APIs, Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible key at https://quantaintelligence.ai/inference.

FAQ

FAQ

How many queries does my golden set need?

A few hundred is a sensible default for tuning, because smaller sets cannot distinguish configurations that differ by a point or two of recall. Qdrant's tutorial uses 20 to 50 query vectors for a fast interactive check, which is adequate for catching gross regressions but not for choosing between close settings Qdrant. Once you settle on a production configuration, keep the larger set as your regression baseline.

Should I tune ef_search or rebuild with a higher m first?

Measure the ef_search curve first, because it costs nothing to change and requires no rebuild. If recall plateaus below your target while ef_search keeps rising, you have hit the ceiling that m and ef_construction set, and only a rebuild will lift it Qdrant. Always check whether the storage or memory arithmetic even permits a larger m before you spend a night rebuilding.

Why is my index so much larger than the formula predicts?

The (4 * m + 8 * (m + 1)) bytes per vector covers the graph, not the vector data. With 768-dimensional float32 embeddings, the raw vectors alone are 3,072 bytes per row, which typically dwarfs the graph OpenSearch. If size is the problem, reduce the vector representation with halfvec, binary quantization with re-ranking, or a compression level, rather than shrinking m pgvector issue 822.

My build never finishes. What do I check first?

Look for the notice that the graph no longer fits into maintenance_work_mem, then raise that setting properly. A pgvector user hit exactly this at roughly 40 million rows per partition, and the maintainer noted that SET LOCAL does not apply to a concurrent index build because it runs outside a transaction, and suggested the default ef_construction of 64 for a faster build pgvector issue 822. Parallel builds are the other large lever, reported to cut HNSW build time by a factor of 30 in one benchmark Neon.

Is recall@k the right metric to optimize at all?

It is a good tuning target because it is simple, judge-free and cheap to compute from a golden set, and it is the metric the ANN tooling reports by default Qdrant. Recent work argues it overstates the cost of approximation and that distance-ratio measures track downstream quality better, staying stable while recall@k falls arXiv. Use recall@k to narrow the field, then confirm the finalists with an end-to-end evaluation of the task you actually care about.

Sources

Sources

  1. HNSW vs IVFFlat: How to Choose the Right Vector Index - BigData Boutique
  2. Measuring ANN Recall
  3. Golden dataset evaluation: build and maintain LLM test sets
  4. HNSW Indexes with Postgres and pgvector | Crunchy Data Blog
  5. HNSW index creation is stuck on dozens millions entries #822 - GitHub
  6. pgvector: 30x Faster Index Build for your Vector Embeddings
  7. Memory-optimized vectors
  8. ANN Search: Recall What Matters
  9. Evaluation Metrics Revisited: Recall, Precision, Latency
  10. pg_recall_guard 0.2.4: Watch vector indexes for recall drift ...
  11. An early look at HNSW performance with pgvector | Jonathan Katz
  12. Introducing reciprocal rank fusion for hybrid search - OpenSearch
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles