Artificial Intelligence

HNSW Tuning in Practice: m, ef_construction, ef_search

Rows of memory modules inside a data center server, representing the memory footprint of a vector index
William Warby

HNSW tuning for vector search comes down to three settings: m, ef_construction and ef_search. The first two shape the graph you build; the third shapes every query you run. Raise any of them and recall improves, paid for in graph memory, index build time or query latency. In pgvector, HNSW beats IVFFlat on the speed-recall tradeoff but builds more slowly and consumes more memory, which is why the right approach is to measure recall against exact search first, change one setting, and re-measure. That measurement discipline is the same one behind KV cache memory sizing for long-context serving: size from measured behaviour, not from vendor defaults.

What each parameter does

m is the maximum number of connections per node in each layer of the HNSW graph. Higher m means a denser graph with a higher recall ceiling, and a graph that costs more memory and takes longer to build and update. ef_construction is the size of the dynamic candidate list used while inserting points: a larger list produces a better-organised graph, at the price of slower inserts and slower index builds. ef_search is the size of the candidate list explored at query time; it trades recall directly against latency and can often be changed without touching the index at all.

ParameterControlsRaise it to gainRaise it and pay
mconnections per layerhigher recall ceilinggraph memory, build and insert time
ef_constructionbuild-time candidate listbetter graph qualityinsert speed, build time
ef_searchquery-time candidate listhigher recall per queryquery latency

The defaults you inherit

Start from what the engine ships before changing anything, and write the defaults into your runbook so later tuning has a baseline. In pgvector, an HNSW index is created with m of 16 connections per layer and ef_construction of 64 unless you override them, and the documentation’s own advice is to keep those defaults unless you are actually seeing low recall. Query time is a separate knob: hnsw.ef_search, the query-time candidate list, defaults to 40 in pgvector, and raising it buys recall at the cost of speed without any rebuild. Because it is a session setting, you can raise it per workload instead of per cluster: use SET LOCAL inside a transaction so a single slow, recall-critical query can run with a larger candidate list while routine traffic keeps the default.

A repeatable tuning procedure

Tune in this order, changing one variable per iteration; the cheapest levers come first and the expensive rebuilds come last.

  1. Build a ground truth set. Pick a sample of real queries, run exact nearest neighbour search (no approximate index, or full reorder), and store the true top-k results.
  2. Measure recall@k. Run the same queries through the HNSW index and compute overlap with the ground truth. Record p50 and p99 latency at the same time.
  3. Raise ef_search first. It is the only knob that needs no rebuild; step it up, re-measure recall and latency, and stop at the smallest value that meets your recall target.
  4. Only then raise m. If the recall target is unreachable at acceptable ef_search, increase m and rebuild the index, then repeat step 3 from the new baseline.
  5. Keep ef_construction at its default unless recall is still short after the m change; it mostly improves graph quality while slowing every insert and the initial build.
  6. Re-check p99 before shipping. Recall targets are meaningless if the tail latency of the wider candidate list breaks your serving budget.

Filtering changes the picture

Filtered vector search is where default tuning quietly fails, because a filter can shrink the searchable region far below what the graph was built for. The ordering of index creation matters too. In Qdrant, payload indexes should exist before you ingest data: creating a payload index after ingestion forces an HNSW rebuild to benefit from it, because the filterable HNSW index only gains its filter-aware edges when it is generated after the payload indexes exist. Qdrant also lets operators fail fast here, with a strict mode that rejects queries filtering on unindexed fields instead of letting them degrade latency for everyone. In pgvector, test filtered queries explicitly, since a WHERE clause applied on top of an approximate index can return fewer true neighbours than unfiltered recall measurements suggest.

Memory and build time

HNSW memory is dominated by the graph plus the raw vectors, so m and your dimension count set your footprint long before traffic does. pgvector supports the vector type up to 2,000 dimensions and halfvec up to 4,000, and halved precision is the standard lever when the graph no longer fits comfortably in RAM. Build time has its own knobs: pgvector builds HNSW indexes significantly faster when the graph fits in maintenance_work_mem, and emits a notice when it no longer does, telling you to raise the setting. Parallel maintenance workers help large builds, with two workers by default. If the store runs beside inference on shared infrastructure, plan rebuild windows around the limit and cold-start behaviour described in Cloud Run GPU inference limits, cold starts and costs, because a rebuild that stalls a serving tier is a production incident, not a tuning detail.

Sources

Keep exploring