An HNSW index gives you exactly three knobs, and only one of them is adjustable after the index exists. M and efConstruction are baked into the on-disk graph the moment the build finishes — changing either means a rebuild. efSearch is the only per-query control. Yet most production "recall is too low" tickets trace to neither: they trace to filtering being applied after the graph walk, and to sharding quietly truncating the candidate set before the merge. Before touching any knob, get a recall baseline you trust — the metric definitions are in the RAG retrieval evaluation checklist. And there is a bigger shift to absorb first. As of 2026, the mainstream default has moved from "float HNSW" to "1-bit quantized HNSW plus rescoring." Elasticsearch has defaulted float vectors with ≥ 384 dimensions to bbq_hnsw since 9.1, and 9.4+ defaults to bbq_disk where the license permits — see the dense_vector field reference. If your capacity model still assumes raw float32 sitting inside the graph, it is off by an order of magnitude.

What each parameter actually buys you

M: the shape of the graph, and your memory floor

M is the number of bidirectional links per node (layer 0 typically gets 2M). It sets navigability, and it sets the portion of memory that has nothing to do with your vector data. hnswlib's ALGO_PARAMS remains the most honest published guidance: the reasonable range is 2–100, M = 12-48 covers most use cases, and memory is roughly M * 8-10 bytes per stored element.

That formula matters more than it looks. For 1M vectors at 768 dimensions with M = 16, the graph alone costs 128–160 MB. As float32 the vectors are 3.07 GB, so the graph is a 5% rounding error. But quantize to 1 bit, as the current defaults do, and the vectors collapse to 96 MB — the graph becomes the single largest thing in memory. That is why teams quantize aggressively and find the memory bill barely moved.

Bigger M is not strictly better. Higher intrinsic dimensionality and higher recall targets want more links, but every traversal step expands proportionally more neighbors, so latency climbs linearly while recall flattens. Lucene caps MAXIMUM_MAX_CONN at 512 and its javadoc warns that exceptionally large values consume an inordinate amount of heap.

efConstruction: paid once, non-refundable

efConstruction sizes the candidate queue during the build. It has zero effect on query latency. The entire cost lands on build time, and the entire benefit lands on graph quality — which is a ceiling. No amount of efSearch recovers a badly built graph.

hnswlib supplies the one calibration procedure I have seen that does not involve copying someone else's number: set query-time ef equal to ef_construction, measure M-nearest-neighbor recall, and if it falls below 0.9, raise ef_construction. Do that on your data instead of inheriting "everyone uses 200."

There is also a build-time cliff worth knowing about in Postgres. pgvector states that indexes build significantly faster when the graph fits in maintenance_work_mem, and emits a notice — hnsw graph no longer fits into maintenance_work_mem after N tuples — once it doesn't, after which build throughput falls off a cliff. Raise maintenance_work_mem first (the docs' own example is SET maintenance_work_mem = '8GB'), or you will misdiagnose a memory shortfall as "efConstruction is too expensive."

efSearch: the only online knob

efSearch goes by several names — hnsw.ef_search in pgvector, hnsw_ef in Qdrant, the kNN query's k in Lucene, which Elasticsearch exposes separately as num_candidates. It is the only parameter you can change per request, and it should be the only thing you touch when trading recall against latency. It must be ≥ k; Qdrant internally enforces ef = max(ef, limit) as a safety net, per its FAQ.

Its curve is sharply non-linear. Going from 40 to 100 usually buys real recall; going from 200 to 400 often buys a fraction of a point while nearly doubling latency. So the goal is not "the optimal efSearch." Plot the recall-latency curve on your own data, find the knee, and expose one step on either side of it as a runtime degradation control — lower under peak load, higher for offline jobs. Hard-coding one value in a config file wastes the only lever you have.

Defaults are not consistent across engines

Engine M / maxConn efConstruction Query-time ef
pgvector 0.8.6 (2026-07-29) m = 16 ef_construction = 64 hnsw.ef_search = 40
Qdrant 1.19 (2026-08-05) m = 16 ef_construct = 100 hnsw_ef falls back to ef_construct
Lucene 10.x DEFAULT_MAX_CONN = 16 (max 512) DEFAULT_BEAM_WIDTH = 100 (max 3200) the kNN query's k; Elasticsearch exposes it as num_candidates, per shard
hnswlib 12–48 recommended (range 2–100) calibrate via the procedure above ef ≥ k

Everyone converged on M = 16. Nobody converged on efConstruction. That disagreement is the finding: M = 16 is a defensible default, efConstruction is the one you must calibrate. pgvector's 64 is the most conservative of the three and usually the first thing worth raising.

The hierarchy may not be earning its keep

