How to Tune the HNSW M Parameter: Pick the Smallest Graph That Hits Your Recall SLO
M is the one HNSW build parameter you cannot change without a rebuild. What it does, what it is called in hnswlib, Faiss, pgvector, Qdrant, Weaviate, Milvus, Elasticsearch and OpenSearch, the memory formula, and a Python sweep that logs recall@10, p99 and bytes per vector.
The page that arrives at 3am is rarely about M. It is about a node that OOM-killed during a reindex, or a recall regression that showed up two weeks after someone “bumped M to 64 for safety”. Both are the same mistake seen from different ends: M was chosen by folklore instead of measurement, and because M is baked into the graph at build time, the only way out was a full rebuild under load. This is how to tune the HNSW M parameter from the operator’s side: what it physically changes in the graph, the one chart that tells you if it is right, and a sweep you can run before the index ships instead of after it pages you.
What M actually is
In the Malkov and Yashunin paper, M is the number of bidirectional links each new element gets when it is inserted. Two derived values matter operationally. The bottom layer, where nearly every vector lives, allows Mmax0 connections per element, and the authors report that 2 * M is “a good choice” for it, with higher values giving “performance degradation and excessive memory usage”. The level multiplier that decides how many vectors get promoted to upper layers is set to 1 / ln(M). Mainstream implementations hard-code both, which is why the paper calls M “the only meaningful construction parameter left for the user” and puts its reasonable range at 5 to 48.
The same paper gives the direction of the tradeoff: smaller M “generally produces better results for lower recalls and/or lower dimensional data, while bigger M is better for high recall and/or high dimensional data”. The hnswlib parameter notes say the same thing in terms of intrinsic dimensionality, widen the legal range to 2 to 100, and add a heuristic worth remembering: M * ef_construction can be treated as roughly constant, so raising M lets you lower ef_construction for a similar-quality graph.
M’s second effect is memory, and it is linear. The Faiss index guidelines put an HNSW index at d * 4 + M * 2 * 4 bytes per vector; hnswlib describes the graph side as roughly M * 8-10 bytes per element; OpenSearch’s k-NN docs estimate 1.1 * (4 * dimension + 8 * M) bytes per vector and work the example of a million 256-dimensional vectors at M=16 to about 1.267 GB. Plug your own numbers in: at 768 dimensions the vector is 3,072 bytes, so going from M=16 to M=48 adds 256 bytes of graph per vector, about 8 percent. At 128 dimensions the same change nearly doubles the index. Whether M is a free knob or an expensive one is a function of your embedding width, not of the database. The memory sizing guide walks the full arithmetic.
Where the knob lives, per engine
| Engine | Name | Default | Notes |
|---|---|---|---|
| hnswlib | M in init_index | 16 | Range 2 to 100 per ALGO_PARAMS |
| Faiss | HNSW<M> in the index string, e.g. HNSW32 | none | Guidelines suggest 4 to 64 |
| pgvector | WITH (m = ...) | 16 | ”max number of connections per layer” |
| Qdrant | hnsw_config.m | 16 | Also settable per named vector |
| Weaviate | maxConnections | 32 | Zero layer gets 2 * maxConnections; immutable |
| Milvus | M | 30 | Allowed [2, 2048], recommended [5, 100] |
| Elasticsearch | index_options.m | 16 | Applies to hnsw, int8_hnsw, int4_hnsw, bbq_hnsw |
| OpenSearch | method.parameters.m | 16 | ”Keep between 2 and 100”; not updatable after creation |
Defaults are from each project’s docs as linked in Sources. The Weaviate reference and the OpenSearch methods page both mark the parameter as not changeable after creation; that is true everywhere, whether or not the docs say so. The graph is the value.
The metric that matters
Do not tune M against recall alone; recall at a given ef_search almost always rises with M, so a recall-only chart tells you to keep turning the knob. The number that decides M is recall@k at the ef_search you will actually run, plotted against bytes per vector, with p99 as a constraint rather than an objective.
Define recall@k per query as the size of the intersection between the ANN top-k and the exact top-k, divided by k, and average it over a query set drawn from production logs rather than from the corpus. Fix ef_search at the production value first, because it is the cheaper knob and the one you can change without a rebuild; the ef_search post covers how to set it. Then the M decision is: the smallest M whose recall@k curve, at that ef_search, clears your SLO with margin, at a p99 you can live with. Every step of M above that is memory spent on recall you have already got.
Wiring it up
The sweep below builds one hnswlib index per candidate M against a fixed ef_construction, measures recall@10 and p99 at the production ef_search, sizes the serialized index, and logs everything to MLflow so the runs stay comparable when someone repeats the exercise after the next embedding model swap. The API calls match the hnswlib README: init_index, add_items, set_ef, knn_query, save_index.
import os
import tempfile
import time
import hnswlib
import mlflow
import numpy as np
DIM, N, K = 768, 200_000, 10
EF_SEARCH = 64 # what production will run; fix this first
EF_C = 200 # hold constant across the sweep
M_GRID = [8, 12, 16, 24, 32, 48]
xb = np.load("corpus.npy").astype(np.float32)[:N] # unit-normalised
xq = np.load("queries.npy").astype(np.float32) # logged prod queries
# Exact ground truth, computed once. 200k x 768 brute force fits in RAM.
gt = np.argpartition(-(xq @ xb.T), K, axis=1)[:, :K]
def recall_at_k(pred, truth):
hits = [len(set(p) & set(t)) for p, t in zip(pred, truth)]
return float(np.mean(hits)) / K
mlflow.set_experiment("hnsw-m-sweep")
for M in M_GRID:
with mlflow.start_run(run_name=f"M={M}"):
idx = hnswlib.Index(space="ip", dim=DIM)
idx.init_index(max_elements=N, ef_construction=EF_C, M=M)
t0 = time.perf_counter()
idx.add_items(xb, np.arange(N))
build_s = time.perf_counter() - t0
idx.set_ef(EF_SEARCH)
preds, lat = [], []
for q in xq:
t = time.perf_counter()
labels, _ = idx.knn_query(q[None, :], k=K)
lat.append(time.perf_counter() - t)
preds.append(labels[0])
path = os.path.join(tempfile.gettempdir(), f"hnsw_m{M}.bin")
idx.save_index(path)
bytes_per_vec = os.path.getsize(path) / N
mlflow.log_params({"M": M, "ef_construction": EF_C, "ef_search": EF_SEARCH})
mlflow.log_metrics({
"recall_at_10": recall_at_k(preds, gt),
"p99_ms": float(np.percentile(lat, 99)) * 1000,
"build_s": build_s,
"bytes_per_vector": bytes_per_vec,
"graph_bytes_per_vector": bytes_per_vec - DIM * 4,
})
Build on a sample large enough to have real upper layers; a 10k-vector toy index makes every M look identical. And query with real traffic: recall on queries sampled from the corpus is inflated because the query’s own neighborhood is already wired into the graph.
What you’ll see
On the good chart, recall@10 climbs steeply from M=8 to somewhere in the teens and then flattens, while graph bytes per vector keep rising linearly. The knee is your answer. The paper’s own figures (author-run, not an independent benchmark) show this shape on SIFT-class data, and the independent ANN-Benchmarks results for hnswlib and hnsw(faiss) are the place to sanity-check the recall versus queries-per-second frontier for a dataset like yours before trusting a single sweep.
Two bad shapes are diagnostic. If recall keeps climbing all the way to M=48 with no knee, the embedding space has high intrinsic dimensionality and you are in the regime the paper says wants bigger M; check whether a lower-dimensional or better-trained embedding model fixes it more cheaply than graph memory does. If recall is flat and low across the whole grid, M is not your problem. Look at ef_construction, at ef_search being clamped below k, or at post-filtering that starves the candidate list; the recall troubleshooting post lists those in order.
p99 rises gently with M at fixed ef_search, because each hop visits more neighbors, but the real latency cliff is the larger graph no longer fitting in RAM. That never shows up in an offline sweep, only on the serving dashboard, which is why recall@k and index bytes belong next to latency on the same panel; SentryML’s notes on model monitoring cover treating retrieval quality as a production metric rather than a one-off eval.
Caveats
- M is immutable. Every engine in the table rebuilds the graph to change it. Run the sweep before the first production build, and budget a rebuild when the embedding model changes, because the optimal M moves with the data.
- Layer zero is double. Weaviate documents this explicitly and it is true of the reference implementation. Memory estimates that use plain M under-count the bottom layer by half.
- Milvus defaults differ. M=30 and
efConstruction360 are higher than everyone else’s 16 and 64 to 128. Porting a config between engines without renormalising will silently change your recall and footprint. - Quantised indexes shift the ratio. With INT8, INT4 or BBQ storage the vector shrinks and the graph becomes a larger share of memory, so “M is nearly free at 768 dimensions” weakens as you quantise.
- Ground truth goes stale. The exact top-k you computed for the sweep is only valid for that corpus snapshot. Regenerate it with each reindex or the recall number stops meaning anything.
Related across the network
- LangChain Building Blocks: Chains, Tools and Agent Control — langchainguide.com
- LangChain RAG Pipeline: Setup to First Answer — langchainguide.com
- LangChain vs LangGraph vs LlamaIndex Compared — langchainguide.com
- Qdrant vs Milvus vs Pinecone: Vector DB Comparison — ragstackguide.com
- RAG Chunking Strategy: Picking Chunk Size and Overlap — ragstackguide.com
Sources
- Malkov, Yashunin: Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs (arXiv:1603.09320)
- hnswlib: ALGO_PARAMS.md (M, ef_construction, ef)
- Faiss wiki: Guidelines to choose an index
- pgvector README: HNSW index options
- Qdrant docs: Indexing (HNSW parameters)
- Weaviate docs: Vector index parameters (maxConnections)
- Milvus docs: HNSW index parameters
- Elasticsearch docs: dense_vector field type (index_options)
- OpenSearch docs: k-NN methods and engines (HNSW parameters)
- OpenSearch docs: k-NN index memory estimation
- ANN-Benchmarks
Related
HNSW ef_search Parameter Explained: The Knob That Trades Recall for p99
ef_search is the beam width of an HNSW query. What it controls, what it is called in hnswlib, Faiss, pgvector, Qdrant, Weaviate, Milvus and Elasticsearch, the recall@k metric that tells you if it is set right, and a Python sweep that exports it to Prometheus.
How to Choose a Vector Database: Fix the Recall Target, Measure on Your Data, Then Shop
How to choose a vector database for production retrieval: the five constraints that decide it, the one metric to benchmark on your own corpus, a Python harness to measure it, and where pgvector, Qdrant, Milvus, Weaviate, Pinecone and Elasticsearch actually differ.
Qdrant vs Weaviate vs Milvus: Filtered Recall, RAM and Scale-Out Compared
A qdrant vs weaviate vs milvus comparison for teams putting retrieval in production: index and quantization options, what a metadata filter does to each engine, multi-tenancy limits, cluster mechanics, and the one metric to decide on.