Tuning HNSW Vector Indexes
HNSW tuning is the work of picking three or four index parameters so that your approximate nearest neighbor search returns the right vectors fast enough for production. The three knobs that matter are m (graph connectivity), ef_construction (build-time search width), and ef_search (query-time search width). Everything in this guide is about measuring recall and latency for real, then moving those knobs on purpose instead of copying defaults from a README.
If you have shipped a semantic search feature, a RAG retriever, or a recommendation lookup and it either feels slow or returns wrong results, HNSW tuning is almost always the fix. This article covers what HNSW is doing under the hood, how each parameter changes the recall-latency trade, how to build a repeatable benchmark, and how to apply it in pgvector, Qdrant, and a raw hnswlib script.
What HNSW actually is
HNSW stands for Hierarchical Navigable Small World. It is a graph index. Every vector becomes a node, and nodes are connected to a bounded number of neighbors. The "hierarchical" part means the graph is built in layers: the top layer has very few nodes with long-range links, and each lower layer has more nodes with shorter links, until the bottom layer contains every vector. A search starts at the top, greedily hops toward the query vector, drops a layer, repeats, and finishes with a fine-grained walk of the bottom layer.
This matters for tuning because the whole thing is a greedy graph traversal. It is approximate. It can miss the true nearest neighbor if the graph is too sparse or if it stops walking too early. The parameters exist to control exactly those two failure modes: how densely the graph is wired, and how long the walk continues before it gives up.
Two consequences fall out of that:
- A denser graph and a longer walk both raise recall and both cost you something (memory, build time, or query latency).
- There is no single correct setting. The right values depend on your embedding dimension, dataset size, distance metric, and how much recall your product can tolerate.
That is why HNSW tuning is empirical. You cannot reason your way to the numbers. You measure.
The three parameters that control everything
m: how many neighbors each node keeps
m is the maximum number of bidirectional links per node on the lower layers (the top build layer often gets 2 * m). Bigger m means a denser graph, which means more paths to the true neighbors and higher recall. It also means more memory per vector and slower builds.
Typical ranges run from 8 to 64. For most text-embedding workloads, m between 16 and 32 is the sane starting band. Go higher when your vectors are high dimensional (1000+ dims) or when recall is stubbornly low no matter how you set the query knob. Memory scales roughly linearly with m, so doubling m roughly adds the neighbor-list storage per vector.
m is a build-time parameter. You cannot change it without rebuilding the index. Choose it deliberately, because rebuilding a large index is not free.
ef_construction: how hard the builder searches
ef_construction is the size of the candidate list the builder keeps while it is deciding which neighbors to link a new node to. Larger values make the builder consider more candidates, producing a higher-quality graph with better recall at query time. The cost is build time, which grows noticeably as you raise it.
Common values are 64 to 512. A value around 128 to 200 is a reasonable default for datasets in the hundreds of thousands to low millions of vectors. Raising ef_construction has diminishing returns: going from 64 to 128 usually helps recall clearly, going from 256 to 512 often helps only a little while doubling build time. This is also a build-time parameter.
ef_search: how hard each query searches
ef_search (sometimes just ef, or hnsw.ef_search in pgvector) is the size of the candidate list during a query. This is the single most important knob for day-to-day HNSW tuning because it is set at query time. You can change it per query without rebuilding anything.
Higher ef_search walks more of the graph, finds more candidates, and raises recall, at the cost of latency. It must be at least as large as k, the number of results you want. If you ask for the top 10 but set ef_search to 10, you give the walk no room to explore and recall collapses. A good rule of thumb is to start ef_search well above k, often 4x to 10x, then tune from there.
Because ef_search is free to change, the standard workflow is: pick m and ef_construction, build once, then sweep ef_search to find the smallest value that hits your recall target. That sweep is the core of the whole exercise.
Recall is the metric, and you have to measure it
Recall@k is the fraction of the true top-k neighbors that your approximate search actually returned. If the exact top 10 for a query are a set of 10 vectors, and your HNSW index returns 9 of them, that is recall@10 of 0.9. You compute it by comparing HNSW results against a brute-force exact search on the same queries.
You cannot skip this step. Latency without recall is meaningless because you can always be fast by returning garbage. The build-once-sweep-ef workflow only works if you have a recall number to steer by.
Here is a self-contained benchmark using hnswlib and numpy. It builds an index, computes exact ground truth by brute force, then sweeps ef_search and prints recall and latency at each setting.
import time
import numpy as np
import hnswlib
rng = np.random.default_rng(42)
dim = 768
n = 50_000
n_queries = 1_000
k = 10
data = rng.standard_normal((n, dim)).astype(np.float32)
queries = rng.standard_normal((n_queries, dim)).astype(np.float32)
# Exact ground truth by brute force (cosine via normalized dot product)
def normalize(x):
return x / np.linalg.norm(x, axis=1, keepdims=True)
data_n = normalize(data)
queries_n = normalize(queries)
sims = queries_n @ data_n.T
gt = np.argsort(-sims, axis=1)[:, :k]
# Build the HNSW index
index = hnswlib.Index(space="cosine", dim=dim)
index.init_index(max_elements=n, ef_construction=200, M=16)
index.add_items(data, np.arange(n))
def recall_at_k(pred, truth):
hits = 0
for p, t in zip(pred, truth):
hits += len(set(p) & set(t))
return hits / (len(truth) * k)
for ef in [16, 32, 64, 128, 256]:
index.set_ef(ef)
start = time.perf_counter()
labels, _ = index.knn_query(queries, k=k)
elapsed = time.perf_counter() - start
r = recall_at_k(labels, gt)
qps = n_queries / elapsed
print(f"ef_search={ef:>4} recall@{k}={r:.4f} qps={qps:8.1f}")Run that and you get a table. On a typical machine it looks like a curve: recall climbs steeply from low ef_search, then flattens as it approaches 1.0, while queries per second fall off. Your job is to read that curve and pick the point where recall is high enough and latency is acceptable. There is no universal answer, but the shape of the curve tells you where the cheap wins are.
Reading the recall-latency curve
The curve almost always has three regions.
- The steep region at low
ef_search, where a small bump inef_searchbuys a lot of recall for almost no latency. Never operate here unless you genuinely do not care about accuracy. - The knee, where the curve bends. This is usually where you want to be. Recall is high (often 0.95 to 0.99) and latency is still reasonable. Most production systems live near the knee.
- The flat region at high
ef_search, where recall is barely improving but latency keeps rising. Operating here is wasteful. You are paying for accuracy you cannot measure the benefit of.
If you cannot reach your recall target even at high ef_search, that is a signal that ef_search is not your bottleneck. The graph itself is too sparse. Go back and raise m or ef_construction and rebuild. This is the single most common mistake in HNSW tuning: cranking ef_search into the flat region trying to fix a problem that lives in the build parameters.
A practical target for RAG and semantic search is recall@10 around 0.95 to 0.98. Below 0.9 and users start noticing missing results. Chasing 0.999 is usually a waste unless you are doing something precision-critical like dedup or fraud matching.
Tuning HNSW in pgvector
pgvector is Postgres, so the same three knobs show up as index options and a session variable. Here is a full round trip.
Create the index. m and ef_construction are set at build time in the WITH clause.
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);Set the query-time search width. This is a session-level GUC, so set it per connection or per transaction.
SET hnsw.ef_search = 100;
SELECT id, content
FROM documents
ORDER BY embedding <=> '[...query vector...]'
LIMIT 10;The <=> operator here is cosine distance, matching the vector_cosine_ops opclass. Use <-> for L2 and <#> for negative inner product, and match the opclass to the metric your embeddings were trained for. A mismatch there wrecks recall in a way no amount of ef_search tuning will fix.
Two pgvector-specific notes that trip people up.
First, building an HNSW index on a large table is slow and memory hungry. Raise maintenance_work_mem before the build so the graph fits in memory, otherwise the build spills and crawls.
SET maintenance_work_mem = '2GB';Second, pgvector will happily fall back to a sequential scan if the planner thinks it is cheaper, especially with restrictive WHERE filters. Check with EXPLAIN ANALYZE that your query actually uses the HNSW index. Filtered vector search is its own topic, but the short version is that heavy pre-filtering can bypass the index entirely, so verify the plan rather than assuming.
EXPLAIN ANALYZE
SELECT id FROM documents
ORDER BY embedding <=> '[...]'
LIMIT 10;Look for an Index Scan using line referencing your HNSW index. If you see Seq Scan, the index is not being used and your latency numbers are meaningless.
Tuning HNSW in Qdrant
Qdrant exposes the same parameters under its own names. m and ef_construct are collection-level HNSW config, and ef is a per-query search parameter (called hnsw_ef).
Create a collection with explicit HNSW config using the Python client.
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff
client = QdrantClient("localhost", port=6333)
client.create_collection(
collection_name="docs",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)Set the query-time width per search with hnsw_ef.
from qdrant_client.models import SearchParams
hits = client.query_points(
collection_name="docs",
query=query_vector,
limit=10,
search_params=SearchParams(hnsw_ef=128),
).pointsQdrant lets you set hnsw_ef per request, which is exactly what you want for the sweep. Run the same recall benchmark against the collection, varying hnsw_ef, and pick the knee. Qdrant also supports an m=0 trick to disable the global graph when you rely heavily on payload-filtered search with per-segment indexes, but that is an advanced case. Start with the standard config and measure before reaching for it.
A repeatable tuning procedure
Put the pieces together into a procedure you can run every time you onboard a new dataset or embedding model. HNSW tuning is not a one-time ritual; new embeddings change the geometry of your space and can change the right settings.
- Freeze a representative sample of your real vectors and a set of real query vectors. Synthetic random vectors behave differently from real embeddings, which cluster. Tune on data that looks like production.
- Compute exact ground truth by brute force for those queries. This is your recall oracle. Cache it.
- Pick starting build parameters:
m = 16,ef_construction = 200. Build the index. - Sweep
ef_searchacross a range like 16, 32, 64, 128, 256. Record recall@k and latency at each. - Find the knee. If you hit your recall target at acceptable latency, you are done. Ship that
ef_search. - If you cannot reach the target even at high
ef_search, raisem(to 32 or 48) oref_construction(to 256 or 400), rebuild, and sweep again. Build parameters fix ceilings;ef_searchmoves you along the curve under that ceiling. - Re-run the whole procedure whenever the embedding model or data distribution changes.
The discipline that makes this work is measuring recall every time. Latency numbers alone will lie to you.
Memory, build time, and other trade-offs
The knobs do not only trade recall for latency. They also cost memory and build time, and those constraints often decide the final settings more than the recall curve does.
Memory is dominated by m. Each vector stores its raw values plus up to m neighbor links per layer. For high-dimensional vectors the raw storage dominates, but neighbor lists still add up across millions of nodes. If you are memory constrained, keep m modest and lean on ef_search at query time to recover recall, accepting slightly higher latency.
Build time is dominated by ef_construction and, to a lesser extent, m. A large ef_construction on a multi-million-vector table can turn a build into an hours-long job. If you rebuild frequently (say, nightly re-index), a lower ef_construction with a slightly higher ef_search at query time can be the better overall trade, because you pay the build cost far more often than any single query.
Quantization is the other lever worth knowing about. Scalar or product quantization shrinks vectors so more of the index fits in RAM, at some recall cost. Most vector databases let you combine HNSW with quantization. If your index does not fit in memory, quantization plus a modest ef_search bump often beats an unquantized index that spills to disk. Measure it the same way: recall and latency, side by side.
Common HNSW tuning mistakes
- Setting
ef_searchequal to or barely abovek. The walk needs room. Start at several timesk. - Tuning on random vectors. Real embeddings cluster, and the recall curve differs. Always tune on production-like data.
- Cranking
ef_searchinto the flat region to fix low recall. If moreef_searchstops helping, the fix is a denser graph: raisemoref_constructionand rebuild. - Mismatching the distance metric and the opclass or space. Cosine embeddings indexed with L2 will underperform in ways no query tuning can rescue.
- Not verifying the index is actually used. In pgvector especially, check
EXPLAIN ANALYZEfor an index scan, not a sequential scan. - Reporting latency without recall. The two numbers are only meaningful together.
FAQ
What is a good default for m and ef_construction?
Start with m = 16 and ef_construction = 200 for most text-embedding workloads in the hundreds of thousands to low millions of vectors. These are conservative, well-behaved values. Only move them after you have measured and found that ef_search alone cannot reach your recall target, or that build time or memory forces a change.
How high should ef_search be?
Higher than k, always, usually several times higher. Beyond that there is no fixed number: sweep it and pick the knee of the recall-latency curve where recall is high enough and latency is acceptable. Because ef_search is a query-time parameter, you can even vary it per request, using a higher value for queries where accuracy matters more.
Why is my recall low even at high ef_search?
Because the graph is too sparse to reach the true neighbors, and ef_search can only explore the graph that exists. Raise m or ef_construction and rebuild the index. Also confirm your distance metric matches how the embeddings were trained, and that queries are normalized if you are using cosine.
Can I change m or ef_construction without rebuilding?
No. Both are build-time parameters baked into the graph structure. Only ef_search (also called ef or hnsw_ef) is a query-time parameter you can change freely. This is exactly why the standard workflow builds once and sweeps ef_search.
Is HNSW always the right index?
Not always. HNSW gives excellent recall and latency but uses a lot of memory and builds slowly. For very large datasets that must stay on disk, or where memory is tight, an IVF-based index or a disk-oriented approach may fit better. HNSW is the strong default for in-memory workloads up to the low tens of millions of vectors; past that, benchmark alternatives before committing.
How do I measure recall in production?
Sample real queries, run exact brute-force search over your dataset (or a representative shard) to get ground truth, and compare against your HNSW results to compute recall@k. Do this on a schedule, because embedding model updates and data drift can quietly move your recall. Treat recall as a monitored metric, not a one-time check.
Does HNSW tuning change when I switch embedding models?
Yes. Different models produce different dimensionalities and cluster geometry, which shifts the recall-latency curve. Re-run the full tuning procedure (ground truth, build, ef_search sweep) whenever you change the model that produces your vectors, rather than assuming the old settings still hold.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.