Munyampirwa, Lakshman, and Coleman's Down with the Hierarchy: The "H" in HNSW Stands for "Hubs" (arXiv:2412.01940, v3 dated 2025-07-03) reports a counterintuitive result validated across a broader set of large-scale datasets than prior work: on high-dimensional data, a flat navigable small world graph delivers latency and recall essentially identical to full HNSW, with lower memory overhead. Their explanation is the "hub highway hypothesis" — a well-connected set of hub nodes emerges naturally in the flat graph and performs the long-range routing that the explicit layers were supposed to provide.

The original algorithm (Malkov and Yashunin, arXiv:1603.09320, v4 submitted 2018-08-14) motivated the layers by analogy to skip lists. The 2026 takeaway is not that layers are harmful — no shipped engine has removed them — but that the hierarchy is not your tuning surface. Your surface is M, efConstruction, efSearch, and the quantization tier.

Quantization: the bill arrives at rescoring, not compression

PQ is no longer the default answer

Qdrant's quantization docs put it plainly: product quantization reaches up to 64x compression, but its distance computation "is not SIMD-friendly, so it is slower than scalar quantization." PQ therefore spends both accuracy and speed to buy memory. Unless memory is a hard constraint and you have explicitly accepted the latency hit, PQ should not be your default in 2026.

By contrast, int8 scalar quantization gives 4x compression with accuracy loss Qdrant measures at "usually less than 1%." That is the tier to turn on by default.

The shift is visible in FAISS too. Its index selection guidelines now list PQ and RaBitQ side by side under "memory is very important," and release 1.15.0 (2026-07-31, per the CHANGELOG) continued adding SIMD kernels for RaBitQ and newer quantizers across AVX2, AVX512, and RISC-V RVV. When the library that popularized PQ stops presenting it as the sole answer, that is a signal.

Why 1-bit methods won

The turning point was Gao and Long's RaBitQ (SIGMOD 2024): quantize D-dimensional vectors into D-bit strings while providing a provable error bound on the distance estimate. The paper's framing of the problem is the important part — prior methods including PQ achieve practical success but carry no theoretical error bound and are observed to fail badly on some real-world datasets. A bound is what lets you ship 1-bit quantization without a per-dataset gamble.

Elastic's BBQ derives from RaBitQ's insights and, per the BBQ reference, performs asymmetric quantization by default: 1 bit for indexed vectors, 4 bits for the query vector. The same docs caution that datasets below 384 dimensions may see reduced accuracy and pay relatively higher overhead for the corrective factors — which is exactly why Elasticsearch's default splits at 384 and keeps int8_hnsw below it.

A second line is TurboQuant (Zandieh et al., arXiv:2504.19874), which applies a random orthogonal rotation before quantizing each coordinate independently. Qdrant shipped it in 1.18 (2026-05-11) at 4-bit (8x), 2-bit (16x), 1.5-bit (~21x), and 1-bit (32x), and its write-up reports 9–21 percentage points better recall than binary quantization at the same 32x compression.

Rescoring is the hidden line item

Compression ratios are marketing; rescoring is the invoice. Elastic's kNN guidance is refreshingly specific: int8 typically needs little to no rescoring, int4 usually recovers most of its loss with 1.5x–2x oversampling, and bbq commonly requires 3x–5x.

Read that operationally. 3x oversampling means fetching three times the candidates and computing exact distances against the original vectors. Where are those originals? If they are not resident, that is a round of random reads per query. A meaningful share of the memory you saved comes back as random I/O — which looks far worse at P99 than in the mean. Elastic's mechanics make the shard interaction explicit: retrieve num_candidates per shard, then rescore the top k * oversample per shard against the originals.

Qdrant 1.19 makes the trade-off unusually legible. Its new Turbo4 datatype stores 4-bit TurboQuant as the only representation with no full-precision copy, cutting storage from 36 bits per coordinate to 4 — roughly ninefold — and the release notes state directly that without the originals, Qdrant cannot rescore top candidates. That is an honest contract. Just know which one you signed.

Filtering and sharding are where recall actually leaks

Filters are applied after the graph walk

pgvector's README says it outright: with approximate indexes, filtered queries can return fewer results because filtering is applied after the index is scanned. Version 0.8.0 added iterative scans as a remedy — hnsw.iterative_scan set to strict_order or relaxed_order, with hnsw.max_scan_tuples defaulting to 20,000 and hnsw.scan_mem_multiplier defaulting to 1x work_mem.

Iterative scanning is a tourniquet, not a cure. When the filter column has few distinct values, pgvector's own recommendation is a partial index:

CREATE INDEX ON items USING hnsw (embedding vector_l2_ops) WHERE (category_id = 123);

Qdrant takes a different route. full_scan_threshold defaults to 10000, measured in kilobytes (the docs note 1 KB ≈ one 256-dimensional vector). When the estimated data volume satisfying a condition falls below it, the query planner abandons HNSW traversal and does a full scan for better performance — see the indexing docs. That is a vendor conceding, in code, that below a certain size brute force simply wins.

num_candidates is per shard

Elastic's semantics: each shard finds num_candidates approximate neighbors, computes similarity, selects its own top k, and those are merged into a global top k. The consequence is that changing your shard count changes the effective recall of an unchanged num_candidates. This is the easiest trap to fall into during a capacity expansion — you believe you only added hardware, but you also invalidated your recall calibration. Any tuning result must be recorded together with the shard count it was measured at, and re-measured after a reshard.

One aside: if you have exhausted both efSearch and the filtering path and recall still misses, the cause is usually upstream of the index — chunk granularity and running a single vector-only retriever (fusing in a second one, as in MongoDB hybrid search with $rankFusion, is the usual fix) explain far more missing recall than graph parameters do.

When brute force is the better engineering call

Three cases, stated as judgments rather than hedges.

One: too few queries to amortize the build. FAISS's guidelines are explicit — if you plan to perform only a few searches (say 1,000–10,000), use Flat, because index construction time does not pay for itself, and Flat is the only index that guarantees exact results. Offline batch jobs, one-off deduplication, and evaluation-set regressions all live here.

Two: the post-filter subset is small. This is the logic Qdrant compiled into its query planner. In a multi-tenant system where each tenant holds tens of thousands of vectors, "partition by tenant, scan the partition" beats "global HNSW, filter afterwards" on correctness and predictability, and eliminates a whole class of overfiltering bugs.

Three: after quantization, exhaustive search is a memory-bandwidth problem. Do the arithmetic — this is arithmetic, not a benchmark. One million 768-dimensional vectors occupy 3.07 GB as float32, 768 MB as int8, and 96 MB at 1 bit. A 96 MB sequential scan on a modern server is tens of milliseconds, recall is 1.0 within quantization error, and there is no graph, no build phase, no tombstone accumulation from deletes, and no parameter to recalibrate. Capacity planning becomes a straight line. This is exactly why Elasticsearch ships bbq_flat and int8_flat alongside their HNSW counterparts, and why pgvector supports indexing binary_quantize(embedding)::bit(n) with Hamming distance (<~>) for a cheap first pass followed by exact reranking:

SELECT * FROM (
    SELECT * FROM items
    ORDER BY binary_quantize(embedding)::bit(3) <~> binary_quantize('[1,-2,3]')
    LIMIT 20
) t ORDER BY embedding <=> '[1,-2,3]' LIMIT 5;

My position: below roughly a million vectors per partition, with clean tenant or partition boundaries, try quantization plus in-partition exhaustive search before reaching for HNSW. Return to the numbers above: at 1 bit the vectors cost 96 MB and an M = 16 graph costs 128–160 MB. You would spend more memory than the data itself, plus a build phase, plus a parameter set that needs recalibration on every reshard — all to avoid a 96 MB sequential scan. HNSW earns its keep once the dataset outgrows what memory bandwidth handles comfortably, not before.

A tuning order that does not rely on guessing

  1. Pick the quantization tier before touching graph parameters. Quantization sets your memory baseline and determines whether rescoring is needed; graph parameters only trim around that baseline. Start at int8. Consider 1-bit with 3x oversampling only at ≥ 768 dimensions and under real memory pressure.
  2. Start M at 16 and require evidence to move it. Three independent engines defaulting to 16 is not a coincidence. Only raise it after efSearch is already at your latency budget and recall still misses — and accept that you are signing up for a rebuild.
  3. Calibrate efConstruction with hnswlib's procedure. Set ef = efConstruction, measure recall, raise until it clears 0.9. Fix maintenance_work_mem first if you are on pgvector.
  4. Tune only efSearch online. Make it a per-request override, not a static config value, and know where its knee sits.
  5. Give filtering its own path. Partial or partitioned indexes for low-cardinality filter columns; iterative scan as the fallback for everything else.
  6. Re-measure everything after a reshard. num_candidates is per shard, so the old calibration is void.

Three things not to do. Do not expect a higher efConstruction to rescue a low efSearch — it does not touch query latency and cannot compensate at search time. Do not deploy 1-bit quantization before measuring the random I/O cost of rescoring at your oversampling factor. And do not reach for PQ by default anymore. One last hard limit if you are on Postgres: pgvector indexes vector up to 2,000 dimensions and halfvec up to 4,000 — past that, reduce dimensionality or change types before any of this tuning applies.