Exact search over a billion embeddings takes fifteen seconds and three terabytes of RAM. Every trick in this lesson is a different, principled way of refusing to read most of the data — and of knowing exactly what that refusal costs.
You have a billion embeddings. Maybe they are product descriptions, maybe chunks of a documentation corpus, maybe frames from a video archive. Each one is a list of 768 floating-point numbers produced by a text or image encoder. A user types a question, you embed it, and you want the ten stored vectors whose direction is closest to the question's.
The specification is one line of numpy:
python — the honest, correct, unusable versionimport numpy as np X = np.load("corpus.npy") # shape (1_000_000_000, 768), float32 q = embed("how do I rotate a key?") # shape (768,), float32 scores = X @ q # shape (1_000_000_000,) top10 = np.argpartition(-scores, 10)[:10]
That code is correct. It returns the true answer, every time, with no tuning and no approximation. And it is completely unusable, for reasons that are pure arithmetic. Let us do that arithmetic slowly, because every design decision in the rest of this lesson is a response to one of these three numbers.
A float32 is 4 bytes. One vector is 768 × 4 = 3,072 bytes, call it 3 KB. A billion of them:
Three terabytes. The largest single-socket cloud instances top out somewhere between 768 GB and 2 TB of RAM, and they are eye-wateringly expensive. So before you have thought about speed at all, you already need four to six machines just to hold the array, which means a shard-and-merge layer, which means a fan-out, which means your tail latency is now the maximum over several machines rather than the latency of one.
One query is a matrix-vector product: a billion dot products of length 768. Each dot product is 768 multiplications and 767 additions, so about 1,536 floating-point operations. Total:
A modern server core running AVX-512 fused multiply-add can sustain roughly 50 GFLOP/s on a well-vectorised inner product — that is 5 × 1010 flops per second. Divide:
Spread it over 64 cores and you get 0.48 seconds — and you have burned an entire 64-core machine to answer one query. Hold that thought, because the flop count is not even the binding constraint.
To multiply a vector by a number, the number has to arrive at the arithmetic unit. Every one of those 3.07 × 1012 bytes must be read out of DRAM, and each byte is used for exactly one multiply and then thrown away. There is no reuse, so caches do not help. This is a pure streaming workload, and streaming workloads are governed by memory bandwidth — how many bytes per second the memory subsystem can deliver.
A well-configured dual-socket server delivers on the order of 200 GB/s = 2 × 1011 bytes per second. So:
That is the honest floor for a single machine holding the whole array, and notice it is worse than the flop estimate on 64 cores. Adding cores does not help: the cores are already waiting on memory. This is what people mean when they say brute-force vector search is bandwidth-bound.
It is worth making the bandwidth argument rigorous rather than asserting it, because the same reasoning decides several later design questions.
The relevant quantity is arithmetic intensity: how many floating-point operations you perform per byte loaded from memory. A machine has a characteristic ratio too — its peak flops divided by its peak bandwidth — and whichever side of that ratio you fall on tells you which resource binds.
For a single-query exact scan: each float32 loaded is used for exactly one multiply and one add, so 2 flops per 4 bytes:
A server with 3 TFLOP/s of usable throughput and 200 GB/s of bandwidth has a balance of 3 × 1012 / 2 × 1011 = 15 flops per byte. You are at 0.5 against a machine that wants 15 — a factor of 30 on the wrong side. The cores are idle 97% of the time waiting for data, and no amount of vectorisation, threading, or micro-optimisation changes that, because the bottleneck is upstream of the arithmetic entirely.
| Workload | Intensity (flops/byte) | Verdict on a 15 flops/byte machine |
|---|---|---|
| Single-query exact scan | 0.5 | Hopelessly memory-bound |
| Batch of 32 queries (a GEMM) | ~16 | Right at balance — now compute-bound, and 30× more efficient per query |
| PQ scan, 8-byte codes | ~0.9 with 64× fewer bytes | Still memory-bound, but the memory is 64× smaller and fits in cache |
| Graph hop, M = 32 neighbours | 0.5, on scattered addresses | Latency-bound, not bandwidth-bound — a different problem again |
Fifteen seconds is not merely expensive, it is the wrong shape. A user-facing search box has an end-to-end budget of about 200 ms at the 99th percentile. Network, request parsing, embedding the query with a transformer, reranking, and rendering will eat most of that. Retrieval gets perhaps 10 ms.
| Approach | Latency for one query | Machines to hold the data | Gap to a 10 ms budget |
|---|---|---|---|
| Single machine, full scan | 15.4 s | Impossible — 3 TB | 1,540× |
| Sharded over 10 machines | 1.54 s | 10 | 154× |
| Sharded over 100 machines | 154 ms | 100 | 15× |
| Sharded over 1,540 machines | 10 ms | 1,540 | 1× — and bankrupt |
Sharding trades money for latency at a fixed exchange rate, and the exchange rate is terrible. Worse, each query now touches every shard, so your throughput does not improve at all — a 1,540-machine fleet still answers about 100 queries per second in total, because every machine works on every query.
You cannot close a 1,500× gap by buying hardware. You close it by not reading most of the data. And the moment you decide not to read some of the data, you have accepted that you might miss the right answer. That acceptance is the entire subject of this lesson.
A reader who has taken an algorithms course has an objection ready: this is what spatial index structures are for. A k-d tree partitions space by splitting on one coordinate at a time, and answers exact nearest-neighbour queries in O(log N) in low dimensions. Ball trees, cover trees, and R-trees are variations on the same idea. Why not just build one?
Because all of them are exact, and exactness in high dimensions is achieved by backtracking. A k-d tree finds a candidate leaf quickly, then must go back and check every branch whose bounding region could possibly contain something closer. In two dimensions almost no branch qualifies and the pruning is devastatingly effective. In 768 dimensions almost every branch qualifies, and the tree degenerates into a full scan performed in the worst possible order — random pointer chasing instead of a clean sequential stream. A k-d tree over a billion 768-dimensional vectors is genuinely slower than the numpy one-liner.
The reason is worth deriving, because it also explains why approximate methods work at all.
Take N points drawn independently from a standard Gaussian in d dimensions and normalise them to unit length — a decent first model for normalised embeddings. Pick two at random and compute their cosine similarity. Each coordinate of the dot product is a product of two independent, roughly-zero-mean numbers, and there are d of them, so the sum behaves like a zero-mean Gaussian whose standard deviation shrinks as more terms average out:
Put in real dimensions and read the table. The spread is the width of the band that all random pairs fall into.
| d | 1 / √d | 99.7% of random pairs land in | What that means |
|---|---|---|---|
| 2 | 0.707 | [−1.00, 1.00] | Distances are wildly spread out; pruning is easy |
| 16 | 0.250 | [−0.75, 0.75] | Still plenty of structure to exploit |
| 128 | 0.0884 | [−0.265, 0.265] | Everything is nearly orthogonal to everything |
| 768 | 0.0361 | [−0.108, 0.108] | All one billion points are, to a first approximation, the same distance away |
| 3072 | 0.0180 | [−0.054, 0.054] | Worse still |
Worked example 1 — why pruning dies. At d = 768, suppose the true nearest neighbour of your query has cosine 0.62 and everything else is random, sitting in a band of width ±0.108. A k-d tree prunes a branch when the branch's bounding box is provably farther than the best candidate found so far. But a coordinate-aligned box in 768 dimensions constrains only the coordinates it has split on — say 30 of them after 30 levels of descent. The other 738 coordinates are unconstrained, and their contribution to the distance is exactly the concentrated band. The bound the tree can prove is therefore almost never tight enough to exclude the branch. Concretely: the tree must visit a fraction of leaves that grows exponentially in the dimension, and by d ≈ 20 it already exceeds 100%.
Before choosing a structure, be precise about the question, because these five have genuinely different answers and teams routinely build for one and deploy the other.
| Problem | Statement | What changes |
|---|---|---|
| k-nearest neighbours | Return the k closest points to q | The default. Everything in this lesson targets it |
| Range query | Return every point within radius ε of q | The result size is unbounded and data-dependent. Graph indexes have no natural stopping rule; IVF handles it more gracefully. In high dimensions the answer is usually empty or enormous, with almost nothing in between — distance concentration again |
| Maximum inner product | Return argmax 〈q, x〉 over unnormalised x | Not a metric. Long vectors win regardless of direction, so structures that assume the triangle inequality misbehave. Chapter 6 |
| Filtered k-NN | k closest among points satisfying a predicate | Changes the architecture more than the index choice does. Chapter 7 |
| Diverse k-NN | k close points that are also unlike each other | Cannot be expressed as a distance at all. Solved above the index with maximal marginal relevance or clustering of the shortlist |
Two of these are traps in particular. Range queries feel natural — "everything similar enough" — and behave badly, because the threshold that returns 8 documents for one query returns 40,000 for another. And filtered k-NN is the one everybody actually needs and nobody benchmarks; if your product has tenants, permissions, or freshness rules, read Chapter 7 before choosing anything.
The classic ANN literature was built on 96- to 128-dimensional descriptors. Modern encoders emit four to twenty-four times that, and every quantity in this chapter scales linearly in d. It is worth seeing the shift in one table, because papers written for d = 128 quietly become papers about a different problem at d = 3072.
| d | Bytes/vector | 1M corpus | 1B corpus | Exact scan of 1M, 200 GB/s |
|---|---|---|---|---|
| 96 (DEEP1B) | 384 | 384 MB | 384 GB | 1.9 ms |
| 128 (SIFT) | 512 | 512 MB | 512 GB | 2.6 ms |
| 384 (small text encoders) | 1,536 | 1.5 GB | 1.5 TB | 7.7 ms |
| 768 (base text encoders) | 3,072 | 3.1 GB | 3.1 TB | 15.4 ms |
| 1536 | 6,144 | 6.1 GB | 6.1 TB | 30.7 ms |
| 3072 (large encoders) | 12,288 | 12.3 GB | 12.3 TB | 61.4 ms |
There are exactly three ideas in this field, and everything shipped in production is a composition of them.
They compose because they answer different questions. Partition and navigation both answer "which vectors do I look at?" Compression answers "how much does each look cost?" A production index almost always uses one of the first two together with the third: IVF-PQ, DiskANN's graph plus in-memory PQ codes, ScaNN's tree plus anisotropic codes. Chapter 8 puts them back together.
Each of the three families arrived as a response to the failure of the one before it, and knowing the sequence makes the design decisions feel earned rather than arbitrary.
| Era | The idea | What it unlocked | Why it was not enough |
|---|---|---|---|
| Exact spatial structures (1970s–1990s) | k-d trees, ball trees, R-trees: recursively partition space and prune by bound | Genuinely logarithmic exact search in 2–10 dimensions. Still the right answer for geographic data | Pruning bounds go vacuous above ~20 dimensions; degenerates to a slow full scan |
| Data-independent hashing (1998–2010) | LSH: random projections whose collision rate falls with distance, with provable bounds | The first sublinear method with a theorem attached, and a decade of theory | Constants are brutal on real data because the hashes ignore where the points actually are |
| Learned partitions (2010–2012) | IVF: k-means the space and scan a few cells. The inverted multi-index refines it | Practical billion-scale search for the first time; the routing layer of nearly every modern system | Routing alone does not solve memory — the vectors still had to be stored somewhere |
| Compression (2011–) | Product quantization: represent a vector by m small sub-codes and score by table lookup | 64× compression, and with it a billion vectors on one machine | Imposes a recall ceiling; needs reranking to reach the top of the curve |
| Navigable graphs (2014–2016) | NSW, then HNSW: greedy routing on a graph with links at every scale | The best in-memory recall/latency frontier there is, with no training stage | Requires full-precision vectors resident in RAM; slow to build; hostile to deletes |
| Storage-aware graphs (2019–) | DiskANN / Vamana: codes in RAM to route, vectors on SSD to answer | A billion points on one commodity node | Multi-day builds; updates needed a separate paper |
| Task-aware compression (2020–) | ScaNN: weight the quantization loss by which error directions change the ranking | Better recall at identical code length, and an idea that generalises far beyond search | Derivation assumes a high score threshold; less useful for deep tails |
| Accelerator-native (2023–) | CAGRA and friends: graphs built and searched with thousands of threads | Order-of-magnitude faster builds; strong batch throughput | Accelerator memory is small and expensive, so partitioning returns |
Read the last column downward. Every era solved the previous one's binding constraint and exposed a new one, and the four papers in bold are the ones whose ideas are still inside what you would deploy today. That is why this lesson is built on those four and not on a survey.
Before any index, settle the distance, because the wrong choice silently produces a beautifully fast wrong answer. Three appear constantly:
| Objective | Formula | Where it comes from | Gotcha |
|---|---|---|---|
| Squared Euclidean (L2) | ‖q − x‖2 | SIFT descriptors, image features, anything geometric | Sensitive to vector norm; a long vector is far from everything |
| Cosine / angular | 1 − 〈q, x〉 / (‖q‖‖x‖) | Text embeddings, almost all modern encoders | Implemented by normalising once and then using inner product |
| Maximum inner product (MIPS) | argmax 〈q, x〉 | Recommenders, two-tower retrieval, softmax output layers | Not a metric — no triangle inequality. Longer vectors win. Breaks indexes that assume metric structure |
The identity that connects the first two is worth writing out, because it explains why so much of the literature quietly assumes L2 even when everyone is doing cosine. For unit vectors, ‖q‖ = ‖x‖ = 1, so
Squared Euclidean distance is a decreasing linear function of the inner product, so ranking by one is exactly ranking by the other. Normalise your vectors once at ingest and every L2 index in the world becomes a cosine index for free. This is why FAISS's IndexFlatIP and IndexFlatL2 agree on normalised data.
MIPS on un-normalised vectors is the genuinely different case, and it is the one ScaNN was designed for. The reason it is harder: the point maximising 〈q, x〉 can be arbitrarily far from q in Euclidean terms, so the geometric intuition that "close means nearby" simply fails. Chapter 6 takes this seriously.
One more historical detour, because it is instructive about why this field turned out empirical. For twenty years the respectable answer to approximate search was locality-sensitive hashing (Indyk & Motwani, 1998).
The idea is genuinely elegant. Design a family of hash functions with the property that nearby points collide more often than distant ones. For cosine similarity the canonical construction is SimHash: draw a random Gaussian vector r and hash a point to a single bit,
Two points collide exactly when the random hyperplane through the origin does not separate them, and the probability of that is a clean function of the angle θ between them:
Concatenate b such bits into a bucket key, build L independent tables, and at query time look in the query's bucket in every table. Points that never collide are never examined. There are theorems attached, with real bounds. So why does almost nobody deploy it?
Worked example 2 — the constants. Suppose the true neighbour has cosine 0.62, so θ = arccos(0.62) = 0.902 radians = 51.7°. Per-bit collision probability:
With b = 16 bits per table — already coarse enough that buckets are enormous — the probability that the true neighbour lands in the query's bucket in one table is
Under half a percent. To reach 95% recall you need enough independent tables that at least one hits:
Six hundred and seventy hash tables. Each stores an id per point, so at a billion points and 8-byte ids the index costs 670 × 109 × 8 = 5.4 TB — larger than the data it indexes. And it gets worse on the other side: a random pair sits at θ = 90°, giving a per-bit collision of 0.5 and a per-table bucket collision of 0.516 = 1.53 × 10−5. Multiply by a billion points and each table hands you about 15,000 false candidates; across 670 tables that is ten million candidates to filter, for one query.
One detail changes the arithmetic more than people expect: are your queries arriving one at a time, or in batches?
A single query streams the whole corpus and uses each byte once — the bandwidth-bound case of Number Three. A batch of B queries turns the same work into a matrix–matrix product: load a block of the corpus into cache once, score all B queries against it, move on. The corpus is streamed once for B queries instead of once per query, so the per-query cost falls by roughly B until you saturate compute instead of bandwidth.
| Workload | Shape | Bound by | Effective cost per query |
|---|---|---|---|
| Interactive search box | (1, d) × (d, N) | Memory bandwidth | Full stream. The hard case |
| Batch re-scoring, offline jobs | (B, d) × (d, N) | Compute, once B > ~20 | Stream / B — often 20–50× cheaper |
| Recommendation pre-compute | (N, d) × (d, N) | Compute | A GEMM; the best case hardware offers |
So "how big can brute force go?" has two different answers, and they differ by more than an order of magnitude. If your workload is nightly batch scoring, exact search survives far longer than any benchmark plot suggests. If it is an interactive box, you meet the wall early. Know which one you are.
python — measure your own wall before believing anyone else'simport numpy as np, time N, d = 1_000_000, 768 X = np.random.randn(N, d).astype("float32") X /= np.linalg.norm(X, axis=1, keepdims=True) # cosine == inner product for B in (1, 8, 64): Q = X[:B] _ = X @ Q.T # warm the pages t = time.perf_counter() S = X @ Q.T # (N, B) scores dt = (time.perf_counter() - t) / B print(f"B={B} {dt*1e3:.2f} ms/query {X.nbytes/dt/1e9:.0f} GB/s") # The GB/s column is the number that matters: compare it to your machine's # memory bandwidth. If you are near it, no amount of tuning will help -- # only touching less data will.
The arithmetic above should also tell you when not to read the rest of this lesson. Redo Number Three for a smaller corpus:
One and a half milliseconds, exact, no index, no build step, no tuning, no staleness, deletes are free, and arbitrary metadata filters cost nothing because you are looping anyway. At a hundred thousand vectors an ANN index is a liability. Chapter 7 works out where the crossover actually sits, and it is farther out than most teams assume.
Chapter 0 established that you must skip most of the data. This chapter builds the vocabulary for describing how much you skipped and what it cost you — because "it's approximate" is not a specification, and a vendor benchmark that reports only latency is not a benchmark.
Fix a query q and a corpus. Let Gk(q) be the ground truth: the set of the k genuinely closest vectors, computed once, offline, by brute force. Let Rk(q) be what your index actually returned. Then
Three traps live in that innocent formula, and every one of them has been used, deliberately or not, to make an index look better than it is.
Trap one: k on the left is not always k on the right. The literature writes x-recall@y: did the true top-x appear among the y results returned? "10-recall@10" is the honest, strict version. "1-recall@10" asks only whether the single true nearest neighbour is somewhere in your ten — a far easier bar. On the same index at the same settings these two numbers can differ by fifteen points. Always read which one is plotted.
Trap two: ties. If several corpus vectors sit at exactly the same distance, the ground truth is ambiguous and recall becomes unstable. On real float embeddings exact ties are vanishingly rare; on quantized or binary data they are common enough to matter.
Trap three: the ground truth must use the same metric you deploy. Computing ground truth with L2 and then serving with cosine on un-normalised vectors produces a recall number that measures nothing.
Here is the calculation that changes how people set their recall target. A retrieval-augmented generation pipeline works like this: retrieve 20 chunks with the vector index, rerank them with a cross-encoder, feed the top 5 to the model.
Suppose answering a particular question genuinely requires three specific chunks — a definition, an example, and a caveat, scattered across the corpus. Your index has recall@20 = 0.90. Treat the three retrievals as roughly independent (they are not exactly, but the direction of the error is what matters):
Twenty-seven percent of multi-hop questions are unanswerable before the model has read a single token, and no amount of prompt engineering will fix it, because the evidence is not in the context. Now push recall to 0.98:
Eight points of recall bought a 4.6× reduction in end-to-end failures. That is why the interesting part of every recall/QPS curve is the far right end, above 0.95, where the curve is steepest and most expensive — and why benchmarks that stop plotting at 0.9 are hiding the region you will actually operate in.
Recall is a set metric: it asks which items came back, not in what order. Two failure modes slip straight through it, and both are visible to users.
Ordering inside the returned set. An index that returns the true top 10 in the order 7, 3, 1, 9, 2, 5, 10, 4, 8, 6 scores recall@10 = 1.000 — a perfect score for a result page whose best item is third from last. If your interface shows a ranked list and users read from the top, that is a real regression that recall cannot see.
How badly you missed. Recall counts a miss identically whether the item you returned instead was almost as good or completely unrelated. Two indexes at recall 0.9 can differ enormously in whether the substituted item was a near-duplicate of the right answer or noise.
| Metric | Sensitive to | Use when |
|---|---|---|
| recall@k | Set membership only | Tuning an index, or when a reranker downstream will fix the order anyway |
| nDCG@k against exact ranking | Order and position | The index output is the result page |
| Mean distance ratio — d(returned)/d(true), averaged | How badly you missed | Diagnosing whether misses are near-neighbours or genuine failures |
| Rank of the true nearest in your output | Where the best item actually landed | Debugging a specific bad query |
Latency and throughput are different numbers and conflating them is the most common benchmarking error in this field.
| Metric | What it measures | Why it can mislead |
|---|---|---|
| Mean latency | Average wall-clock time per query | Hides the tail entirely; a p99 of 200 ms can hide behind a 4 ms mean |
| p99 latency | The slow 1% — the number your users complain about | Grows when you shard, because the query waits for the slowest shard |
| QPS, single thread | The ann-benchmarks convention: 1 ÷ mean latency, one core | Rewards algorithms with no intra-query parallelism; penalises GPU and heavily-SIMD designs |
| QPS per core / per dollar | What the capacity plan actually needs | Rarely published, because it depends on the machine |
| Batch throughput | Many queries at once, amortising the memory stream | Enormously better than single-query numbers; irrelevant if you serve interactively |
Memory is measured per vector, including everything. The number that matters is total resident bytes divided by N, and it must include the index overhead, not just the payload. A worked comparison at N = 1,000,000 and d = 128 (the classic SIFT1M setup, so these numbers are checkable against published results):
| Index | Payload per vector | Structure overhead per vector | Total | For 1M |
|---|---|---|---|---|
| Flat float32 | 128 × 4 = 512 B | 0 | 512 B | 512 MB |
| HNSW, M = 16 | 512 B | 32 links × 4 B = 128 B (layer 0) + ~4 B upper | 644 B | 644 MB |
| HNSW, M = 48 | 512 B | 96 links × 4 B = 384 B + ~8 B | 904 B | 904 MB |
| IVF4096, flat vectors | 512 B | 8 B id + centroids amortised | ~520 B | 520 MB |
| IVF4096, PQ m = 16 | 16 B code | 8 B id | 24 B | 24 MB |
| IVF4096, PQ m = 8 | 8 B code | 8 B id | 16 B | 16 MB |
Read the last two rows against the first. Product quantization takes the payload from 512 bytes to 8 — a factor of 64 — and at that point the identifier costs as much as the vector. That is not a rounding detail: FAISS's 64-bit ids are half the memory of an IVF*,PQ8 index, which is why large deployments switch to 32-bit ids or to implicit ids derived from position.
Notice also that HNSW's overhead is not small. At M = 48 the graph costs 384 bytes per vector — three quarters of the vector itself. HNSW is fast partly because it keeps everything in RAM at full precision, and that is exactly why it cannot reach a billion points on one machine.
A detail of presentation that encodes a fact about the problem. Recall/QPS plots put throughput on a logarithmic axis, and the reason is that throughput varies by three or four orders of magnitude across the parameter sweep of a single index while recall varies over a factor of well under two.
| ef | Recall@10 | QPS | Recall change | QPS change |
|---|---|---|---|---|
| 10 | 0.80 | 32,000 | — | — |
| 64 | 0.96 | 8,500 | +0.16 | ÷ 3.8 |
| 512 | 0.995 | 1,200 | +0.035 | ÷ 7.1 |
| 2048 | 0.999 | 310 | +0.004 | ÷ 3.9 |
From ef = 10 to ef = 2048, recall moves 0.199 and throughput falls by a factor of 103. On a linear y-axis the entire high-recall region — the only part anyone deploys at — is squashed flat against the bottom and invisible. The log axis is not a stylistic preference; it is the only way to see the region where decisions are made.
The same asymmetry explains a common misreading of vendor claims. "Our index is 3× faster" is a statement about a vertical distance on a log plot, which corresponds to a very small horizontal distance in recall — often smaller than the difference between two reasonable recall definitions. Always ask what recall the 3× was measured at, and whether the comparison held it fixed.
The three quantities are not independent; each index family fixes one relationship and lets you trade the other two.
A useful discipline once you have a measurement harness: stop comparing configurations by recall and start comparing them by what the last point of recall cost. The interesting quantity is a derivative, not a level.
Take a plausible sweep on a 10-million-vector corpus at d = 768 and tabulate the marginal cost of each step up:
| Configuration | Recall@10 | Bytes/vector | p50 | Marginal cost of the gain |
|---|---|---|---|---|
| PQ 32 B, nprobe 8 | 0.812 | 40 | 1.1 ms | baseline |
| PQ 32 B, nprobe 32 | 0.874 | 40 | 3.4 ms | +6.2 pts for +2.3 ms, 0 bytes |
| PQ 64 B, nprobe 32 | 0.931 | 72 | 4.1 ms | +5.7 pts for +32 B/vector = +320 MB |
| PQ 64 B + rerank 500 | 0.978 | 72 hot | 6.5 ms | +4.7 pts for +2.4 ms and an SSD read path |
| HNSW M=32, ef 128 | 0.981 | 3,200 | 1.4 ms | +0.3 pts for +3,128 B/vector = +31 GB |
The last row is the one that ends arguments. Moving from the reranked PQ configuration to full HNSW buys three tenths of a point of recall for thirty-one gigabytes — roughly a hundred gigabytes of RAM per point of recall. Whether that is a bargain or an absurdity depends on the pipeline, and Worked Example 3's compounding argument is how you decide. But the question is now numeric.
The theory community defines the problem cleanly. A (1 + ε)-approximate nearest neighbour query returns a point p such that
This is a lovely definition. Locality-sensitive hashing (Indyk & Motwani, 1998) comes with provable bounds in exactly this framework, and for two decades it was the theoretically respectable answer.
Practitioners essentially never use it, and the reason is Chapter 0's concentration result. Work out what ε buys you at d = 768. Suppose the true nearest neighbour is at distance 0.87 (cosine 0.62) and the great mass of random points sits at distance about 1.41 (cosine 0). Set a seemingly tight ε = 0.05:
Any point within 0.914 is an acceptable answer. Now: how many of the billion points lie between 0.87 and 0.914? Because distances concentrate near 1.41 with a standard deviation around 1/√768 ≈ 0.036, the region [0.87, 0.914] is roughly fifteen standard deviations below the mean — and contains, in practice, either the true neighbour and a handful of its genuine siblings, or nothing at all. So ε = 0.05 is either trivially satisfied or unsatisfiable, and it tells you nothing about whether the system is any good. Push to ε = 0.5 and suddenly the acceptable region is [0.87, 1.305], which is most of the corpus: a guarantee that admits garbage.
When you finish evaluating a configuration, write it down in the same shape every time. Four numbers and one sentence make configurations comparable months later, when nobody remembers what "the fast one" meant.
| Field | Example | Why it is on the card |
|---|---|---|
| Recall@k, with k stated | 10-recall@10 = 0.962 | The strictness matters more than the value; without k the number is not comparable |
| p50 / p99 at real concurrency | 2.1 ms / 8.4 ms at 16 threads | Single-thread QPS predicts nothing about a loaded server |
| Resident bytes per vector | 96 B hot + 3,072 B cold | Multiplied by N, this is the machine bill. Split hot and cold explicitly |
| Build time and peak build RAM | 42 min, 61 GB | Decides the rebuild cadence and the build machine, and never appears in benchmarks |
| The knob and its value | nprobe = 24 | Without it the row is a point on an unknown curve and cannot be reproduced |
Two disciplines make the card honest. First, always record the knob — a recall number without its parameter setting is not a measurement, it is a claim. Second, split hot and cold memory: an index using 96 resident bytes plus a 3 KB cold rerank path has a completely different cost structure from one using 3,168 resident bytes, and a single "bytes per vector" column hides exactly that.
Before the machinery, get a feel for the shape of the trade. The instrument below has two knobs: how many bytes each vector is compressed to, and how many candidate vectors the search is allowed to visit. Everything else — recall, latency, memory, and which named index family lives at your current setting — falls out of those two.
Drag the two sliders and watch the operating point move. The recall model is a shape fitted to published SIFT1M curves, not a measurement — the point is the geometry of the trade-off, not the third decimal place. Note especially what happens when you push the code size down: recall stops responding to search effort entirely. That flat region is the quantization ceiling.
Three things to notice. First, the recall curve saturates: past a few thousand visited vectors, more effort buys almost nothing, because you are already touching everything that could plausibly win. Second, dropping from 32 bytes to 8 does not gently degrade recall — it lowers a hard ceiling that effort cannot climb back over. Third, the region every production system actually lives in is the knee, and the knee moves with your code size.
Sharding is the standard answer to "the corpus does not fit." It has a cost that no single-machine benchmark can show you, and the arithmetic is short enough to do here.
Suppose one shard has p50 = 4 ms and p99 = 20 ms — a perfectly normal graph index under load, where the variance comes from differing hop counts and cache behaviour. Now fan a query out to S shards and merge the results. The request finishes when the slowest shard finishes, so the probability that the whole request comes in under 20 ms is
| Shards S | P(request ≤ 20 ms) | What used to be p99 is now… |
|---|---|---|
| 1 | 0.990 | p99 |
| 5 | 0.951 | p95 |
| 10 | 0.904 | p90 |
| 50 | 0.605 | roughly the median |
| 100 | 0.366 | worse than the median |
At 100 shards, the 20 ms figure that used to be your rare bad case happens on nearly two requests in three. To recover a real p99 for the whole request you now need each shard's p99.99 to be acceptable — a far harder engineering target than its p99.
Everything in this chapter is unfalsifiable until you can run it. The harness is genuinely short:
python — recall + latency sweep, the whole thingimport numpy as np, time def ground_truth(X, Q, k=10, block=200_000): """Exact top-k by brute force, in blocks so it fits in memory.""" best_s = np.full((len(Q), k), -np.inf, "float32") best_i = np.zeros((len(Q), k), "int64") for b in range(0, len(X), block): S = Q @ X[b:b+block].T # (nq, block) S = np.concatenate([best_s, S], axis=1) I = np.concatenate([best_i, np.arange(b, min(b+block, len(X))) [None, :].repeat(len(Q), 0)], axis=1) top = np.argsort(-S, axis=1)[:, :k] best_s = np.take_along_axis(S, top, 1) best_i = np.take_along_axis(I, top, 1) return best_i def recall_at_k(pred, truth): k = truth.shape[1] return np.mean([len(set(p[:k]) & set(t)) / k for p, t in zip(pred, truth)]) def sweep(index, Q, truth, knob, values, k=10): for v in values: # THE point: a curve, not a point setattr(index, knob, v) t = time.perf_counter() _, I = index.search(Q, k) dt = (time.perf_counter() - t) / len(Q) print(f"{knob}={v:5d} recall={recall_at_k(I, truth):.4f} {1/dt:8.0f} QPS") # sweep(ivf, Q, truth, "nprobe", [1, 2, 4, 8, 16, 32, 64, 128]) # sweep(hnsw, Q, truth, "efSearch", [10, 20, 40, 80, 160, 320, 640])
Ten thousand held-out queries and a few minutes of brute force give you ground truth good enough to make every subsequent decision empirical. Building this first, before choosing an index, is the highest-leverage hour in the whole project.
It is worth stating the interface, because "index" is used loosely enough to hide real differences.
the contract every index in this lesson implementsclass Index: def train(self, sample): # learn the partition and/or the codebooks. IVF: k-means over centroids. # PQ: m separate k-means runs. HNSW: nothing to train. Cost: minutes to hours. def add(self, vectors, ids): # assign / encode / link. HNSW inserts one node at a time and is the # expensive one here. IVF-PQ's add is embarrassingly parallel. def search(self, q, k, **knobs): # knobs is the whole point: nprobe for IVF, ef for HNSW, beam width for # DiskANN. These move you along the recall/QPS curve WITHOUT rebuilding. def remove(self, ids): # the operation nobody benchmarks and everybody needs. Often a tombstone.
The line that deserves attention is search(**knobs). An index is not a point on the recall/QPS plane; it is a curve, and the knob is the parameter that traces it. Every serious comparison sweeps that knob. Every marketing claim quotes a single point on it. Chapter 7 is largely about that distinction.
The second line that deserves attention is train. Partition and compression methods have a training stage that sees a sample of the data and learns something about its distribution. That makes them fast and compact — and makes them drift when the data distribution moves. Graph methods have no training stage at all, which is a real and underrated advantage when your corpus is changing under you.
| Property | Partition (IVF) | Compression (PQ) | Graph (HNSW / Vamana) |
|---|---|---|---|
| Needs training on a sample | Yes — k-means over nlist centroids | Yes — m k-means runs | No |
| Build cost for 1M × 128 | Seconds to a minute | Seconds | Minutes |
| Insert one vector | O(nlist) — cheap | O(m · k*) — cheap | A full search — expensive |
| Delete one vector | Remove from a list — easy | N/A | Hard — tombstone and rebuild |
| Degrades as data drifts | Yes — centroids go stale | Yes — codebooks go stale | Barely |
| Memory overhead | Tiny | Negative — it saves memory | Large — 128–400 B/vector |
| Recall ceiling | None — raise nprobe to 100% | Yes — set by code length | None — raise ef |
That last row is the one to memorise. Partition and graph methods can always be pushed to exact search by turning the knob all the way up; compression cannot. It is a fundamentally different kind of approximation, and it is why every high-recall production system that uses PQ also has a reranking stage that reads the real vectors.
Start from the crudest possible idea and refine it until it is the algorithm that actually ships.
You cannot look at all the vectors. So: sort them into buckets ahead of time, and at query time look in only a few buckets. That is the whole idea. Everything interesting is in three questions — how do you choose the buckets, how do you decide which ones to open, and what does it cost you when the answer is in a bucket you did not open?
The structure is called an inverted file index (IVF), borrowed directly from text search. In a text engine, an inverted index maps each word to the list of documents containing it — the "posting list." A query for "kalman filter" opens two posting lists and intersects them, instead of reading every document.
The vector version replaces "word" with "region of space." You learn a set of representative points — centroids — and each centroid owns the region of space closer to it than to any other centroid. That region is its Voronoi cell. Every database vector is filed under the centroid whose cell it falls in, and the list of vectors filed under centroid i is the posting list, usually called an inverted list. A query computes which cells it is in or near, and reads only those lists.
Take the concrete setting used by every FAISS tutorial so the numbers are checkable: N = 1,000,000 vectors, d = 128, nlist = 4,096, nprobe = 8.
Step one: average list length. k-means makes cells of roughly equal population where the data is dense and roughly equal volume where it is sparse, but on real data the populations are within a factor of a few. To first order:
Step two: the coarse step. Comparing the query against every centroid is itself a brute-force scan — a small one:
Step three: the fine step. nprobe = 8 lists, each about 244 vectors:
Step four: the total, and the speedup.
Now look hard at Step two versus Step three. The coarse step costs more than twice the fine step. Two thirds of the query is spent deciding where to look. That is not a rounding error — it is a structural fact about IVF that drives two real design decisions, and most tutorials skip straight past it.
IVF262144_HNSW32 uses an HNSW graph as the coarse quantizer, turning a 262k-point scan into a ~50-node graph walk. Chapter 4's structure, used as a component inside Chapter 2's structure.Everyone repeats "set nlist to about √N." Almost nobody says where it comes from. It falls out of the arithmetic above in three lines.
Total cost, holding nprobe fixed, as a function of nlist:
The first term grows with more cells, the second shrinks. Differentiate with respect to nlist and set to zero:
Check it against the numbers we just used: nprobe = 8, N = 106 gives √(8 × 106) = 2,828. The conventional 4,096 sits right next to it. With nprobe = 1 the formula collapses to exactly √N = 1,000. The folk rule is the solution of a one-line optimisation.
Two honest caveats. First, the optimum is shallow — the cost curve is nearly flat between about ½√(nprobe·N) and 2√(nprobe·N), so anything in that band is fine. Second, this optimises cost at fixed nprobe, not recall. More cells at the same nprobe means a smaller fraction of the corpus scanned, which lowers recall. The real tuning loop fixes a recall target and then finds the cheapest (nlist, nprobe) pair that reaches it.
k-means over nlist centroids needs enough training points to place them meaningfully. FAISS warns below 39 points per centroid and recommends 39–256; the widely used practical target is 100–256 per centroid.
| nlist | Minimum training vectors (39×) | Comfortable (100×) | Typical N this suits |
|---|---|---|---|
| 1,024 | 39,936 | 102,400 | 105 – 106 |
| 4,096 | 159,744 | 409,600 | 106 |
| 65,536 | 2,555,904 | 6,553,600 | 107 – 108 |
| 262,144 | 10,223,616 | 26,214,400 | 109 |
This is why billion-scale IVF is trained on a subsample — typically a few tens of millions of vectors — rather than on the full corpus. It is also why changing nlist is a rebuild, not a tuning knob. nprobe is free to change at query time; nlist is baked in at train time. Knowing which parameters live on which side of that line is most of what "operating a vector index" means.
Here is where IVF loses recall, made completely concrete in two dimensions so you can check every number with a calculator.
Two centroids: c1 = (0, 0) and c2 = (10, 0). The Voronoi boundary between them is the vertical line x = 5. Query q = (4.9, 0.2).
Which cell is q in?
q is closer to c1, so with nprobe = 1 we open list 1 and only list 1.
Now a database point p = (5.05, 0.25), sitting just on the other side of the boundary:
p was filed under c2. And yet:
p is 0.158 away from q — overwhelmingly the nearest neighbour, thirty times closer than either centroid — and nprobe = 1 will never see it. It is in the list next door. Raise nprobe to 2 and the answer appears immediately, because c2 is the second-nearest centroid.
Two refinements the literature added, both worth knowing:
Multi-probe by soft assignment at build time. Instead of filing each vector under one centroid, file it under its two or three nearest. Recall at a given nprobe rises; memory rises with it, because vectors are now duplicated. DiskANN's sharded build (Chapter 5) uses exactly this trick, with each point sent to its two nearest shards.
The inverted multi-index (Babenko & Lempitsky, 2012). Rather than one k-means over nlist centroids, run k-means separately on the first and second half of the vector, with K centroids each. The effective number of cells is K2, so K = 214 gives 228 ≈ 268 million cells — a partition far finer than you could ever train directly — while the coarse scan costs only 2K = 32,768 comparisons. If that sounds like the trick in the next chapter, it is: it is product quantization applied to the coarse quantizer.
The coarse quantizer's job is narrow: given a query, name a small set of regions that probably contain the answer. k-means is the standard choice, but it is not the only one, and knowing the alternatives clarifies what property actually matters.
| Partition | How it routes | Verdict |
|---|---|---|
| k-means (IVF) | Nearest of nlist learned centroids | The standard. Adapts to the data's density; the coarse scan is its only weakness, and a graph fixes that |
| Random projections / LSH buckets | Sign pattern of a few random hyperplanes | Data-independent, so the buckets are wildly unbalanced on clustered data. Chapter 0's constants |
| k-d tree over the top principal components | Axis-aligned splits on high-variance directions | Works passably at low intrinsic dimension; cells are boxes, and boxes are a poor fit to the shape of embedding clusters |
| Inverted multi-index | Nearest pair of centroids from two half-space codebooks | K2 effective cells for a 2K coarse scan. Superb granularity, and the cells become very unbalanced at high K |
| Learned tree (ScaNN) | A tree trained with the same score-aware objective as the codebooks | Routes to preserve the ranking rather than the coordinates — Chapter 6's idea applied one level up |
| Graph, used as a partitioner | Find the nearest few centroids by walking a graph over them | Not a different partition — a faster way to evaluate the k-means one. This is what IVF*_HNSW* means |
The property that separates the winners from the losers in that table is simple: does the partition put roughly the same number of points in each region, and do the region boundaries follow the data rather than the coordinate axes? k-means gets both approximately right because that is literally what it optimises. Everything data-independent gets the first one badly wrong on clustered data, and every axis-aligned method gets the second one wrong in high dimensions.
One design choice inside IVF matters enormously once compression enters, and it is easy to miss. When an IVF index stores compressed codes, it does not encode the vector x. It encodes the residual:
Why? Because the residual is a much smaller, much better-behaved vector. Suppose your corpus has a spread (standard deviation per coordinate) of σ, and your 4,096 cells cut that spread down so that within a cell the spread is σ/4. The quantization error of any fixed-size codebook scales with the variance of what it is quantizing, so encoding residuals instead of raw vectors reduces squared error by roughly a factor of 16 at the same code length.
Concretely, with an 8-byte PQ code you can either spend all 64 bits describing where in the whole corpus the vector is, or spend them describing where within its cell it is — having got the cell for free from the inverted list id you were storing anyway. The second is obviously better, and the effect is large: encoding residuals rather than raw vectors is worth several points of recall at identical memory.
The cost is a small complication at query time. The distance from q to x is now the distance from (q − c) to r, so the PQ lookup tables must be built per probed list, not once per query. With nprobe = 8 you build eight sets of tables instead of one. FAISS calls this by_residual and it is on by default; turning it off is a real speed/recall knob for very large nprobe.
Filing each vector under its two nearest centroids instead of one is a cheap way to buy recall, and it is worth costing exactly rather than hand-waving.
At N = 106 and nlist = 4,096, single assignment gives lists of 244. Double assignment stores every vector twice, so the total entries double to 2 × 106 and the average list length becomes 488.
| Single assignment | Double assignment | |
|---|---|---|
| Entries stored | 1,000,000 | 2,000,000 |
| Average list length | 244 | 488 |
| Vectors scanned at nprobe = 8 | 1,953 | 3,906 |
| Vectors scanned at nprobe = 4 | 977 | 1,953 |
| Memory (PQ 16 B + 8 B id) | 24 MB | 48 MB |
| Boundary misses | Every query near a face loses | Points near a face are in both cells |
Read the third and fourth rows together. Double assignment at nprobe = 4 scans exactly as many vectors as single assignment at nprobe = 8 — identical query cost — but the vectors it scans are a better-chosen set, because every point near the boundary of the four probed cells is present rather than one door away. You have traded memory (which you may have) for boundary coverage (which is where all your recall was going). That is usually a better trade than raising nprobe, and it is the same insight DiskANN uses when it sends every point to its two nearest shards during the merge-based build.
The obvious follow-up: why not assign to three, or five? Because the memory cost is linear and the recall benefit falls off fast — a point sits near one face far more often than near three. Two is the standard because the second copy captures most of the boundary population.
IVF's real operational advantage over graphs is that adding and removing vectors is trivial: assign, append, done. But "trivial" is not "free of consequences", and the failure is slow enough to miss.
| Elapsed | What happens | Observable symptom |
|---|---|---|
| Day 0 | Centroids trained on a representative sample | Recall matches the benchmark |
| Week 4 | New content lands unevenly — a new product line, a new language | A few lists grow much faster than the rest; p99 creeps up |
| Month 3 | Some cells hold 10× the average; the partition no longer reflects the data | Recall down several points, uniformly, with no code change to blame |
| Month 6 | The embedding model is upgraded | Every centroid, every code, and every vector is invalid at once |
The remedy is unglamorous and should be built on day one: a golden query set with brute-force ground truth, recomputed on every rebuild, with recall published as a deploy-time metric. Recall regressions are otherwise completely silent — the system returns confident, plausible, slightly wrong results, and nothing alerts.
Two thousand points in the plane, partitioned by k-means into cells. Move the query, change nlist and nprobe, and watch the probed cells light up. The true nearest neighbour is circled: when it falls outside the probed region, the search has already failed no matter how good the rest of the pipeline is. The counters report exactly the arithmetic from Worked Example 5 at your current settings.
Press "Put the query on a boundary" and then step nprobe from 1 to 2. That single step is the two-dimensional version of the difference between 60% and 90% recall on a real corpus. Then press the recall sweep and watch how the measured recall responds to nprobe — the curve rises fast and then flattens, which is exactly the shape you saw in Chapter 1's triangle explorer and exactly the shape you will see on ann-benchmarks plots in Chapter 7.
One property of IVF is easy to state and worth internalising: nprobe interpolates continuously between a very fast approximation and exact search. Set nprobe = nlist and every list is scanned, so the result is exactly what brute force would return — slower, because you also paid for the centroid scan, but exact. Nothing about the index is lossy.
| nprobe (of 4,096) | Fraction scanned | Character |
|---|---|---|
| 1 | 0.024% | Fast and boundary-fragile. Around 0.6 recall@1 on typical data |
| 8 | 0.20% | The common default. Around 0.9 |
| 64 | 1.6% | High recall, still 60× cheaper than exact. Around 0.98 |
| 512 | 12.5% | Diminishing returns; the coarse step is now cheap by comparison |
| 4,096 | 100% | Exact, and slower than a plain flat scan |
This is a real operational lever, not a curiosity. It means the same index can serve a cheap default path at nprobe = 8 and an expensive high-recall path at nprobe = 128 for the queries that deserve it — a paying customer, an internal evaluation job, a retry after an empty result. Graph indexes have the same property through ef. Compression does not: there is no code length you can raise at query time.
"Average list length 244" is a comforting number and a slightly dishonest one. k-means minimises squared error, not variance in cluster population, so on real data the lists are far from equal. A dense region of the embedding space — boilerplate, near-duplicate documents, one enormous customer — produces cells holding thousands of vectors while sparse regions produce cells holding a handful.
Why it hurts, concretely. Query latency is proportional to the total length of the probed lists, not to nprobe. If your longest list holds 20,000 vectors instead of 244, then any query that lands in it costs eighty times the average. That is not a tail you can average away — it is a systematic, content-dependent latency spike that will correlate exactly with your most common queries, because the biggest cluster is the most popular topic.
| Symptom | Cause | Remedy |
|---|---|---|
| p99 latency far above p50 with stable nprobe | A few very large lists | Cap list length; split oversized cells with a second-level partition |
| Recall varies wildly by query type | Some queries land in cells that are too coarse to be selective | More cells, or balanced assignment |
| Several cells are nearly empty | k-means initialised badly, or the data has outliers | Re-train with k-means++ init and more iterations |
| Recall drifts down over weeks | New content lands disproportionately in a few cells | Scheduled retraining of the coarse quantizer |
Two standard mitigations. Spherical k-means normalises centroids to unit length at every iteration, which is the correct algorithm when your metric is cosine and which produces noticeably more even cells on normalised embeddings. Balanced assignment adds a penalty proportional to current cell population, so a point near two centroids is nudged toward the emptier one — you trade a little quantization error for a lot of latency predictability, which is usually a good trade in a serving system.
IVF is only the routing layer. What sits inside the inverted lists is a separate decision, and it is the one that determines memory and the recall ceiling.
| IVF-Flat | IVF-PQ | IVF-PQ + refine | |
|---|---|---|---|
| Stored per vector | Full float32 vector + id | m-byte code + id | m-byte code + id, floats elsewhere |
| Bytes at d = 128, m = 16 | 520 | 24 | 24 hot + 512 cold |
| Distances | Exact within probed cells | ADC estimates | ADC to shortlist, exact to rank |
| Recall ceiling | None — raise nprobe to 100% | Set by code length | None, in practice |
| Where it shines | 106–107 with RAM to spare | 108–109, memory-bound | Large corpora that still need high recall |
python — the same corpus, three ways, in FAISSimport faiss, numpy as np d, N, nlist = 128, 1_000_000, 4096 X = np.random.randn(N, d).astype("float32") # 1. exact -- the baseline you must beat. No training, no tuning. flat = faiss.IndexFlatL2(d) flat.add(X) # 2. IVF-Flat -- routing only. Exact distances inside the probed cells. ivf = faiss.index_factory(d, f"IVF{nlist},Flat") ivf.train(X[:400_000]) # ~100 vectors per centroid; a sample suffices ivf.add(X) ivf.nprobe = 8 # the only query-time knob # 3. OPQ + IVF-PQ + refine -- routing, compression, and an exact rerank. pq = faiss.index_factory(d, f"OPQ16_128,IVF{nlist},PQ16", faiss.METRIC_L2) pq.train(X[:400_000]) pq.add(X) faiss.extract_index_ivf(pq).nprobe = 8 # Memory: 512 MB / 520 MB / 24 MB. Recall: 1.00 / ~0.98 / ~0.80 before rerank. # The 20-point gap in row 3 is the quantization ceiling, and a refine stage # over the top 500 candidates closes almost all of it.
Read the last comment carefully, because it is the most common misdiagnosis in the field: the third index is not "worse." It is a different point on the triangle, chosen because 24 MB fits somewhere 520 MB does not, and its apparent recall deficit is an artefact of measuring it without the reranking stage it was designed to feed.
| Aspect | Detail |
|---|---|
| Build-time parameter | nlist — number of cells. Changing it requires retraining and re-adding everything |
| Query-time parameter | nprobe — cells to scan. Free to change per query, even per user tier |
| Rule of thumb | nlist ≈ √(nprobe · N); train on 100–256 vectors per centroid |
| Recall ceiling | None. nprobe = nlist is exact brute force |
| Memory overhead | nlist × d floats for centroids, plus one id per vector. Negligible |
| Inserts | Cheap and parallel: one coarse search, one list append |
| Deletes | Genuinely easy — remove the id from its list. A real advantage over graphs |
| Weakness | Recall depends on how the query sits relative to cell boundaries; centroids go stale as data drifts |
| Natural partner | PQ for the payload (next chapter), HNSW for the coarse quantizer (Chapter 4) |
Jégou, Douze and Schmid's 2011 paper is the single highest-leverage idea in this lesson. It is what turns "3 terabytes" into "16 gigabytes," and it does it with an idea you can hold in your head completely. This chapter derives it from nothing and then works a complete numerical example by hand, from raw vectors to the final distance table.
A quantizer is a function q that maps any vector to one of a finite set of centroids C = {c1, …, ck}, called the codebook. The natural choice is nearest-centroid assignment, and the natural way to learn the codebook is k-means — which is precisely the algorithm that minimises the expected squared error E[‖x − q(x)‖2].
Once you have a codebook, you store only the index of the centroid. With k centroids, an index takes log2(k) bits. So the compression is spectacular in principle: a 128-dimensional float32 vector costs 512 bytes, and its centroid index with k = 256 costs one byte.
Now push it. We want codes long enough to be useful — say 64 bits, so 264 distinct codes. How big is the codebook?
Nine sextillion bytes. Global installed storage capacity is on the order of 1022 bytes, so the codebook for one 64-bit quantizer would consume a meaningful fraction of all the storage humanity has built. And you would have to train it, which needs more data points than centroids.
Here is the move. Split the vector into m contiguous chunks of equal length. For d = 128 and m = 8, each chunk is d* = 16 dimensions:
Now quantize each chunk independently, with its own codebook of k* centroids learned by k-means in the subspace. The code for x is the tuple of m sub-indices:
The reconstruction is the concatenation of the chosen sub-centroids: x̂ = [ ci1(1) | … | cim(m) ]. And now count what you have.
| Quantity | Flat VQ, 64-bit code | Product quantizer, m = 8, k* = 256 |
|---|---|---|
| Distinct reconstructions | 264 | (k*)m = 2568 = 264 — identical |
| Centroids to store | 1.845 × 1019 | m × k* = 8 × 256 = 2,048 |
| Floats of codebook | 2.36 × 1021 | m × k* × d* = 8 × 256 × 16 = 32,768 = 128 KB |
| k-means runs needed | 1, over 1019 centroids — impossible | 8, each over 256 centroids in 16-D — seconds |
| Code size per vector | 8 bytes | 8 bytes |
What did you pay? Independence. The product quantizer can only represent points that are a concatenation of per-subspace centroids; it cannot express correlations between subspaces. If dimensions 3 and 97 are strongly correlated and land in different chunks, PQ cannot exploit that. Section "OPQ" below is the fix.
Everything below uses d = 8, m = 2, d* = 4, and k* = 4. Real PQ uses k* = 256 so that each sub-index is exactly one byte; we shrink it to 4 (two bits per sub-index) purely so the whole thing fits on a page and you can check every number. Nothing about the structure changes.
The two codebooks, learned by k-means on the first four and last four dimensions respectively:
| Sub-codebook 1 (dims 1–4) | Sub-codebook 2 (dims 5–8) |
|---|---|
| c(1)0 = (1, 0, 0, 1) | c(2)0 = (1, 1, 1, 1) |
| c(1)1 = (0, 1, 1, 0) | c(2)1 = (3, 0, 0, 3) |
| c(1)2 = (2, 2, 0, 0) | c(2)2 = (0, 3, 3, 0) |
| c(1)3 = (0, 0, 2, 2) | c(2)3 = (0, 0, 0, 0) |
The database vector to encode:
Encoding, subspace 1. x(1) = (0.9, 0.1, 0.2, 1.1). Squared distance to each centroid, term by term:
The winner is index 0, at squared error 0.07.
Encoding, subspace 2. x(2) = (2.8, 0.2, 0.1, 3.2):
The winner is index 1, at squared error 0.13.
The reconstruction and its error. x̂ = (1, 0, 0, 1 | 3, 0, 0, 3), and the total squared error is exactly the sum of the two per-subspace errors — the subspaces are disjoint coordinates, so squared errors add:
Now the query arrives:
The obvious thing to do — decode every stored code back to a full vector and compute distances — would throw away the entire speed advantage. Instead, asymmetric distance computation (ADC): leave the query in full precision, and precompute a small table of the distance from each query subvector to each centroid in that subspace. Then the distance to any stored code is a sum of table lookups.
Table for subspace 1, from q(1) = (1.0, 0.2, 0.1, 0.9):
Table for subspace 2, from q(2) = (2.9, 0.1, 0.2, 3.1):
Two tables, four entries each: eight numbers, computed once per query. Now the estimated squared distance to any stored vector is two lookups and one addition. For our x with code (0, 1):
No multiplications at all. That is the operational payoff and it is easy to under-appreciate: scanning a million PQ codes is a million table lookups and adds over an 8-byte-per-vector stream, versus a million 128-dimensional dot products over a 512-byte-per-vector stream.
How good is the estimate? Compute the truth. q − x = (0.1, 0.1, −0.1, −0.2 | 0.1, −0.1, 0.1, −0.1):
Estimated 0.13, true 0.11. The estimate is 18% high. That direction is not an accident, and the exact decomposition explains it.
Write the true distance in terms of the reconstruction, by adding and subtracting x̂:
The first term is exactly what ADC computes, 0.13. The last is the quantization error, 0.20. Check the middle term by hand. q − x̂ = (0, 0.2, 0.1, −0.1 | −0.1, 0.1, 0.2, 0.1) and x − x̂ = (−0.1, 0.1, 0.2, 0.1 | −0.2, 0.2, 0.1, 0.2):
The identity closes exactly. Now the statistical reading: over many database points, the residual (x − x̂) points in an essentially arbitrary direction relative to (q − x̂), so the cross term averages to zero and
ADC over-estimates distances by, on average, the mean squared quantization error. The paper notes you can subtract that constant to get an unbiased estimator — and also that it does not matter for ranking, because subtracting the same constant from every candidate changes nothing. What does matter is the variance: the noise floor it adds is what caps recall. Shorter codes, larger quantization error, higher noise, lower ceiling. That is Chapter 1's ceiling, now with a mechanism.
SDC quantizes the query too, then looks up precomputed centroid-to-centroid distances. Encode our query: q(1) is nearest to c(1)0 (squared distance 0.06, the smallest entry in T1) and q(2) is nearest to c(2)1 (0.07). So code(q) = (0, 1) — the same code as x. Therefore:
Estimated distance zero, true distance 0.332. Worse, every database vector whose code is (0, 1) now gets distance exactly 0, so SDC cannot rank within a cell at all — it has thrown away all the resolution that the full-precision query still had. The paper's conclusion follows immediately: ADC has strictly lower distance-estimation error, and SDC is worth using only when the query itself must be stored compressed (for instance when you are indexing the queries too).
Back to d = 128, m = 8, k* = 256, scanning one IVF list of 244 vectors, or all 1,953 vectors of an nprobe = 8 search.
| Step | Cost | Notes |
|---|---|---|
| Build the tables | m × k* × d* = 8 × 256 × 16 = 32,768 multiply–adds | Once per query (per probed list, if using residuals) |
| Score 1,953 codes | 1,953 × 8 = 15,624 lookups + 13,671 adds | Zero multiplications |
| Exact float equivalent | 1,953 × 128 = 250,000 multiply–adds | 16× more arithmetic |
| Memory streamed, PQ | 1,953 × 8 B = 15.6 KB | Fits in L1 cache |
| Memory streamed, float | 1,953 × 512 B = 1.0 MB | Blows L2, hits L3 or DRAM |
Notice the last two rows are the real story. The arithmetic is 16× cheaper, which is nice; the memory traffic is 64× cheaper, which is decisive on a bandwidth-bound workload. And the tables themselves are 8 × 256 = 2,048 floats = 8 KB — they live in L1 for the entire scan.
Modern implementations push this further with 4-bit PQ and SIMD lookups. With k* = 16 instead of 256, a whole sub-table is 16 bytes and fits in a single SIMD register, so the lookup becomes a vector shuffle instruction (vpshufb on x86, tbl on ARM) that scores 16 or 32 codes per instruction rather than one at a time. FAISS calls these "fast scan" indexes and writes them as PQ64x4fs; they are several times faster than the 8-bit version at slightly lower per-code accuracy, which is usually recovered by using twice as many subquantizers at the same total bytes.
PQ assumes the chunks are independent and, implicitly, that the variance is spread evenly across them. Real data violates both. If your embedding has most of its energy in the first 30 coordinates — which is exactly what happens after any PCA-like transform, and approximately what happens in many learned embeddings — then chunk 1 has huge variance and chunk 8 has almost none. You have spent equal bits on both, which is a bad allocation.
Optimized product quantization (Ge, He, Ke & Sun, 2013; independently "Cartesian k-means") learns an orthogonal rotation matrix R and encodes R·x instead of x, choosing R to balance variance across the chunks and decorrelate them. Because R is orthogonal it preserves all distances, so nothing is lost — it merely presents the data to PQ in a friendlier basis.
For d = 128 that is 16,384 multiply–adds once per query — half the cost of building the PQ tables, and utterly negligible against the scan. In exchange you typically gain two to five points of recall. It is one of the highest-return-per-line changes available, which is why FAISS index strings so often start with OPQ.
PQ's recall ceiling is real and unavoidable: two different vectors with the same code are indistinguishable, forever. The standard escape is a two-stage search:
The recall of the final answer is now bounded by the recall of the shortlist at r, not at k — and shortlist recall at r = 500 is dramatically higher than top-10 recall from codes alone, because PQ's estimates are noisy enough to reorder within the top 10 but rarely bad enough to eject a true neighbour from the top 500. This is why "IVF-PQ has bad recall" is almost always a statement about a missing rerank stage rather than about PQ.
Chapter 0's impossible corpus, re-costed with PQ at d = 128 (the classic SIFT1B / BIGANN setting):
| Configuration | Bytes per vector | Total for 109 | Fits on |
|---|---|---|---|
| Flat float32 | 512 | 512 GB | A very large, very expensive machine |
| PQ m = 8, plus int64 id | 8 + 8 = 16 | 16 GB | A laptop |
| PQ m = 16, plus int64 id | 16 + 8 = 24 | 24 GB | A small server |
| PQ m = 32, plus int32 id | 32 + 4 = 36 | 36 GB | A small server |
| PQ m = 64, plus int32 id | 64 + 4 = 68 | 68 GB | A mid-size server |
A 32× factor between the first and last row, and a 32× factor again between the last row and the second. The choice of m is the single biggest lever you have on the cost of a large vector service, and it moves recall smoothly: reported BIGANN results put m = 8 in the rough neighbourhood of 0.2–0.4 for 1-recall@1 without reranking and considerably higher for recall@100, with each doubling of m buying a substantial chunk of what remains. Add reranking and the numbers move again. Measure on your own data — the shape holds, the exact values do not transfer.
This is Worked Example 6, running. The 8-dimensional vector is split into two halves; each half is matched against its four-entry codebook; the winning centroid is highlighted and the code is assembled. Then the query's two lookup tables are built and the estimate for each of six database vectors is read off as a sum of two entries. Toggle to SDC and watch the ranking collapse. Toggle "true distances" to see exactly which pairs the approximation reorders.
Query A is the one worked by hand above; its ADC estimate for vector x1 should read 0.06 + 0.07 = 0.13 against a true 0.11. Query B is chosen to sit between two centroids in the second subspace, where the quantization error is large and the ranking genuinely breaks — that is the failure PQ actually exhibits in production, and seeing it on six vectors makes the million-vector version unsurprising.
m is the one parameter that trades memory against the recall ceiling, and it is worth reasoning about rather than copying. Two constraints bound it from either side.
From below: each subquantizer must have enough dimensions to be meaningful. With k* = 256 centroids covering a d*-dimensional subspace, the centroids per dimension is 2561/d*. At d* = 4 that is 4 centroids' worth of resolution per axis — already coarse. At d* = 2 it is 16, which sounds better but wastes bits describing a nearly-empty plane. The community converged on d* between 4 and 16 for a reason.
From above: memory. Each subquantizer costs one byte (at k* = 256), so m is your byte budget.
| d | m | d* | Bytes/vector | Compression | Character |
|---|---|---|---|---|---|
| 128 | 8 | 16 | 8 | 64× | Very aggressive; shortlist only, rerank mandatory |
| 128 | 16 | 8 | 16 | 32× | The billion-scale default |
| 128 | 32 | 4 | 32 | 16× | High recall without rerank on many datasets |
| 768 | 96 | 8 | 96 | 32× | Modern text embeddings, memory-conscious |
| 768 | 192 | 4 | 192 | 16× | When recall matters more than RAM |
| 1536 | 96 | 16 | 96 | 64× | Consider truncating d first — see Chapter 8 |
Everything above computed squared Euclidean distance. If your objective is maximum inner product, the table is built differently — and, pleasingly, more simply. Because the inner product decomposes additively over the coordinates just as squared distance does:
So the lookup table holds inner products instead of squared distances, and the per-candidate operation is still m lookups and m−1 adds. The only change is the sign of what you sort by. FAISS exposes this as METRIC_INNER_PRODUCT, and it is the substrate on which Chapter 6's anisotropic weighting is applied — ScaNN keeps this scoring machinery and changes only the objective that trains the codebooks.
python — a complete, working PQ. Reproduces the hand calculation exactly.import numpy as np from scipy.cluster.vq import kmeans2 class PQ: def __init__(self, m, ks=256): self.m, self.ks = m, ks def fit(self, X): n, d = X.shape self.ds = d // self.m # d* -- must divide exactly self.cb = np.zeros((self.m, self.ks, self.ds), "float32") for j in range(self.m): # m INDEPENDENT k-means runs sub = X[:, j*self.ds:(j+1)*self.ds] self.cb[j], _ = kmeans2(sub, self.ks, iter=20, minit="points") return self def encode(self, X): # -> (n, m) uint8 codes codes = np.empty((len(X), self.m), "uint8") for j in range(self.m): sub = X[:, j*self.ds:(j+1)*self.ds] # (n, ks) squared distances to this subspace's centroids D = ((sub[:, None, :] - self.cb[j][None])**2).sum(2) codes[:, j] = D.argmin(1) return codes def tables(self, q): # -> (m, ks), built ONCE per query return np.stack([((q[j*self.ds:(j+1)*self.ds] - self.cb[j])**2).sum(1) for j in range(self.m)]) def adc(self, T, codes): # -> (n,) estimated squared distances # m gathers and a sum. No multiplications touch the database at all. return sum(T[j][codes[:, j]] for j in range(self.m)) # Sanity check against Worked Example 6: set m=2, ks=4, plug in the two # codebooks by hand, and adc() must return 0.06 + 0.07 = 0.13 for x1.
That is the entire technique. The adc method is four tokens of numpy and it is the thing that lets a laptop hold a billion vectors. Type it out once and the rest of this lesson stops being abstract.
| Symbol | Meaning | Typical value |
|---|---|---|
| d | Vector dimension | 128, 768, 1536 |
| m | Number of subquantizers (chunks) | 8–64; must divide d |
| d* = d/m | Dimension of each chunk | 4–32 |
| k* | Centroids per sub-codebook | 256 (8 bits) or 16 (4 bits, SIMD) |
| code length | m · log2(k*) bits | 64 bits = 8 bytes at m = 8, k* = 256 |
| codebook storage | k* · d floats — independent of m | 128 KB at k* = 256, d = 128 |
| representable points | (k*)m | 264 at m = 8, k* = 256 |
| table build | k* · d multiply–adds per query | 32,768 |
| per-code score | m lookups + (m−1) adds | 8 lookups |
| estimator bias | +E[‖x − x̂‖2], constant across candidates | Harmless for ranking; its variance sets the ceiling |
IVF asks "which region?" and then scans. Graph methods ask a different question: "which neighbour should I step to?" — and answer it repeatedly until stepping stops helping. This chapter builds HNSW from two much older ideas, both of which you can hold entirely in your head, and then traces a complete search by hand.
Pugh's skip list (1990) solves a problem that looks unrelated: searching a sorted linked list. A linked list holding 1, 4, 7, 9, 13, 18, 22, 30 costs O(n) to search — you must walk every node, because a linked list has no random access.
The fix: build express lanes above it. Each element is promoted to the next level up with probability p (typically ½), independently. Level 0 has everything; level 1 has about half; level 2 about a quarter; and so on.
Searching descends: start at the top-left, walk right while the next element is still ≤ your target, then drop down a level and repeat. Each level you skip past roughly half the remaining elements, so the search is O(log n) expected.
Trace it. Sixteen elements, p = ½. Expected occupancy: level 0 = 16, level 1 = 8, level 2 = 4, level 3 = 2, level 4 = 1. Looking for 13:
| Level | Nodes at this level | Walk | Comparisons |
|---|---|---|---|
| 3 | 1, 22 | 1 → is 22 ≤ 13? No. Drop. | 1 |
| 2 | 1, 7, 18, 30 | 1 → 7 (7 ≤ 13, step) → 18 > 13, drop. | 2 |
| 1 | 1, 4, 7, 13, 18, 22, 30 | 7 → 13. Found. | 1 |
Four comparisons for sixteen elements, against an expected eight for a plain scan. At a million elements the gap is 20 versus 500,000.
In a metric space you cannot sort, so "walk right" is replaced by greedy routing: from your current node, look at its neighbours, move to whichever is closest to the query, and stop when no neighbour is closer than where you stand.
Whether that works depends entirely on the graph's edges. Connect each node only to its ten nearest neighbours and greedy routing takes O(N1/d) hops — you crawl across the space one small step at a time. Connect nodes at random and greedy routing has no gradient to follow at all.
Kleinberg's 1999 analysis of small-world networks gives the sharp answer. On a d-dimensional lattice with local links plus one long-range link per node drawn with probability proportional to r−d (r being the distance), greedy routing finds the target in O(log2 N) steps. Change the exponent in either direction — more long links or fewer — and the bound degrades to a polynomial. Navigability is not a property of "having some long edges"; it is a property of having them at every scale in the right proportion.
Malkov's NSW (2011–2014) got that link distribution for free from an incremental construction: insert points one at a time, connecting each new point to its M nearest among those already inserted. Early insertions happen when the graph is sparse, so their "nearest neighbours" are far away — they become the long-range links. Later insertions land in a dense graph and produce short links. The scale distribution emerges from the insertion order.
What was still wrong. Flat NSW routes in a polylogarithmic number of hops, but the first phase — getting from a random entry point into the right neighbourhood — is a long, low-information walk over high-degree nodes, and its cost grows with N. The long links and the short links are tangled in one graph, so every hop has to consider both.
Malkov & Yashunin's insight (arXiv:1603.09320) is to separate the scales explicitly, exactly as a skip list does. Build a hierarchy of graphs:
The upper layers are a coarse-to-fine zoom; layer 0 is where the accuracy is bought. The number of layers is O(log N) in expectation, the work per upper layer is O(1) in expectation, and the layer-0 beam search dominates the cost.
| Parameter | When it applies | What it controls | Typical |
|---|---|---|---|
| M | Build | Links per node per layer. Layer 0 gets Mmax0 = 2M. Sets memory and the recall ceiling of the graph | 16–48 |
| efConstruction | Build | Beam width while inserting. Higher = better-chosen neighbours = better graph. Costs build time only, never query time | 100–500 |
| ef (efSearch) | Query | Beam width at layer 0. Must be ≥ k. The runtime dial that traces the recall/QPS curve | k…1000 |
| mL | Build | Layer-assignment scale. The paper shows 1/ln(M) is near-optimal; almost nobody changes it | 1/ln(M) |
The memory arithmetic, done properly. N = 1,000,000, d = 128, M = 16, 4-byte neighbour ids.
Layer 0 gives every node up to 2M = 32 links:
Upper layers: the expected number of nodes summed over all layers above zero is
each with up to M = 16 links:
Plus the vectors themselves at 512 B each = 512 MB. Total 644 MB, or 644 bytes per vector, of which 132 bytes (20.5%) is graph. Push M to 48 and the graph alone becomes 384 bytes per vector, three quarters of the payload.
When inserting element e, you run a search to collect efConstruction candidates and must choose M of them as neighbours. The obvious choice — the M nearest — produces a graph that fails in a specific, instructive way.
Picture two dense clusters, A and B, with a gap between them. Insert a point in cluster A. Its M nearest candidates are all in cluster A, because A is dense. Every point in A links only within A. The same happens in B. The result is a graph with no edges crossing the gap: greedy routing that enters A can never reach B, and recall for queries near B that should return points in A collapses. The graph is locally excellent and globally disconnected.
HNSW's Algorithm 4 fixes this with a diversity rule. Walk the candidates in increasing distance from e. Accept a candidate c only if
In words: keep c only if it is closer to me than to anything I have already agreed to link. If c is closer to an existing neighbour r than to me, then routing from me toward c can go via r, so the direct edge is redundant — and spending a precious slot on it costs me an edge in a direction I do not yet cover.
The effect is that the M chosen neighbours point in M different directions rather than all clustering in the densest one. In the two-cluster picture, once the first few in-cluster neighbours are accepted, further in-cluster candidates get pruned, and a point across the gap survives — because nothing already accepted is closer to it than e is. The bridge gets built.
Twelve points in the plane. Coordinates:
| Node | A | B | C | D | E | F | G | H | I | J | K | L |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| x | 1 | 2 | 4 | 6 | 7 | 5 | 3 | 8 | 9 | 6 | 2 | 9 |
| y | 1 | 3 | 2 | 1 | 4 | 5 | 6 | 7 | 2 | 8 | 8 | 9 |
Layers: layer 2 = {A, H}; layer 1 = {A, C, F, H, J}; layer 0 = all twelve. Entry point = A. Query q = (8.2, 8.4), k = 3, ef = 3.
The true answer, computed by brute force: L at 1.000, H at 1.414, J at 2.236.
Layer 2 (edges: A—H). Start at A.
H improves enormously, so move to H. H's only layer-2 neighbour is A, already worse. Greedy stops. One hop covered 89% of the distance to the query. That is what a sparse top layer is for.
Layer 1 (edges: A—C, C—F, C—H, F—J, F—H, H—J). Enter at H, d = 1.414. Neighbours of H at this layer are C, F, J:
None beats 1.414. Greedy stops immediately; H remains the entry point for layer 0. Cost: three distance computations.
Layer 0 (edges: A—B, A—C, B—C, B—G, C—D, C—F, D—E, D—I, E—F, E—H, E—I, F—G, F—J, G—K, G—J, H—I, H—J, H—L, J—K, J—L). Beam search with ef = 3.
Initialise: candidate queue = {H: 1.414}, result set W = {H: 1.414}.
Round 1. Pop the closest candidate, H. Expand its neighbours E, I, J, L:
W has room for 3. Insert L (1.000) and J (2.236); W = {L: 1.000, H: 1.414, J: 2.236}. E at 4.561 and I at 6.450 are worse than W's worst (2.236) and W is full, so they are discarded and not queued. Candidate queue = {L: 1.000, J: 2.236}.
Round 2. Pop L (1.000). Its neighbours are H and J — both already visited. Nothing changes.
Round 3. Pop J (2.236). The stopping rule is "halt when the closest unexpanded candidate is farther than W's worst"; 2.236 equals W's worst, so we proceed. J's neighbours are F, G, H, K, L:
All worse than 2.236; discarded. H and L already visited. Queue is empty. Halt.
The cost. Distances computed: A, H at layer 2; C, F, J at layer 1; E, I, L (J already known) at layer 0 from H; then F, G, K from J. That is ten distinct nodes out of twelve. B and D were never touched.
The two build parameters are usually copied from a blog post. They deserve twenty minutes of thought, because they are the ones you cannot change later without a rebuild.
Memory is a closed-form function of M, so start there and work backwards from your budget. With 4-byte neighbour ids:
The first term is the vector, the second is layer 0's 2M links, and the third is the expected upper-layer links (recall that N/(M−1) nodes exist above layer 0, each with up to M links). Evaluate at d = 768:
| M | Graph bytes/vector | Total at d = 768 | 10M corpus | Character |
|---|---|---|---|---|
| 8 | 69 | 3,141 | 31.4 GB | Cheap, and recall tops out lower — too few directions per node |
| 16 | 132 | 3,204 | 32.0 GB | The sensible default for most corpora |
| 32 | 260 | 3,332 | 33.3 GB | Better high-recall behaviour; the usual production choice |
| 48 | 388 | 3,460 | 34.6 GB | For hard, high-dimensional data. Diminishing returns beyond |
| 64 | 516 | 3,588 | 35.9 GB | Rarely justified; hops get expensive faster than recall improves |
efConstruction has a cleaner rule, because it costs nothing at query time: raise it until the recall/QPS curve stops moving. In practice 100 is thin, 200 is a good default, 500 is thorough, and beyond that the curve is usually indistinguishable while the build takes twice as long. The one signal that you set it too low: a curve that flattens below your target recall no matter how high ef goes. That is a graph whose edges are simply wrong, and no amount of query-time beam width repairs it — the same "ceiling versus effort" diagnosis as Chapter 1, one level up.
| Observation while sweeping ef | Diagnosis | Action |
|---|---|---|
| Recall rises smoothly and saturates near 0.99 | Healthy graph | Pick the knee and ship |
| Recall saturates at 0.90 and will not move | Graph quality, not search effort | Raise efConstruction, or M, and rebuild |
| Recall is fine but QPS falls off a cliff past some ef | The beam no longer fits in cache | Accept the knee; or shard so each index is smaller |
| Recall varies enormously between query types | Some regions are poorly connected | Check for duplicate clusters; deduplicate and rebuild |
The exact graph from Worked Example 8, drawn as three stacked layers. Press Step to advance one expansion at a time: the current node pulses, its neighbours are evaluated, and the beam contents are listed with live distances. Raise ef and watch the beam widen — more nodes evaluated, higher chance of catching a neighbour the greedy path would have walked past. Move the query to see routes that fail at ef = 1 and succeed at ef = 3.
Greedy routing has an obvious failure mode: you can walk into a local minimum, a node closer to the query than all of its neighbours but not the global answer, and stop. What property of the graph rules that out?
The exact answer is known. On the Delaunay graph — the graph connecting points whose Voronoi cells share a face — greedy routing is guaranteed to reach the true nearest neighbour from any starting point, with no local minima. That is a beautiful theorem and a useless one, because in d dimensions the Delaunay graph's average degree grows exponentially with d. At d = 128 nearly every point is a Delaunay neighbour of nearly every other point, and the graph is the complete graph — storing it costs more than the data and traversing it is a full scan.
Because it is an approximation, local minima do exist, and this is exactly what ef fixes. A beam of width ef does not stop at the first node with no improving neighbour; it keeps ef candidates alive and continues expanding until none of them can improve the result set. A local minimum traps a beam of width 1 and is stepped over by a beam of width 16. That is the mechanism behind the recall/QPS curve: ef is buying you escapes from local minima, and each additional escape is rarer and more expensive than the last — which is exactly why the curve saturates.
HNSW has no training stage, and its build is a loop of searches. Worth walking once, because every build-time parameter lives here.
HNSW insertion — Algorithms 1 and 4 of the paper, in wordsdef insert(e, M, efConstruction, mL): l = floor(-ln(uniform(0,1)) * mL) # this element's top layer ep = entry_point # PHASE 1: zoom down through layers ABOVE l with a width-1 greedy walk. # Cheap: these layers are sparse, and we only need a good entry point. for lc in range(top_layer, l, -1): ep = greedy_search(ep, e, layer=lc, ef=1) # PHASE 2: at each layer from l down to 0, do a real beam search and link. for lc in range(min(top_layer, l), -1, -1): W = search_layer(ep, e, layer=lc, ef=efConstruction) # candidates nb = select_neighbours_heuristic(e, W, M) # the DIVERSITY rule link(e, nb, layer=lc) # bidirectional for n in nb: # repair over-full nodes if degree(n, lc) > (2*M if lc == 0 else M): relink(n, select_neighbours_heuristic(n, neighbours(n, lc), 2*M if lc == 0 else M), lc) ep = W if l > top_layer: entry_point, top_layer = e, l
Three things this makes visible. One: efConstruction only appears in Phase 2, so it costs build time and nothing at query time — there is never a reason to be stingy with it beyond your patience. Two: the relink step means inserting one element can rewrite its neighbours' edge lists, which is why parallel insertion needs locking and why HNSW builds do not scale linearly with cores. Three: the same diversity heuristic is applied both when choosing e's neighbours and when repairing an over-full node — so the property is maintained, not merely established.
The build cost follows: N insertions, each a search of roughly the same cost as a query at ef = efConstruction, plus the repair work. At efConstruction = 200 that is roughly 200/efquery times the cost of a query, per inserted vector — which is why a million-vector build is minutes and a billion-vector build is not something you do on a laptop.
One small constraint trips up nearly everyone once. The beam is the result set, so an index cannot return more results than its beam held. Setting ef = 10 and asking for k = 100 does not return 100 items; it returns 10, or throws, depending on the library.
The consequence for a reranking pipeline is easy to miss. If you fetch 500 candidates for a reranker, ef must be at least 500 — and the recall you should be measuring is 10-recall@500, not 10-recall@10. Teams routinely set ef = 64 out of habit, request 500, silently receive 64, and then conclude the reranker is not helping. Check the length of what actually came back, on every path, at least once.
| Goal | k | Sensible ef | Metric to track |
|---|---|---|---|
| Result page, no reranker | 10 | 64–128 | 10-recall@10, plus nDCG |
| Feed a cross-encoder | 100 | 200–400 | 10-recall@100 |
| Feed an exact rerank over codes | 500 | 500–1000 | 10-recall@500 |
| Offline candidate generation | 1000 | 1000–2000 | Whatever the consumer needs |
HNSW is one algorithm and half a dozen widely used implementations, and they differ in ways that matter more than the parameter values you copy between them.
| Implementation | What is distinctive | Watch out for |
|---|---|---|
hnswlib | The reference, by the paper's authors. Small, fast, header-only | Fixed capacity set at construction; deletes are tombstones only |
FAISS IndexHNSW* | Composes with everything else — can wrap a PQ or scalar-quantized storage layer, or act as an IVF coarse quantizer | Parameter names differ from the paper's (efConstruction, efSearch live on the index, not the search call) |
| Lucene / OpenSearch HNSW | Segment-based: each segment holds its own graph, and merges rebuild them | Search fans out across segments, so recall and latency depend on merge state — a moving target under heavy indexing |
pgvector hnsw | Inside Postgres, so filters, joins, and transactions come free | Filtered queries hit exactly Chapter 7's problem; check the plan, and consider partial indexes per filter value |
usearch, and similar | Quantized storage inside the graph (f16, i8), which cuts the dominant memory term | Distances are now approximate during the walk as well as the ranking — measure, do not assume |
| Problem | Why it happens | What people do |
|---|---|---|
| Memory | Full-precision vectors must stay resident for hop distances, plus 128–400 B/vector of graph | Cap the corpus, shard, or move to DiskANN / IVF-PQ |
| Slow build | Every insert is a full search with beam efConstruction. Build is often 10–100× the cost of an IVF build | Parallel insert (with locking), lower efConstruction, or build once and treat as immutable |
| Deletes | Removing a node can disconnect the graph; there is no cheap repair | Tombstone and filter at read time; rebuild periodically. This is why "just use HNSW" fails on mutable corpora |
| Filtered queries | Greedy routing follows the graph, not the predicate. With a selective filter, whole neighbourhoods are ineligible and the walk stalls | Pre-filter and brute force when selectivity is high; filter-aware graphs (ACORN, filtered-DiskANN) otherwise. See Chapter 7 |
| ef must exceed k | The beam is the result set; you cannot return 100 results from a beam of 10 | Always set ef ≥ k, in practice 2–10× k |
| Cold start / mmap | Random access across the whole graph means page faults everywhere until warm | Warm the index before serving; never mmap an HNSW index from spinning disk |
Against all of that: when the data fits in RAM, HNSW is very hard to beat. On the standard million-scale benchmarks it sits at or near the top of the recall/QPS frontier, reaching recall above 0.95 while evaluating a fraction of a percent of the corpus, with one intuitive runtime knob and no training stage to go stale. If your corpus is under ten million vectors and mostly static, the correct first move is HNSW, and the correct second move is to measure before doing anything cleverer.
Subramanya, Devvrit, Kadekodi, Krishnaswamy and Simhadri's NeurIPS 2019 paper makes a claim that sounds impossible after Chapter 4: one billion points, a single machine with 64 GB of RAM, more than 5,000 queries per second, 95%+ 1-recall@1, mean latency under 5 milliseconds. This chapter derives how, and the derivation is almost entirely arithmetic about a storage device.
The naive plan: build an HNSW graph over a billion points and keep the vectors on SSD, paging them in as the walk needs them. Cost it out with d = 128.
The memory that would be needed if you kept it all resident — Chapter 4's arithmetic at a billion:
What happens if you page instead. A random 4 KB read from a good NVMe SSD takes about 100 microseconds at queue depth 1. A graph search visits, say, 120 nodes to reach 95% recall. And — this is the crucial part — the visits are sequentially dependent: you cannot know which node to fetch next until you have read the current one and computed distances to its neighbours.
Twelve milliseconds is already over a tight latency budget, and it is a floor: no amount of CPU helps, and the drive is doing 120 IOPS worth of work per query at queue depth 1, so throughput is dreadful too. Paging a graph index is not a small inefficiency; it is a different performance regime.
A billion 128-dimensional points on a 64 GB machine.
| What | Where | Size | Arithmetic |
|---|---|---|---|
| PQ codes, m = 32 | RAM | 32 GB | 109 × 32 B |
| PQ codebooks | RAM | 128 KB | 256 × 128 floats |
| Cached graph near the entry point | RAM | ~1–4 GB | All nodes within 3–4 hops of the medoid |
| Full vectors + adjacency | SSD | ~772 GB | 109 × (512 B vector + 4 B degree + 64 × 4 B links) = 772 B/node |
| RAM total | ≈ 36 GB | Comfortably inside 64 GB |
Note how neatly the node record fits the hardware. 772 bytes is under a 4 KB sector, so a single read never straddles a boundary; DiskANN packs several nodes per sector and reads whole sectors, which is what the drive wants anyway. If d were 768, the node record would be 3,072 + 256 = 3,328 bytes — still one sector, but only one node per sector, which is exactly why high-dimensional corpora often get dimension-reduced (or Matryoshka-truncated) before a disk index is built.
Vamana builds a directed graph with maximum out-degree R (typically 64–128). The loop is simple:
Vamana, in words and then in numbers# 1. Start from a random R-regular graph, or an empty one. # 2. Compute the MEDOID s = the point minimising the sum of distances to all # others (approximated on a sample). This is the fixed entry point for # every query forever -- a deliberate choice, unlike HNSW's top layer. # 3. For each point p, in random order: # V = GreedySearch(s -> p) # the set of nodes VISITED, not just the top-k # out(p) = RobustPrune(p, V, alpha, R) # for each j in out(p): # keep the graph roughly undirected # out(j) = out(j) + {p} # if |out(j)| > R: out(j) = RobustPrune(j, out(j), alpha, R) # 4. Run the whole pass TWICE: first with alpha = 1, then with alpha = 1.2.
RobustPrune is where α lives. Given a candidate set V, sort by distance from p and repeatedly take the closest surviving candidate p*, add it to the output, then delete every remaining candidate v satisfying
Read that as a detour test. The edge p → v is redundant if you could get from p to v by hopping through p* and the detour is not much longer than the direct edge. With α = 1 the rule is exactly HNSW's diversity heuristic (and the classical relative-neighbourhood graph): prune v whenever the detour is no longer than the direct edge. With α > 1 the rule becomes more permissive: keep the direct edge unless the detour is shorter by a factor of α.
The hand-worked pruning decision. Take p = (0, 0), the closest accepted neighbour a = (1, 0) so d(p, a) = 1, and a candidate v = (1.5, 2.4).
Now apply the rule at two settings:
The ratio d(p,v)/d(a,v) = 2.830/2.452 = 1.154. Any α above 1.154 keeps this edge; anything below prunes it. Geometrically, v sits far from p but sideways relative to a, so routing through a saves only 13% of the distance. α = 1.2 says: unless the detour saves you 20%, keep the direct road.
The other deliberate difference from HNSW: Vamana is flat. No layers. The long edges that the hierarchy was providing are now inside the single graph, put there on purpose by α rather than emerging from insertion order. A flat graph is much easier to lay out on disk — there is exactly one record per point — and the fixed medoid entry point removes the need to store or traverse upper layers at all.
HNSW spends a hierarchy on the problem of "where should a search start?" Vamana answers it with a single fixed point and no hierarchy at all, which deserves an explanation rather than an assertion.
The medoid is the data point minimising the total distance to all other points — the most central actual point, as distinct from the centroid, which is an average and generally not in the dataset. Computing it exactly is quadratic, so it is estimated on a sample:
estimating the medoid — enough precision for an entry pointS = sample(X, 100_000) # a sample is plenty; this need not be exact c = S.mean(axis=0) # the centroid -- not necessarily a real point medoid = argmin(dist(x, c) for x in S) # the real point nearest to it
Why is one entry point sufficient? Because α-pruning has already put long-range edges into the graph deliberately. In flat NSW the entry point mattered enormously, since reaching a distant region required many short hops. In a Vamana graph the first few hops from the medoid are long by construction — that is precisely what α = 1.2 retained — so a search reaches any region of the space in a handful of steps regardless of where it started.
Three consequences, all of them practical:
| Consequence | Why it follows |
|---|---|
| The graph file has exactly one record per point | No layers to store, no per-layer adjacency. Simple sector-aligned layout |
| The hot cache is trivially identifiable | Every query starts at the same node, so the nodes near it are read by every query. Cache them and the first hops become free |
| Query latency has lower variance | Every search takes the same route out of the gate, rather than starting from a randomly chosen top-layer node |
The cost is that the medoid must be recomputed when the data distribution shifts substantially — another item for the rebuild checklist, and a cheap one.
Greedy search is a chain: read a node, learn its neighbours, pick one, read it. Each link in the chain is a full SSD latency, and the drive is idle in between. The fix is to widen the frontier.
Beam width W means: at each round, take the W closest unvisited candidates and issue all W reads simultaneously. The drive processes them concurrently — NVMe is built for exactly this, with deep hardware queues — so W reads cost about the same wall-clock time as one.
Worked example 10 — latency and throughput versus beam width. Assume a search needs about 120 node visits for 95% recall, and a 4 KB random read costs 100 µs.
| Beam width W | Rounds (≈ visits / W) | Latency (rounds × 100 µs) | I/Os per query | QPS ceiling at 600k IOPS |
|---|---|---|---|---|
| 1 | 120 | 12.0 ms | 120 | 5,000 |
| 2 | 60 | 6.0 ms | ~130 | 4,600 |
| 4 | 30 | 3.0 ms | ~150 | 4,000 |
| 8 | 15 | 1.5 ms | ~190 | 3,150 |
| 16 | 8 | 0.8 ms | ~280 | 2,140 |
Two things fall straight out. First, beam width is a latency-versus-throughput dial, not a recall dial: it makes each query faster while making the fleet slower, because wider beams re-read nodes the narrow search would have skipped. Second — and this is the satisfying part — the paper's headline of "over 5,000 QPS" is not a mystery number. A single high-end NVMe drive sustains roughly 500,000 to 1,000,000 random 4 KB IOPS. Divide:
The headline throughput is the drive's IOPS rating divided by the graph's hop count. That is the whole system in one division, and it tells you exactly which two numbers to improve: buy more IOPS, or build a graph with fewer hops. α-pruning is the second option.
During the walk, when a node's neighbours are considered, their distances are estimated from the in-memory PQ codes — free, no I/O. Only nodes actually selected for expansion have their sectors read, and those reads deliver full-precision vectors, which are used to compute exact distances for the result set.
So the search is guided by cheap approximations and answered with exact numbers. The quantization error affects which nodes get visited (a routing decision, where being slightly wrong usually still lands you in the right neighbourhood) but never the final ranking (where being wrong is fatal). This is the deepest structural idea in the paper, and it is the same asymmetry Chapter 3 described for ADC, applied one level up.
DiskANN's RAM budget is dominated by one line: the PQ codes. So m is not a compression decision here, it is a machine size decision, and the two constraints pull in opposite directions.
| m (bytes/code) | RAM for 109 codes | Fits in 64 GB with the graph cache? | Routing quality |
|---|---|---|---|
| 8 | 8 GB | Easily | Poor — the walk wanders, so more hops, so more I/O |
| 16 | 16 GB | Yes | Adequate |
| 32 | 32 GB | Yes, with ~30 GB to spare | Good — the paper's operating point |
| 64 | 64 GB | No — nothing left for the cache | Excellent, and unusable |
The tension is unusual and worth naming: shorter codes save RAM but cost disk I/O, because a badly routed walk visits more nodes and every extra node is a 100 µs read. Compression here is not a pure win against a recall ceiling as it is in IVF-PQ — the final ranking uses exact vectors from disk, so recall is not capped by m at all. What m controls is how many sectors you have to read to find the answer. That is a genuinely different role for the same technique, and it is why DiskANN's m (32) is larger than a typical IVF-PQ m (8–16) despite both being "PQ."
Vamana's construction is itself memory-hungry — it needs random access to vectors. The paper's answer is a merge-based build:
| Step | What happens | Why |
|---|---|---|
| 1. Cluster | k-means the corpus into k ≈ 40 shards | Each shard is small enough to build in RAM |
| 2. Overlap | Send each point to its two nearest shards | Points near shard boundaries need edges on both sides — the same soft-assignment trick as multi-probe IVF in Chapter 2 |
| 3. Build | Run full Vamana independently per shard | Parallel, in RAM, no coordination |
| 4. Merge | Union the edge lists per point, then RobustPrune back down to R | A point present in two shards contributes both neighbourhoods; pruning restores the degree bound and the diversity property |
| 5. Lay out | Write vector + adjacency per node into sector-aligned records | One read per hop at query time |
The reported build for a billion SIFT descriptors took on the order of two days on a single 64 GB machine. That is the honest headline cost of DiskANN: queries are cheap, construction is a batch job you schedule. If your corpus turns over daily, this is the wrong index — which is precisely why FreshDiskANN followed, adding a small in-memory index for recent writes that is periodically merged into the on-disk graph, the same read-optimised-plus-write-buffer split that LSM-tree storage engines use.
The layout is the design, so it is worth seeing as bytes. For d = 128 and R = 64:
| Offset | Field | Bytes | Why it is here |
|---|---|---|---|
| 0 | vector, float32 × 128 | 512 | Exact coordinates for the final ranking — the whole reason to touch the disk |
| 512 | degree, uint32 | 4 | Vamana's out-degree is variable up to R |
| 516 | neighbour ids, uint32 × up to 64 | 256 | Where to walk next, delivered by the same read |
| node record | 772 | Five nodes fit in a 4 KB sector with 140 bytes spare |
The co-location is not a micro-optimisation; it halves the number of round trips, which is the only currency here. Separating vectors and adjacency into two files would mean two dependent reads per hop, doubling latency and halving throughput at a stroke.
Note also what is not in the record: nothing about layers, because Vamana is flat, and no PQ code, because those live in RAM in one contiguous array indexed by node id. The two representations of the same point are physically separate and used at different moments.
Every single query starts at the medoid and takes its first few hops through the same small set of nodes. Those nodes are read on every query, which is exactly the access pattern a cache is for.
The arithmetic. Cache every node within 4 hops of the medoid. With out-degree R = 64 the reachable set grows fast, but the graph is not a tree and overlap is heavy; in practice a few million nodes covers it. At 772 bytes per node:
— against a 64 GB budget already holding 32 GB of PQ codes, so it fits comfortably. And it removes the first 3–4 hops of every query from the I/O path entirely. If a query takes 120 hops, saving 4 sounds trivial — but those 4 are sequential and at the head of the dependency chain, and with beam width 4 they represent a full round of latency each. The paper reports this cache as a real contributor, and it is the cheapest optimisation in the system.
DiskANN beam search — where each precision is useddef search(q, k, L, W): # L = candidate list size, W = beam width cand = PriorityQueue([(pq_dist(q, medoid), medoid)]) # RAM, approximate seen, out = {medoid}, [] while cand.has_unexpanded(): # pick the W closest UNEXPANDED candidates -- all by PQ, zero I/O batch = cand.closest_unexpanded(W) # ONE round trip for all W. This is the whole point of the beam. sectors = ssd.read_many([node_offset(n) for n in batch]) for n, sec in zip(batch, sectors): vec, nbrs = parse(sec) out.append((exact_dist(q, vec), n)) # EXACT -- for the answer for m in nbrs: if m not in seen: seen.add(m) cand.push((pq_dist(q, m), m)) # APPROXIMATE -- for routing cand.truncate(L) return sorted(out)[:k] # ranked by EXACT distances only
Every call to pq_dist is a table lookup over data already in RAM and decides only where to go. Every call to exact_dist uses bytes that arrived from the SSD and decides only what to return. Trace those two functions through the loop and you have the paper.
Worked example 10 costed a single query. Serving many at once changes which resource binds, and the arithmetic is worth finishing.
Assume 120 reads per query, beam width 4, a drive rated at 600,000 random 4 KB IOPS and 3 GB/s of sequential bandwidth.
| Offered load | IOPS demanded | Bandwidth demanded | Binding constraint |
|---|---|---|---|
| 500 QPS | 60,000 | 0.24 GB/s | Neither — latency is 3 ms, comfortably |
| 2,000 QPS | 240,000 | 0.98 GB/s | Neither, but queueing begins to show in the tail |
| 5,000 QPS | 600,000 | 2.46 GB/s | IOPS, exactly at the rating. Latency starts climbing |
| 7,500 QPS | 900,000 | 3.69 GB/s | Both, and the drive is saturated. Latency degrades sharply |
Notice that IOPS runs out before bandwidth does, by a comfortable margin — 4 KB reads at 600k IOPS is only 2.46 GB/s against a 3 GB/s rating. That is the signature of a random-access workload, and it tells you which drive specification to shop on. It also explains why widening the beam is not free at scale: beam width 8 needs roughly 60% more reads per query, so the same drive supports proportionally fewer queries per second even though each one finishes faster.
| Situation | Why it fails | What to use instead |
|---|---|---|
| Corpus under ~107 | All the machinery buys nothing; the data fits in RAM | HNSW, or a flat index |
| High write rate | Builds are batch jobs measured in hours or days | IVF with a write buffer, or FreshDiskANN with a compaction budget |
| Sub-millisecond latency required | An SSD round trip is 100 µs and you need several rounds | In-memory anything |
| Network storage instead of local NVMe | Network latency is 10–50× a local read, and the design is a latency budget | Local NVMe, or a different architecture entirely |
| Highly selective filters | The graph walk lands on ineligible nodes and stalls, and each stall cost a disk read | Partition by the filter; brute-force the subset |
Everything above assumes the graph is built once and then read forever. Real corpora are not like that, and the gap between "billion-point search on one node" and "billion-point search on one node with a live write path" took a second paper to close.
The three hard operations, in increasing difficulty:
| Operation | Why it is hard on a disk graph | FreshDiskANN's answer |
|---|---|---|
| Insert | Requires a search plus edge rewrites on neighbours — random writes across the SSD | Buffer new points in a small in-memory Vamana index; search both and merge results |
| Delete | Removing a node can strand the nodes that routed through it | Tombstone immediately; during the periodic merge, reconnect each deleted node's in-neighbours to its out-neighbours and re-prune |
| Merge | Rewriting a 772 GB file is not an online operation | Background compaction of the in-memory delta into the on-disk graph, at a cadence set by the write rate |
The practical planning rule that falls out: your rebuild cadence must be shorter than the time it takes the delta to become a significant fraction of the base. If 1% of a billion points changes per day, the in-memory delta is 10 million points — a perfectly reasonable in-memory Vamana index — and a weekly compaction keeps it that way. If 20% changes daily, no compaction schedule saves you and a disk-resident graph is the wrong architecture.
| HNSW | DiskANN / Vamana | |
|---|---|---|
| Structure | Hierarchical, multi-layer | Flat, single graph |
| Entry point | Top-layer node, effectively random within it | Fixed medoid |
| Long edges come from | Layer assignment plus insertion order | α > 1 in RobustPrune, deliberately |
| Optimised for | Fewest distance computations | Fewest dependent I/Os — low diameter |
| Typical degree | M = 16–48 (32–96 at layer 0) | R = 64–128 |
| Where vectors live | RAM, full precision | SSD full precision + RAM PQ codes |
| RAM for 109 × 128 | ~768 GB | ~36 GB |
| Latency | ~0.1–1 ms | ~2–5 ms |
| Build time | Minutes at 106 | Days at 109 |
| Best when | Corpus fits in RAM | Corpus does not, and you refuse to buy a cluster |
DiskANN is slower per query than in-memory HNSW, by roughly an order of magnitude, and that is the correct trade: it is 20× cheaper in hardware for the same corpus. Nobody runs DiskANN on a million vectors. Everybody should consider it before provisioning a 40-machine HNSW fleet.
Every quantizer so far has minimised the same thing: reconstruction error, E[‖x − x̂‖2]. It is the objective k-means minimises, it is what PQ inherits, and it feels obviously right — a good compression is one that loses little.
Guo, Sun, Lindgren, Geng, Simcha, Chern and Kumar's ICML 2020 paper (arXiv:1908.10396) points out that it is not obviously right, because reconstruction error is not the thing you care about. You care about whether the ranking survives. And it turns out that some reconstruction errors damage the ranking severely while others, of exactly the same magnitude, do not damage it at all.
ScaNN targets maximum inner product search (MIPS): given q, find argmaxx 〈q, x〉. This is the native objective of two-tower recommenders and retrieval models, where the score is a dot product and the vectors are deliberately not normalised — norm carries information (popularity, confidence, document length).
Quantization enters as an approximation to the score. You store x̂ instead of x, so you compute 〈q, x̂〉 instead of 〈q, x〉. Write the residual r = x − x̂. Then the error in the score is exactly
So the quantity we actually want small is 〈q, r〉 — the projection of the residual onto the query — not ‖r‖. Those are very different demands, and the difference is a direction.
We do not know q at training time. But we know something about which q matters. A database point x only ever affects a result list when it scores highly, and it scores highly only for queries pointing roughly along its own direction. Queries pointing elsewhere give x a low score, x is nowhere near the top-k, and an error in its score changes nothing.
Make that precise. Split the residual into its component along x and the rest:
Now take the extreme case where the query is exactly aligned with x, q = x / ‖x‖. Then 〈q, r⊥〉 = 0 by construction, and the entire score error comes from the parallel part:
Move q away from x's direction and the perpendicular part starts to contribute — but in high dimensions, a query that scores x highly cannot be far from x's direction, because inner products with random directions concentrate near zero (Chapter 0's result, arriving for the third time). So conditioned on "x is a plausible top-k answer for q," the parallel component dominates the error, and it dominates more as the dimension grows.
This is why the paper's derived weights depend on both the dimension and a score threshold T: the higher the bar for "this item matters," the tighter q is pinned to x's direction, and the more the parallel weight should exceed the perpendicular one. In practice the whole thing collapses to a single tunable ratio.
Set h∥ = h⊥ and you recover ordinary k-means exactly, since ‖r∥‖2 + ‖r⊥‖2 = ‖r‖2. Anisotropic quantization is a strict generalisation with one extra knob, and the knob has a clear meaning: how much more do I fear a length error than a sideways error?
This is the whole paper in eight lines of arithmetic. Take a two-dimensional database point:
Consider two candidate reconstructions, chosen so that their reconstruction errors are exactly equal.
Candidate A — a pure length error. x̂A = (3.6, 4.8):
Candidate B — a pure sideways error. x̂B = (3.8, 3.4):
Ordinary k-means is completely indifferent between A and B. Both have squared error 1.0. Now score them with a query aligned to x — the query for which x is a genuine top result. Take the unit vector q = (0.6, 0.8):
| Quantity | Value | Error vs truth |
|---|---|---|
| 〈q, x〉 = 0.6(3) + 0.8(4) | 1.8 + 3.2 = 5.000 | — (the truth) |
| 〈q, x̂A〉 = 0.6(3.6) + 0.8(4.8) | 2.16 + 3.84 = 6.000 | +1.000 — a 20% overestimate |
| 〈q, x̂B〉 = 0.6(3.8) + 0.8(3.4) | 2.28 + 2.72 = 5.000 | 0.000 — exactly right |
Identical reconstruction error; one is perfect and the other is off by a full unit on a score of 5. And now the other half of the argument — check a query perpendicular to x, where B's error should show up. Take q′ = (0.8, −0.6):
| Quantity | Value | Error vs truth |
|---|---|---|
| 〈q′, x〉 = 0.8(3) − 0.6(4) | 2.4 − 2.4 = 0.000 | — (the truth) |
| 〈q′, x̂A〉 | 2.88 − 2.88 = 0.000 | 0.000 |
| 〈q′, x̂B〉 = 0.8(3.8) − 0.6(3.4) | 3.04 − 2.04 = 1.000 | +1.000 |
B is wrong by 1.000 here. But look at where: it turned a score of 0 into a score of 1, on an item that was never going to be in the top-k of that query. Compare with A's failure, which turned a score of 5 into a 6 — on a query where x is a contender, and where inflating the score by 20% can push x past a genuinely better item. The same magnitude of error costs everything in one place and nothing in the other.
The loss, evaluated. With h⊥ = 1 and h∥ = η:
At η = 4, candidate A is charged four times as much as B, and any k-means-style optimisation over this loss now prefers B decisively. Change the objective, and the centroids move to different places — places that preserve rankings rather than places that preserve coordinates.
The paper derives h∥ and h⊥ in closed form from an assumption that queries are uniform on the sphere, conditioned on the score exceeding a threshold T. The intuition is one you already have from Chapter 0.
A uniformly random unit vector in d dimensions has inner product with any fixed direction distributed with standard deviation 1/√d. At d = 768 that is 0.036. So conditioning on "this query scores x above a meaningful threshold" is an extremely tight constraint: it selects queries lying in a narrow cap around x's direction. Inside that cap, the perpendicular components of q are small, so 〈q, r⊥〉 is small, while 〈q, r∥〉 is nearly the full ‖r∥‖.
Which gives a clean, checkable prediction: anisotropic quantization should help more in high dimensions and more when you only care about a short result list — and less when d is small or you need a long, well-ordered tail. That is exactly the reported behaviour.
The claim that "queries aligned with x are the ones that matter" deserves numbers rather than assertion. Stay in two dimensions with x = (3, 4), ‖x‖ = 5, and parameterise the query by the angle φ it makes with x. Take a unit query q(φ), so the true score is
Candidate A's residual is parallel with ‖r∥‖ = 1, so its score error is 〈q, r∥〉 = 1 · cos φ. Candidate B's is perpendicular with ‖r⊥‖ = 1, so its score error is 1 · sin φ. Tabulate:
| φ | True score 5cosφ | A's error (cosφ) | B's error (sinφ) | Is x a top result here? |
|---|---|---|---|---|
| 0° | 5.00 | 1.000 | 0.000 | Yes — the best possible query for x |
| 15° | 4.83 | 0.966 | 0.259 | Yes |
| 30° | 4.33 | 0.866 | 0.500 | Probably |
| 60° | 2.50 | 0.500 | 0.866 | Unlikely |
| 85° | 0.44 | 0.087 | 0.996 | No — x is nowhere near the top |
Read the table as a weighting problem. B's error is largest exactly where it does not matter (φ near 90°, score near zero) and vanishes exactly where it would (φ near 0°). A's error does the opposite: it is at its maximum precisely on the queries for which x is the answer. Weight each row by the probability that x is actually a contender — which is concentrated at small φ — and A's expected damage dwarfs B's.
Now bring in dimension. In two dimensions, a uniformly random query has a decent chance of landing at small φ. In 768 dimensions the angle between a random unit vector and any fixed direction concentrates sharply around 90° — the cosine has standard deviation 1/√768 = 0.036, so a random query sits at φ ≈ 88–92° essentially always. Conditioning on "the score exceeds threshold T" therefore selects an astonishingly narrow cap around x's direction, and inside that cap the table above is dominated entirely by its top row.
That is the paper's closed-form result, arrived at by counting. And it produces a falsifiable prediction, which is the mark of a real derivation: anisotropic weighting should help most in high dimensions with a short result list, and least in low dimensions with a long one. Which is what the ablations show.
It is worth being concrete about how the learned centroids differ, because "a weighted loss" sounds like it should change little.
Plain k-means places a centroid at the mean of its assigned points. Averaging vectors that point in slightly different directions produces a result that is shorter than the vectors themselves — the classic shrinkage of averaging. Shortening is a residual along the data direction, which is exactly the parallel component. So ordinary k-means has a systematic bias toward the single error type that damages inner products most, and it has it structurally, not by accident.
The anisotropic loss penalises that shrinkage heavily, and the fitted centroids push outward relative to their k-means positions — better preserving the norms and radial positions of the points they represent, at the cost of tolerating more sideways error. The reconstruction error goes up. The ranking quality goes up too. If you ever measure a quantizer by mean squared error and find that the better-ranking one scores worse, this is why, and it means the metric is wrong rather than the quantizer.
The previous example showed an error of 1.0 on a score of 5.0. That only matters if it changes an ordering, so let us make it change one.
Two database items and one query, in two dimensions:
The true scores put y ahead:
Now quantize each of them with a unit-length residual, but in different directions. For x, take the parallel-error reconstruction from Worked example 12:
For y, build a unit residual that is exactly perpendicular. A vector orthogonal to y = (3.4, 4.2) is (−4.2, 3.4); normalise it by ‖y‖ = 5.404 to get the unit perpendicular u = (−0.777, 0.629). Set ŷ = y − u:
Both reconstructions carry exactly the same reconstruction error. Score them:
The estimated ranking now puts x above y, reversing the truth. Both reconstructions have exactly unit error, so a reconstruction-error objective sees them as equally good approximations — and yet one of them silently swapped the top two results. Repeat that across a corpus and you have an index whose top-1 accuracy is far worse than its mean squared error suggests.
The anisotropic loss charges x̂ η times more than ŷ and pushes the codebook away from exactly this failure. That is the entire mechanism, and it is why the paper's improvement shows up most strongly in recall@1 and least in recall@100 — deep in the list the ordering errors are between items nobody looks at.
A fair objection: if the final stage rescores candidates with exact inner products, does the quantizer's ranking error matter at all? It matters for exactly one thing, and it is the thing that determines recall — which candidates reach the rescoring stage.
If quantization pushes the true best item out of the top-r shortlist, no rescoring can bring it back. Chapter 1's shortlist-recall framing applies verbatim: the quantizer's job is not to rank correctly, it is to not eject the winner from the candidate pool. Anisotropic weighting improves precisely that, because the errors it suppresses are the ones capable of demoting a genuine top item by a large amount.
There is a well-known trick for turning MIPS into ordinary nearest-neighbour search, and it is worth working through because its failure is instructive and because you will meet people proposing it.
Let M = maxx ‖x‖. Augment every database vector with one extra coordinate, and the query with a zero:
Every augmented database vector now has norm exactly M, because ‖x‖2 + (M2 − ‖x‖2) = M2. And so:
The first two terms are constants, so minimising the augmented Euclidean distance is exactly maximising the original inner product. A clean reduction: now use any L2 index you like. Why does nobody ship this?
The arithmetic. Take M = 10 and two database vectors with ‖x1‖ = 2 and ‖x2‖ = 3. Their extra coordinates are
Let q be a unit vector with 〈q, x1〉 = 1.8 and 〈q, x2〉 = 1.5. The augmented squared distances are
Correct ordering — and look at the margin. The entire signal is 0.6 out of roughly 97.7, a relative difference of 0.6%. Everything an index would exploit — cell membership, graph structure, quantized codes — is now dominated by a constant term contributing 101 units, with the answer hiding in the sixth part of a percent.
The anisotropic loss is the paper's contribution; the library around it is a full three-stage pipeline, and the stages should look familiar by now.
When it was published, this combination took the top of the ann-benchmarks glove-100-angular leaderboard, roughly doubling the throughput of the previous best at comparable recall. The partitioning and the SIMD scoring were engineering; the anisotropic loss was the idea, and it is the part that transfers.
The anisotropic loss is applied twice, at two granularities, which is easy to miss:
| Stage | What is quantized | Effect of the anisotropic objective |
|---|---|---|
| Partitioning tree | The whole vector, into one of a few thousand branches | Branches are chosen so that the branch centre preserves scores, not coordinates — the routing decision itself becomes MIPS-aware |
| Residual PQ codes | The residual after the branch centre, into m 4-bit sub-codes | The main use, as derived above |
| Rescoring | Nothing — full precision | Unaffected. The final ranking is exact |
And the reason the library is fast rather than merely accurate is the 4-bit LUT16 scoring from Chapter 3: with k* = 16, each subquantizer's lookup table is 16 bytes, which fits in a single SIMD register, so a shuffle instruction scores many candidates at once. Accuracy from the loss, throughput from the register width, correctness from the rescoring pass. Three separate ideas, and only the first one is the paper.
A fair question, since most modern text encoders emit unit vectors and cosine similarity. If every ‖x‖ = 1, is there a parallel error to worry about?
Yes, and the reason is that the reconstruction is not normalised. The database vector x lies on the unit sphere; its quantized reconstruction x̂ is a sum of codebook centroids and lands wherever it lands — generally inside the sphere, because quantization is a form of averaging and averaging shortens. So the residual r = x − x̂ has a radial component even when x itself carries no norm information.
If the shrinkage were identical for every x it would cancel in the ranking and nothing would matter. It is not identical: vectors in dense regions are reconstructed accurately and barely shrink, while vectors in sparse regions shrink more. So the radial error is a density-dependent bias that systematically demotes items in sparse parts of the space — which, unhelpfully, is exactly where the distinctive, specific documents live.
The paper gives h∥ and h⊥ in closed form from a score threshold T, but the practical recipe is shorter and you should know both.
| Step | What to do | Why |
|---|---|---|
| 1 | Pick T as the score of the k-th result on a sample of real queries | The derivation conditions on "this item could be in the top k". T is that boundary, measured rather than guessed |
| 2 | Compute the weights from T and d, or simply sweep η = h∥/h⊥ over {1, 2, 4, 8, 16} | η = 1 is plain k-means, so the sweep contains the baseline and the comparison is honest |
| 3 | Evaluate with recall@k against exact MIPS ground truth — not with reconstruction error | Reconstruction error will get worse as η rises. That is the intended behaviour, not a regression |
| 4 | Re-check after any embedding-model change | T moves when the score distribution moves, and it moves a lot between models |
Step 3 is where people abandon the technique by accident. If your quantizer evaluation dashboard reports mean squared reconstruction error — and most do, because it is the easy number — then anisotropic quantization looks like a strict regression and gets reverted. Delete that metric or clearly mark it as not the objective.
"Optimise the compression for the decision, not for the reconstruction" is a template, and recognising it elsewhere is most of this chapter's long-term value.
| Domain | The naive objective | The task-aware one |
|---|---|---|
| Neural network quantization | Minimise weight error ‖W − Ŵ‖2 | Minimise the change in the layer's output on real activations — which is what GPTQ-style methods do, and why they beat round-to-nearest |
| Audio and image codecs | Minimise sample or pixel error | Spend bits where the ear and eye are sensitive; tolerate error where perception cannot detect it |
| Dimensionality reduction for retrieval | Maximise explained variance (PCA) | Preserve the neighbourhood structure the ranking depends on — which is why a retrieval-trained projection beats PCA at the same output dimension |
| Cardinality estimation in databases | Minimise error in the estimated row count | Minimise the probability of choosing a different plan — being wrong by 2× matters only when it flips a join order |
| Situation | Does anisotropic quantization help? | Why |
|---|---|---|
| Un-normalised MIPS, two-tower recommender | Yes, strongly | The exact setting it was derived for: norms carry signal and parallel errors distort them |
| Normalised cosine embeddings, high d | Yes, moderately | All norms are 1, so the parallel component is a pure radial error the loss still penalises correctly; the conditioning argument still applies |
| Euclidean L2 on geometric features (SIFT-like) | Less | The objective is a true metric and the ranking is not dominated by norm; plain PQ or OPQ is already well matched |
| Very low dimension (d < 32) | Little | The concentration argument that pins q near x's direction is weak, so the parallel and perpendicular weights converge |
| Long result lists, deep ranking tails | Less | The derivation conditions on a high score threshold; if you need item 5,000 ranked correctly, that conditioning is not valid |
There is no theorem you can cite instead of measuring, so the professional skill in this field is reading measurements correctly. This chapter is about the plots, the traps in them, and the single most important question nobody asks: should I be using an index at all?
ann-benchmarks (Aumüller, Bernhardsson & Faithfull) is the community's shared yardstick. Its protocol is deliberately austere: standard datasets with precomputed brute-force ground truth, each algorithm in its own container, single-threaded, one query at a time, and every algorithm swept across its full parameter grid.
| Dataset | N | d | Metric | Character |
|---|---|---|---|---|
| SIFT-128-euclidean | 1,000,000 | 128 | L2 | Classic image descriptors; well clustered, forgiving |
| GloVe-100-angular | 1,183,514 | 100 | cosine | Word vectors; harder, and the usual leaderboard battleground |
| GIST-960-euclidean | 1,000,000 | 960 | L2 | High dimension; where PQ and OPQ separate |
| Fashion-MNIST-784 | 60,000 | 784 | L2 | Small enough that brute force is competitive — a useful reality check |
| NYTimes-256-angular | 290,000 | 256 | cosine | Sparse-ish text features |
| DEEP1B (big-ann) | 109 | 96 | L2 | The billion-scale track, where DiskANN-class systems compete |
The canonical chart is recall on the x-axis, QPS on a logarithmic y-axis. Up and to the right is better. Every point on a curve is one parameter setting; the curve is generated by sweeping the runtime knob.
How to actually read two curves:
| What you see | What it means | What to do |
|---|---|---|
| Curve A is above curve B everywhere | A genuinely dominates on this dataset | Use A, subject to the invisible axes below |
| The curves cross | A wins at low recall, B at high (or vice versa) | Decide your recall target first, then read off the winner at that x |
| A curve stops before 0.95 | That configuration cannot reach higher recall at any knob setting — usually a quantization ceiling | Treat it as disqualified if you need high recall, or add reranking |
| A curve is nearly vertical at the right | The last few points of recall cost enormous throughput | This is normal and it is where you will live. Budget for it |
| Only one point is plotted | Someone is selling you something | Ask for the sweep |
ann-benchmarks makes three decisions that surprise people, and each one is defensible and consequential.
| Choice | Rationale | Who it penalises |
|---|---|---|
| Single-threaded, one query at a time | Removes the confound of threading quality and machine size, so the comparison is of algorithms rather than engineering teams | Anything with intra-query parallelism — GPU indexes, heavily SIMD scan kernels, batch-oriented designs. Their real-world advantage is invisible here |
| Full parameter sweep, plotted as a curve | An index is a family; a single point is not a measurement | Nobody — this is simply correct, and it is the norm this field should be proudest of |
| Containerised per algorithm | Each implementation gets its own dependencies and build flags, so nobody loses to a packaging problem | Nobody, but it means the numbers include each project's own compiler settings, which vary |
The first row is the one to hold onto. A single-threaded QPS number is a fine comparative measure and a poor absolute one. Your production question is queries per second per dollar under concurrency, and the ordering of algorithms on that axis can differ from the ordering on the published plot — particularly for anything whose advantage is parallelism.
The billion-scale sibling, big-ann-benchmarks, changes the rules deliberately: it fixes a hardware budget, allows batch queries, and separates tracks for in-memory, out-of-core, and custom-hardware systems. That is a different and equally valid framing — "what is the best system under this budget" rather than "what is the best algorithm per core" — and the winners differ accordingly. When someone quotes a leaderboard, ask which question it was answering.
Four quantities decide real deployments and appear on no standard chart.
Build time. HNSW on a million vectors is minutes; a billion-point Vamana graph is days. If your corpus is re-embedded every time you ship a new model, build time is the system's cost. It can differ by three orders of magnitude between two curves that look identical.
Memory. A curve that reaches 0.99 recall at 20,000 QPS while using 900 bytes per vector loses to one at 8,000 QPS and 24 bytes per vector, if the corpus is large enough that the first one needs a fleet.
Mutability. Insert cost, delete semantics, and how gracefully the index degrades between rebuilds. Benchmarks build once and query forever. Almost no production system does.
Tail latency. QPS is one over the mean. Graph searches have variable hop counts, so p99 can be several times p50 — and if you shard across S machines and merge, the query waits for the slowest of S, so the fan-out p99 approaches the p99 of the max, which is much worse than the p99 of one machine.
| Notation | Question it asks | Strictness |
|---|---|---|
| 10-recall@10 | How many of the true top 10 are in my 10 results? | Strict. The honest default |
| 1-recall@10 | Is the single true nearest neighbour anywhere in my 10? | Much easier — often 10–20 points higher on the same run |
| 1-recall@1 | Did I return exactly the right item? | Strict, and the standard for the billion-scale tracks |
| 10-recall@100 | Are the true top 10 inside my 100-item shortlist? | The right metric when a reranker follows — and the reason IVF-PQ looks far better with a rerank stage than without |
If a paper reports 1-recall@10 and you need 10-recall@10, the number does not transfer. If you are building a pipeline with a reranker, measure recall at the shortlist size, not at the final k — measuring the wrong one is the most common way teams conclude their index is bad when their metric is.
The most valuable arithmetic in this chapter, because it saves teams from building infrastructure they do not need. Exact search is memory-bandwidth-bound (Chapter 0), so the latency is one division:
Take a single modern core streaming at roughly 20 GB/s, and a whole server at 200 GB/s:
| N | d | Bytes scanned | 1 core @ 20 GB/s | Server @ 200 GB/s | Verdict |
|---|---|---|---|---|---|
| 10,000 | 768 | 30.7 MB | 1.5 ms | 0.15 ms | Index it and you have added a dependency for nothing |
| 100,000 | 768 | 307 MB | 15 ms | 1.5 ms | Brute force is fine for most products |
| 1,000,000 | 768 | 3.07 GB | 154 ms | 15 ms | The crossover. Depends on your budget |
| 1,000,000 | 128 | 512 MB | 26 ms | 2.6 ms | Still arguable at low d |
| 10,000,000 | 768 | 30.7 GB | 1.5 s | 154 ms | Build the index |
And exact search scales further than people expect on a GPU, where bandwidth is an order of magnitude higher. An accelerator with 1.5 TB/s of memory bandwidth scans a million 768-dimensional vectors in
— exactly, with no index, no build, no staleness, and trivially correct metadata filtering. FAISS's GPU flat index is a genuinely competitive production choice into the tens of millions.
Real queries almost never say "nearest neighbours." They say "nearest neighbours where tenant = 42 and updated_at > last month and language = 'de'." No standard benchmark measures this, and it breaks graph indexes badly.
Let s be the selectivity — the fraction of the corpus passing the filter. Three strategies:
| Strategy | How | Good when | Fails when |
|---|---|---|---|
| Pre-filter | Materialise the matching subset and brute-force it | s is small — a 0.1% subset of 10M is 10,000 vectors, which Worked Example 10 says is 1.5 ms | s is large; you are back to a full scan |
| Post-filter | Search for k′ > k, then drop non-matching results | s is large — almost everything passes | s is small; see the arithmetic below |
| Filter-aware index | Push the predicate into traversal (ACORN, filtered-DiskANN, per-partition indexes) | Filters are known in advance or low-cardinality | Arbitrary ad-hoc predicates |
The post-filter arithmetic. With selectivity s = 0.001 and a search returning 100 candidates, the expected number that survive the filter is
Nine times out of ten you return an empty page. To expect ten survivors you would need to retrieve 10,000 candidates — which for most indexes costs more than scanning the 0.1% subset exactly. Notice this is not a tuning problem; it is arithmetic, and it tells you the answer: at low selectivity, pre-filter and brute force.
There is a subtler failure for graph indexes even with generous post-filtering. Greedy routing follows edges, and if most nodes fail the predicate, the walk keeps landing on ineligible nodes. The graph's navigability was built for the whole corpus, and the filtered subgraph may not be connected at all — so recall over the filtered set can collapse far below what the unfiltered recall would suggest. Partitioning the index by the high-cardinality filter dimension (a separate index per tenant) is unglamorous and usually correct.
Latency and QPS numbers are easy to produce and easy to produce wrongly. Five mistakes account for nearly all of the bad numbers in circulation.
| Mistake | What it inflates | The fix |
|---|---|---|
| Measuring a cold index | Latency, badly — every hop is a page fault | Run a few thousand warm-up queries and discard them |
| Querying with vectors from the index | Recall, enormously — the answer is at distance zero and every method finds it | Hold out queries that were never added |
| Reusing one query | Everything — the whole working set is in cache after the first call | Cycle through thousands of distinct queries |
| Reporting 1/mean as QPS under concurrency | Throughput, by ignoring contention | Measure completed queries per second at your real thread count |
| Comparing across machines | Everything, silently | One machine, one process configuration, all candidates |
The second row deserves emphasis, because it is the most common serious error and it produces beautiful numbers. If your query set is a random sample of the indexed vectors, the nearest neighbour of each query is that query, sitting at distance exactly zero, and any index that touches it at all will find it. Recall comes out near 1.000 for every configuration, the curves look flat and wonderful, and the whole experiment measured nothing.
Recall against brute-force ground truth answers "does my index agree with exact search?" That is the right question for tuning an index and the wrong question for deciding whether the product got better, because exact search is not the ground truth of usefulness — it is the ground truth of your embedding model.
| Metric | Measures | Use it when |
|---|---|---|
| recall@k vs brute force | Index fidelity to exact search | Tuning the index. Fast, cheap, fully automatic |
| nDCG / MRR vs human labels | End-to-end retrieval quality | Comparing embedding models, or deciding whether a recall drop actually hurt |
| Answer accuracy of the full pipeline | What the user experiences | The only metric that settles a product argument. Slow and expensive |
| Click-through / dwell in production | Reality | The final word, and the slowest loop |
If your production queries carry predicates, then unfiltered ground truth measures a system you do not run. The fix is mechanical and takes an afternoon:
| Step | What to do |
|---|---|
| 1 | Sample real predicates from production logs, not invented ones. Record the selectivity distribution — it is usually bimodal, with a mass of very selective tenant filters and a mass of near-pass-through ones |
| 2 | For each (query, predicate) pair, brute-force the top k within the matching subset. This is cheap precisely because the subsets are small |
| 3 | Measure recall of your production path against that, bucketed by selectivity |
| 4 | Plot recall against selectivity. The shape tells you the architecture |
That last plot is the useful artefact, and it almost always looks the same: recall is fine for selectivity above a few percent, and falls off a cliff below it. The cliff's location is where you should switch from post-filtering to pre-filtering, and it is a number you can measure rather than argue about. Wiring the switch is then a one-line policy: if the estimated matching-set size is under some threshold, scan it exactly; otherwise search and post-filter.
Two families plotted the way ann-benchmarks plots them: recall across, QPS up a log axis. Move the knobs and watch the operating points slide along their curves. The dashed horizontal line is exact brute force at the current corpus size — perfect recall at a fixed QPS. Grow N and watch the brute-force line sink through the curves: the crossing point is exactly where an index starts to be worth building. The curves are shape models calibrated to published million-scale results, not measurements.
Press "Target recall 0.99" and compare the QPS the two families give up to get there. Then drag N down to 104 and watch the brute-force line rise above both curves entirely — at which point the correct index is a for-loop.
SIFT and GloVe are a decade old and were not chosen because they resemble your data. They were chosen because they are public, million-scale, and everyone already had them. Four differences matter enough to change your conclusions.
| Property | SIFT / GloVe | A 2020s text encoder | Consequence |
|---|---|---|---|
| Dimension | 96–128 | 768–3072 | Everything memory-bound gets 6–24× worse; PQ's d* choice changes entirely |
| Clustering | Strongly clustered by construction | Often much flatter after normalisation, especially post-contrastive training | IVF's cells are less selective; recall at a given nprobe drops |
| Intrinsic dimension | Low — a manifold inside the ambient space | Higher, and rising with model quality | Every method's constants get worse; the honest fix is a shorter vector, not a cleverer index |
| Stability | Frozen forever | Replaced whenever the model is upgraded | Build time and rebuild cadence become first-class, and no benchmark measures them |
| Duplicates | Deduplicated | Full of near-identical boilerplate | Huge, imbalanced IVF cells; graph neighbourhoods full of clones; recall metrics polluted by ties |
The last row is the one that ambushes people. Real corpora contain thousands of near-identical chunks — licence headers, navigation text, templated product copy. They collapse into a single dense knot that dominates one IVF cell, fills every graph neighbourhood in that region with clones, and makes the ground-truth top-10 a set of ten essentially identical items whose ordering is numerical noise. Recall computed against that ground truth measures nothing useful. Deduplicate before you benchmark, or your numbers will describe your boilerplate.
| Plot | Axes | The question it settles |
|---|---|---|
| 1. The frontier | recall (x) vs QPS, log (y) | Which family, and at what knob setting, for my recall target |
| 2. The memory frontier | recall (x) vs bytes per vector (y) | What my recall target costs in machines — the plot public benchmarks never draw |
| 3. The latency histogram | latency (x) vs count (y), per configuration | Whether my p99 is acceptable, which the mean cannot tell me |
Plot 2 is the one that changes decisions in practice, because it converts recall into money. Draw it once with your real corpus size and the argument about whether to use PQ ends immediately in either direction.
Suppose you have swept four candidates on your own data and produced one plot. Here is how the reasoning actually goes, in the order it should go.
| Candidate | Curve shape | What you conclude, and when |
|---|---|---|
| A: HNSW M=32 | Highest QPS everywhere, reaches 0.99, stops there | The frontier. But look up its bytes/vector before celebrating — on a 100M corpus this row may be unaffordable |
| B: IVF-PQ 32 B | Rises fast, flattens hard at 0.86, never moves again | A quantization ceiling. Not a competitor at any recall target above 0.86, and no amount of nprobe changes that |
| C: IVF-PQ 32 B + rerank 500 | Same left half as B, then keeps climbing to 0.98 with a visible cost step | The ceiling is gone. The step is the rerank fetch. This is B's real curve, and comparing B to A was never the comparison |
| D: IVF-Flat | Crosses A: better below 0.9, worse above | The crossing is the whole message. If your target is 0.85, D wins; if 0.97, A wins. Pick the target first or you will argue in circles |
Three habits follow. Fix the recall target before looking at the plot, so the crossing points resolve themselves. Never compare a compressed index without its rerank stage to an uncompressed one — you are comparing a component to a system. And always annotate each curve with bytes per vector, because the y-axis you can see is not the axis that will decide.
The most common real-world task is not choosing an index; it is explaining why the one you have got worse. A playbook, in the order that finds the cause fastest:
| # | Check | What a "yes" means |
|---|---|---|
| 1 | Did the ground truth get recomputed against the current corpus? | Stale ground truth is the single most common cause of a phantom regression. Recompute first, before investigating anything |
| 2 | Did the embedding model or its preprocessing change? | Everything downstream is invalid: centroids, codebooks, graph. Rebuild, do not tune |
| 3 | Is normalisation still applied on both sides? | A query normalised and a corpus not (or vice versa) silently converts cosine into something meaningless |
| 4 | Has the knob changed — nprobe, ef — perhaps via a config default? | Someone tuned for latency and paid in recall. Check the deploy diff, not the algorithm |
| 5 | Has the corpus grown a lot since the last training? | Cells and codebooks have drifted. Retrain the coarse quantizer |
| 6 | Has the corpus gained many near-duplicates? | One cell is now enormous; recall and p99 move together. Deduplicate |
| 7 | Are queries now hitting a filter path that was not there before? | Post-filtering with new selectivity. Chapter 7's arithmetic, arriving in production |
| 8 | Is the index warm, and is it the same index on every replica? | A cold or stale replica produces bimodal metrics that look like an algorithm problem |
Ground truth is computed for some fixed k — usually 100, because that covers most downstream uses. Two things go wrong with that constant.
You cannot measure recall above the k you computed. If ground truth holds the true top 100 and you want 10-recall@500, the metric is still well defined (are the true top 10 inside your 500?) but 100-recall@500 is not, because you do not know items 101–500 of the truth. Compute ground truth at the largest k any consumer will ever need, once, and slice down as required.
Ties at the boundary make recall jittery. If items 100 and 101 sit at nearly identical distances — overwhelmingly likely with near-duplicate content — then which one is "the truth" is decided by floating-point noise, and recall@100 fluctuates by a point between runs for no reason. The clean fix is to record the ground-truth distances alongside the ids and count a returned item as correct if its distance is within a tiny epsilon of the k-th true distance. It costs nothing and removes an entire category of phantom regressions.
| # | Step | Why it is on the list |
|---|---|---|
| 1 | Use your vectors and your queries | SIFT and GloVe are well-behaved in ways your embeddings may not be. Distribution shape decides everything |
| 2 | Compute ground truth by brute force, with the deployed metric | 10,000 held-out queries is plenty and takes minutes |
| 3 | Sweep the runtime knob over at least six settings | You are measuring a curve; six points is the minimum that shows a shape |
| 4 | Report recall at the shortlist size if a reranker follows | Otherwise you will condemn a perfectly good index |
| 5 | Record build time and peak build memory | They decide your rebuild cadence and your machine size |
| 6 | Measure p50 and p99 under the concurrency you will actually serve | Single-thread QPS does not predict a loaded server |
| 7 | Include brute force as a baseline row | Roughly one time in three it wins, and you want to find that out now |
| 8 | Test with your real filters applied | The most common production surprise, and invisible in every public benchmark |
Nobody ships "an index." They ship a pipeline in which each stage narrows the candidate set and the next stage scores what survives more expensively than the last. Every technique in this lesson is a stage in that pipeline, and the composition is the actual engineering artefact.
Read the funnel widths. Each stage costs roughly the same total time as the others, because each one is (many more items) × (much cheaper per item). That balance is not an accident either — it is what you get when you tune a pipeline properly, and a stage that costs 10× the others is a stage that is misconfigured.
Abstract funnels are unconvincing. Here is the same five-stage pipeline on 100 million vectors at d = 768, with the width and the cost of each stage.
| Stage | Items in | Items out | Cost per item | Stage cost |
|---|---|---|---|---|
| Coarse quantizer (HNSW over 65,536 centroids) | 65,536 | 16 cells | ~40 graph hops total | ~0.1 ms |
| PQ scan of the probed lists | ~24,400 | 500 | 64 lookups + adds, 64 B streamed | ~1.5 ms |
| Exact rerank on full vectors | 500 | 50 | 768 multiply–adds + a 3 KB fetch | ~2.0 ms |
| Cross-encoder rerank | 50 | 10 | A transformer forward pass over query + document | ~25 ms |
| Whole pipeline | 108 | 10 | ~29 ms |
Read the "items in" column top to bottom: 65,536 → 24,400 → 500 → 50. Each stage is roughly two orders of magnitude narrower than the one before and roughly two orders of magnitude more expensive per item. That is not a coincidence — it is what a tuned funnel looks like, and a stage that violates it is misconfigured.
| Symptom in the funnel | Diagnosis | Fix |
|---|---|---|
| One stage costs 10× the others | Its width is wrong for its per-item cost | Narrow its input, or make it cheaper |
| Rerank changes almost nothing about the final order | The previous stage was already accurate enough | Shrink the shortlist, or drop a stage |
| Rerank changes the order completely | The previous stage's ranking is noise | Widen the shortlist — the winner may not even be reaching the reranker |
| The first stage dominates | Classic coarse-quantizer growth from Chapter 2 | Put a graph index over the centroids |
FAISS encodes an entire pipeline in one string. Being able to decode one on sight is the most compact demonstration that you understand this lesson. Take:
| Token | Meaning | Chapter |
|---|---|---|
OPQ64_256 | Learn an orthogonal rotation, and also project to 256 dimensions, arranged for 64 subquantizers — balancing variance across the chunks that PQ will use | 3 |
IVF65536 | Coarse partition into 65,536 Voronoi cells. Suits N around 108 by the √(nprobe · N) rule | 2 |
_HNSW32 | The coarse quantizer is itself an HNSW graph with M = 32, so finding the nearest of 65,536 centroids is a graph walk, not a 65,536-way scan | 4 inside 2 |
PQ64 | 64 subquantizers over the 256 rotated dimensions — so d* = 4 dimensions per chunk | 3 |
x4 | 4 bits per sub-code (k* = 16), giving 64 × 4 = 256 bits = 32 bytes per vector | 3 |
fs | "Fast scan" — codes packed so the lookup tables fit in SIMD registers and are applied with shuffle instructions | 3 |
r | Refine: keep full or higher-precision vectors and rerank the shortlist exactly | 3, 8 |
That one line is: rotate, partition with a graph-accelerated coarse quantizer, scan 32-byte codes with SIMD, rerank exactly. Four of this lesson's chapters, composed, in 27 characters.
Decoding index strings is the fastest way to check whether the composition really landed. Cover the right column and work through these.
| String | What it builds, and when you would want it |
|---|---|
Flat | Exact brute force. No training, no parameters, perfect recall. The baseline every other row must beat, and the right answer below about 105 vectors |
HNSW32 | A graph with M = 32, full-precision vectors resident. One query knob, efSearch. Right for 105–107 when RAM is available and the corpus is fairly static |
IVF16384,Flat | Partition into 16,384 cells, store full vectors inside. Suits N ≈ 3 × 107 at nprobe = 8. Cheap to build, easy to update, no recall ceiling — and it saves no memory at all, which is the point of the row |
OPQ32_128,IVF4096,PQ32 | Rotate and reduce to 128 dimensions arranged for 32 subquantizers, partition into 4,096 cells, store 32-byte codes. A million-vector index in 40 MB. Add a refine stage before judging its recall |
PCAR64,SQ8 | PCA to 64 dimensions with rotation, then scalar quantization to 8 bits per dimension — 64 bytes, no codebook, and a flat scan. The unglamorous option that is often good enough and takes ten seconds to build |
The fifth row is worth dwelling on. Scalar quantization — one byte per dimension, learned only as a min and max per dimension — gets you 4× compression with almost no accuracy loss and none of PQ's machinery. It is not glamorous and it is frequently the correct first compression step. Reach for PQ when 4× is not enough.
100 million vectors from a modern text encoder, d = 768, cosine, target recall@10 ≥ 0.95, 500 QPS.
Option A — pure HNSW in RAM.
Latency around 1–2 ms, recall easily above 0.95, one machine, no reranking needed. At roughly $4/hour for a memory-optimised 384 GB instance that is about $35,000 per year, plus a replica for availability.
Option B — OPQ + IVF-PQ + rerank. Truncate to 256 dimensions with a Matryoshka-trained encoder, then PQ to 64 bytes:
Plus full-precision vectors on SSD or object storage for the rerank stage — 307 GB of cold bytes, of which a query reads perhaps 500 × 3 KB = 1.5 MB. Latency around 3–6 ms. At roughly $0.25/hour that is about $2,200 per year, plus cheap storage.
Option C — brute force on a GPU. 307 GB does not fit in accelerator memory, so this needs sharding across several devices or streaming from host memory. Sharding across 4 accelerators with 80 GB each: exact search, zero recall loss, per-query scan of 307 GB across 4 devices at 1.5 TB/s each
Far too slow for 500 QPS, and vastly more expensive. GPU brute force is a great answer at 106–107; at 108 with d = 768 it has run out of road.
| A: HNSW | B: OPQ+IVFPQ+rerank | C: GPU flat | |
|---|---|---|---|
| Resident memory | 333 GB | 7 GB | 307 GB across devices |
| Latency | 1–2 ms | 3–6 ms | ~51 ms |
| Recall@10 | 0.97+ | 0.95 with rerank | 1.000 |
| Annual compute | ~$35,000 | ~$2,200 | > $100,000 |
| Build | Hours | Under an hour | None |
| Deletes | Painful | Easy | Trivial |
A 16× cost difference between A and B for two points of recall and four milliseconds. Which is correct depends entirely on what those two points are worth — Chapter 1's compounding argument is how you find out. The point of this table is that "which index?" is a budget question with a numeric answer, not a matter of taste.
The last conversion that makes architecture arguments settle: put every stage in dollars per million queries. Using the 100M-vector Option B configuration and round cloud prices.
| Component | Throughput per unit | Unit cost | $ per million queries |
|---|---|---|---|
| ANN search (16 GB instance) | 500 QPS | $0.25/hr | $0.14 |
| Rerank fetch (NVMe, 500 reads/query) | bundled | bundled | ~$0.05 |
| Query embedding (small encoder, accelerator) | 2,000 QPS | $1.00/hr | $0.14 |
| Cross-encoder rerank, top 50 | 40 QPS | $1.00/hr | $6.94 |
| Generation, if this feeds a language model | — | — | $100–$2,000 |
The corollary for capacity planning: the memory-optimised choice matters most when the corpus is large enough that the first row is not fourteen cents. Redo the table at your own N — if the search row is dominated by everything else, take the simplest index that meets your recall target and spend the attention elsewhere.
One more branch worth knowing. NVIDIA's CAGRA (in the cuVS library) is a graph index designed for GPUs rather than ported to them. The key difference is in the search: a CPU beam search expands one node at a time with a narrow frontier, which suits a few fast cores; CAGRA instead evaluates a large batch of candidates per iteration so that hundreds of threads have work, trading a less efficient search order for massive parallelism.
The reported results are large speedups in build time over CPU HNSW — graph construction is embarrassingly parallel and had been the painful part — and substantial throughput gains at high recall for batched queries. There is also a practical bridge: a CAGRA graph can be converted into an HNSW-compatible structure, so you can build on a GPU in minutes and serve on CPUs. If your bottleneck is rebuild cadence rather than query latency, that conversion is the interesting part.
Its limitation is the obvious one: accelerator memory is small and expensive, so CAGRA lives happily up to tens of millions of vectors, and beyond that you are back to partitioning and compression.
Everything so far described the read path. The write path decides your rebuild cadence, your staleness, and most of your operational pain, and it has a shape of its own.
The staleness budget follows directly. If step 2 embeds 2,000 chunks per second and a document produces 20 chunks, you ingest 100 documents per second. A corpus of 100 million documents therefore takes 106 seconds — 11.6 days — for a full re-embed on one worker. That single number decides whether a model upgrade is an afternoon or a quarter, and it is almost never in the capacity plan.
Target: 100M documents, 20 chunks each = 2 × 109 vectors at d = 768. 2,000 QPS, p99 under 150 ms end to end, full corpus refresh monthly.
| Decision | Choice | Arithmetic |
|---|---|---|
| Dimension | Truncate 768 → 256 (Matryoshka) | 3× on every downstream number, for ~1–2 points of nDCG |
| Index family | OPQ + IVF-PQ + rerank | 2 × 109 is far past HNSW's single-machine ceiling |
| Code size | m = 64 bytes | 2 × 109 × 64 B = 128 GB of codes |
| Ids | int32 positional | 2 × 109 × 4 B = 8 GB. int64 would have cost 16 GB |
| Shards | 4, sharded by tenant | 136 GB / 4 = 34 GB per shard — fits a 64 GB machine with headroom |
| nlist per shard | 65,536 | √(nprobe · N) with N = 5 × 108, nprobe = 8 → 63,246 |
| Coarse quantizer | HNSW32 over the centroids | 65,536 × 256 = 16.8M multiply–adds becomes a ~40-node walk |
| Rerank source | Full 768-d vectors on NVMe | 2 × 109 × 3,072 B = 6.1 TB across 4 nodes = 1.5 TB each |
| Replicas | 2 per shard | 8 machines total, for availability and for hedging the tail |
| Query fan-out | 1 shard per query | Because we sharded by tenant — the p99 is one machine's p99, not the max of four |
| Embedding fleet | Sized for the monthly refresh, not the write rate | 2 × 109 chunks / month = 772 chunks/s sustained. This is the largest line item |
| Situation | Index | Why |
|---|---|---|
| N < 105, any d | Flat / brute force | Under a few ms exact. Perfect recall, no build, free deletes and filters |
| 105–107, RAM is fine, mostly static | HNSW | Best recall/latency frontier when everything fits. One knob |
| 105–107, heavy churn | IVF-Flat or flat with a write buffer | Cheap inserts and real deletes; graphs hate mutation |
| 107–109, memory constrained | OPQ + IVF-PQ + rerank | 16–64 B/vector puts a billion points on one small machine |
| ≥ 109, one node, SSD available | DiskANN | ~36 GB RAM for a billion points; latency in single-digit ms |
| MIPS, un-normalised, throughput critical | ScaNN | Anisotropic loss targets the score, not the coordinates |
| Batched queries, GPU present, rebuilds frequent | CAGRA | Parallel build and batch search; convert to HNSW to serve on CPU |
| Highly selective metadata filters | Partition by the filter, then any of the above | An index per tenant beats a filter-aware traversal almost every time |
| You do not know yet | Flat, and measure | The baseline you must beat, and it is free to build |
Vector search is never the whole request, and looking at where the time actually goes reorders most optimisation priorities. A representative retrieval-augmented request, 100 million documents, OPQ+IVF-PQ with rerank:
| Stage | p50 | p99 | Notes |
|---|---|---|---|
| Request parse, auth, routing | 1 ms | 4 ms | Fixed overhead nobody profiles |
| Embed the query | 8 ms | 25 ms | A transformer forward pass. Usually the single largest term |
| Coarse quantizer (HNSW over centroids) | 0.1 ms | 0.3 ms | Negligible, and it is what Chapter 2 worried about |
| PQ scan of nprobe lists | 1.5 ms | 6 ms | The part everybody tunes |
| Fetch 500 full vectors + rerank | 2 ms | 9 ms | Dominated by fetch, not arithmetic |
| Fetch document text | 3 ms | 20 ms | A different datastore, a different tail |
| Cross-encoder rerank, top 50 | 25 ms | 60 ms | If present, this dwarfs everything else |
| Total | ~41 ms | ~124 ms |
One more composition, because it is nearly universal in production and it is a different axis from everything so far. Dense vector search fails in a specific, predictable way: exact tokens. Product codes, error identifiers, rare proper nouns, version numbers — an embedding smooths precisely the surface detail that makes those queries answerable, so "error TS2571" retrieves documents about TypeScript errors in general and not the one page that names it.
Sparse lexical retrieval (BM25 over an inverted index — the original meaning of the term this chapter has been borrowing) fails in the complementary way: it cannot match paraphrase. The standard answer is to run both and fuse the rankings, most often with reciprocal rank fusion, which needs no score calibration between two systems whose scores are not comparable:
Worked micro-example. A document ranked 1st by BM25 and 30th by the dense index, against one ranked 8th by both, with K = 60:
Doc B wins. That is the intended behaviour: RRF rewards agreement across independent systems over a single spectacular result, which is exactly the right prior when one system is prone to lexical over-matching and the other to semantic drift. The additive form also makes the fusion trivially extensible — a third retriever joins by adding a term.
The systems consequence for this lesson: your vector index does not need to be the only recall path, which means its recall target can be lower than you feared. A dense index at 0.93 recall inside a hybrid system can produce better end-to-end results than one at 0.99 alone, at a fraction of the memory. Chapter 1's warning that the right recall target is a property of the pipeline, not the index, cashes out here.
A rough ladder. Each rung is where a real system typically stops working, and the fix is usually architectural rather than a parameter.
| Scale | What breaks | The move |
|---|---|---|
| 104 vectors | Nothing | A numpy array. Resist everything else |
| 105 | Still nothing, if you batch | Still a numpy array, or a flat index in your database |
| 106 | p99 latency on an interactive path | HNSW. One machine, one knob |
| 107 | Rebuild time, and RAM at high d | Truncate the dimension; consider IVF for cheaper builds |
| 108 | RAM, decisively | OPQ + IVF-PQ with a rerank tier |
| 109 | One machine cannot hold even the codes at high d | DiskANN, or shard — and shard by a filter dimension if you possibly can |
| Any scale, high write rate | Build time exceeds the refresh interval | Write buffer plus periodic compaction. The LSM pattern |
| Any scale, selective filters | Recall, silently, on the filtered path only | Partition by the filter; brute-force small subsets |
| Any scale, model upgrades | Embedding throughput, for weeks | Size the embedding fleet by the refresh cadence, not the write rate |
| Concern | What actually happens | What to do about it |
|---|---|---|
| Sharding | Every query fans out to every shard and waits for the slowest; p99 degrades with shard count | Shard by a filter dimension so a query touches one shard, not by hash |
| Replication | Straightforward — indexes are read-only artefacts | Build once, ship the file, hedge slow requests to a replica |
| Rebuild cadence | Centroids and codebooks drift as content changes; recall decays silently | Track recall against a fixed golden query set on every deploy |
| Model upgrades | A new embedding model invalidates every vector, every centroid, every code | Budget a full re-embed and rebuild as a routine event, not an incident |
| Warmup | A cold mmap'd index page-faults on nearly every hop | Touch the index before taking traffic; never serve from cold storage |
| Ids | int64 ids are half the memory of an 8-byte PQ index | Use int32 or positional ids; keep the mapping outside the index |
| Evaluation | Nobody notices recall regressions because nothing measures them | A golden set with brute-force ground truth, recomputed on every rebuild |
OPQ64_256,IVF65536_HNSW32,PQ64x4fsr: how many bytes per vector does the compressed payload occupy, and what is the HNSW graph doing?Four papers, fourteen years, one problem. Here is everything on one page, followed by where each thread continues.
| Paper | The one idea | What it costs | Still used? |
|---|---|---|---|
| PQ — Jégou, Douze & Schmid, TPAMI 2011 | The representable set is the Cartesian product of m small codebooks, so it multiplies while storage adds. 264 reconstructions from 128 KB | Cannot represent correlations across chunks; imposes a hard recall ceiling | Universally. Every large index has PQ or a descendant inside it |
| HNSW — Malkov & Yashunin, 2016 / TPAMI 2018 | Separate link scales onto layers, as a skip list does; greedy descend, then beam search at the bottom | Full vectors must be RAM-resident; slow build; deletes are hard | The default in-memory index across the whole ecosystem |
| DiskANN — Subramanya et al., NeurIPS 2019 | PQ codes in RAM for routing, full vectors and adjacency co-located on SSD for the answer; α-pruning for low diameter | Multi-day builds; harder to mutate; latency in ms not µs | Yes — the standard answer for a billion points on one node |
| ScaNN — Guo et al., ICML 2020 | Weight the quantization loss by error direction: parallel errors distort scores, perpendicular ones mostly do not | The derivation conditions on a high score threshold; weaker in low d or for deep tails | Yes, and the idea travels far beyond ANN |
| Ch | The one thing |
|---|---|
| 0 | Exact search over a billion 768-dimensional vectors needs 3.07 TB and 15.4 seconds per query, because it is memory-bandwidth-bound at an arithmetic intensity of 0.5 flops per byte. Classical exact structures do not help, because distance concentration makes their pruning bounds vacuous above about twenty dimensions. The escape is to stop reading most of the data, which means accepting that you will sometimes be wrong |
| 1 | Recall, latency, and memory form a triangle, and every index fixes one relationship. Quantization sets a ceiling on recall; search effort determines how close you get to it. Diagnosing which of the two is binding takes one sweep and is the most useful measurement in the field. An index is a curve, not a point |
| 2 | IVF partitions space with k-means and scans only the nprobe nearest cells. nlist = √(nprobe · N) falls out of a one-line optimisation. Recall is lost at cell boundaries, which is why nprobe matters and why soft assignment helps. Encoding residuals rather than raw vectors is what makes IVF-PQ work |
| 3 | Product quantization splits the vector into m chunks and quantizes each independently, so the representable set multiplies as (k*)m while storage adds as k* · d floats. 264 reconstructions from 128 KB. ADC keeps the query in full precision and scores by table lookup: m lookups, zero multiplications, 64× less memory traffic |
| 4 | HNSW puts long links and short links on separate layers, exactly as a skip list does, and runs greedy descent then a beam search. Every graph index is an approximation of the Delaunay graph under a degree budget; the neighbour-selection heuristic decides which edges to keep, and keeping the nearest M is the wrong answer because it disconnects clusters |
| 5 | On SSD the cost unit changes from distance computations to dependent round trips. DiskANN keeps PQ codes in RAM to route and full vectors plus adjacency co-located on disk to answer, and Vamana's α = 1.2 retains long edges to shrink the diameter. The headline throughput is the drive's IOPS divided by the hop count |
| 6 | Reconstruction error is the wrong objective for MIPS. Errors parallel to the database vector distort the scores of exactly the queries for which that vector is a contender; perpendicular errors of the same size are nearly harmless. Weight the loss accordingly. This generalises far beyond search |
| 7 | Compare curves, never points. Recall definitions differ by fifteen points on the same run. Build time, memory, mutability, and tail latency are invisible on the standard plot and decide real deployments. And N · d · 4 / bandwidth will often tell you not to build an index at all |
| 8 | Nobody ships an index; they ship a funnel in which each stage is two orders of magnitude narrower and two orders of magnitude more expensive per item than the last. Approximate for routing, exact for ranking, recursively. The index is rarely where the money or the latency goes |
| 9 | All four papers do the same thing: find the scarce resource, and restructure the problem so it is spent only where it changes the answer |
| Symbol | Meaning | Typical |
|---|---|---|
| N | Corpus size | 105–1010 |
| d | Vector dimension | 96–3072 |
| k | Results requested | 10–100 |
| r | Shortlist size before reranking | 10–50× k |
| nlist | IVF cells (build time) | √(nprobe · N) |
| nprobe | IVF cells scanned (query time) | 1–128 |
| m | PQ subquantizers | 8–64, must divide d |
| d* = d/m | Chunk dimension | 4–32 |
| k* | Centroids per sub-codebook | 256 (8-bit) or 16 (4-bit SIMD) |
| M | HNSW links per node per layer | 16–48; layer 0 gets 2M |
| ef | HNSW query beam width | k to 1000 |
| efConstruction | HNSW build beam width | 100–500 |
| mL | HNSW layer scale | 1/ln(M) |
| R | Vamana max out-degree | 64–128 |
| α | Vamana prune permissiveness | 1.2 |
| W | DiskANN beam width (I/Os per round) | 2–8 |
| h∥, h⊥ | ScaNN parallel / perpendicular loss weights | ratio 2–10, grows with d |
| s | Filter selectivity | the number that decides pre- vs post-filter |
| Number | What it is |
|---|---|
| 3.07 TB | A billion 768-dimensional float32 vectors |
| 15.4 s | One exact query over them, bandwidth-bound at 200 GB/s |
| 1/√d | Standard deviation of the cosine between random unit vectors — 0.036 at d = 768 |
| √(nprobe · N) | Optimal nlist |
| 39–256 | Training vectors per IVF centroid (FAISS's warning threshold and its recommendation) |
| 64× | PQ compression at d = 128, m = 8, k* = 256: 512 bytes → 8 |
| 128 KB | The whole PQ codebook at k* = 256, d = 128 — independent of m |
| 264 | Reconstructions from that 128 KB |
| 644 B/vector | HNSW at M = 16, d = 128: 512 payload + 132 graph |
| ~36 GB | DiskANN's RAM for a billion 128-dimensional points |
| ~100 µs | A random 4 KB NVMe read — the unit DiskANN economises |
| α = 1.2 | Vamana's pruning permissiveness: keep the direct edge unless the detour saves 20% |
| 0.903 = 0.729 | Why 90% recall is not 90% in a three-chunk RAG pipeline |
| N · d · 4 / bandwidth | The calculation that tells you not to build an index |
| Term | One line | Chapter |
|---|---|---|
| Bandwidth-bound | Limited by bytes delivered from memory per second, not by arithmetic. Adding cores does not help | 0 |
| Distance concentration | In high dimensions almost all pairwise distances are nearly equal; the spread of random cosines is 1/√d | 0 |
| MIPS | Maximum inner product search. Not a metric — no triangle inequality, and longer vectors win | 0 |
| Locality-sensitive hashing | Data-independent hashing where collision probability decreases with distance. Provable, and beaten badly on real data | 0 |
| recall@k | Fraction of the true top-k that the index actually returned, averaged over queries | 1 |
| Operating point | One (recall, QPS) pair. An index is a curve of these, traced by its runtime knob | 1 |
| Quantization ceiling | The recall no amount of search effort can exceed, set by how short the codes are | 1, 3 |
| Coarse quantizer | The small set of centroids whose Voronoi cells define an IVF partition | 2 |
| Inverted list / posting list | The vectors filed under one centroid. Named after the text-search structure it copies | 2 |
| Voronoi cell | The region of space closer to one centroid than to any other | 2 |
| nprobe | How many cells a query opens. The query-time knob of IVF | 2 |
| Residual | x minus its cell centroid. What IVF-PQ actually encodes, because it has far less variance | 2 |
| Codebook | The finite set of centroids a quantizer maps into | 3 |
| Product quantizer | m independent sub-quantizers over disjoint chunks; the representable set is their Cartesian product | 3 |
| ADC | Asymmetric distance computation — full-precision query against quantized database, via lookup tables | 3 |
| SDC | Symmetric variant that quantizes the query too. Strictly worse for ranking | 3 |
| OPQ | A learned orthogonal rotation applied before PQ, balancing variance across chunks. Free at query time | 3 |
| Fast scan / LUT16 | 4-bit codes whose lookup tables fit in SIMD registers, scored with shuffle instructions | 3, 6 |
| Skip list | Sorted list plus probabilistic express lanes; the structural ancestor of HNSW's hierarchy | 4 |
| Greedy routing | Repeatedly step to the neighbour closest to the query; stop when none improves | 4 |
| Navigable small world | A graph with short-range links for accuracy and long-range links at every scale for reachability | 4 |
| Delaunay graph | The graph on which greedy routing is provably correct, and whose degree explodes with dimension | 4 |
| ef / beam width | How many candidates stay alive during search. What buys escapes from local minima | 4 |
| Diversity heuristic | Accept a neighbour only if it is closer to me than to anything already accepted — preserves cross-cluster bridges | 4 |
| Vamana | DiskANN's flat graph construction, with the α parameter controlling how permissively edges survive pruning | 5 |
| Medoid | The point minimising total distance to all others; Vamana's fixed entry point | 5 |
| Dependent I/O | A read whose address is unknown until the previous read completes. The unit DiskANN economises | 5 |
| Anisotropic quantization | Weighting the quantization loss by error direction, punishing errors parallel to the data vector | 6 |
| Selectivity | Fraction of the corpus passing a metadata filter. Decides pre- versus post-filtering | 7 |
| Reciprocal rank fusion | Combining rankings by summing 1/(K + rank), needing no score calibration | 8 |
| Memorise | Look up |
|---|---|
| N · d · 4 / bandwidth — the do-I-need-an-index calculation | Any specific library's parameter names |
| Quantization sets a ceiling; effort climbs toward it | The exact recall a given m achieves on a given dataset |
| nlist ≈ √(nprobe · N) | Training-sample-size recommendations |
| PQ: (k*)m reconstructions from k* · d floats | OPQ's optimisation procedure |
| ADC is m lookups and no multiplications | SIMD packing layouts |
| ef and nprobe are query-time; M, m, nlist are build-time | Default values in any particular engine |
| On disk, count dependent round trips, not distance computations | A specific drive's IOPS rating |
| Errors parallel to x hurt MIPS; perpendicular ones mostly do not | The closed-form weights as a function of T and d |
| Compare curves, never points | Which dataset a published curve used |
| Recall compounds: 0.93 = 0.729 | Your own pipeline's compounding factor — measure it once |
The left column is a page you could write from memory in ten minutes and it decides nearly every architectural question in this space. The right column changes every release and should never be memorised.
| Failure | Which structure | Root cause | Chapter |
|---|---|---|---|
| True neighbour sits in an unprobed cell | IVF | The query is near a Voronoi boundary | 2 |
| Two distinct vectors share a code and cannot be ordered | PQ | Finite codebook; the ceiling | 3 |
| Distances estimated uniformly too high | PQ / ADC | The estimator's constant bias; harmless for ranking | 3 |
| Greedy walk stalls in a local minimum | Graph, ef = 1 | The graph is not Delaunay; some directions are missing | 4 |
| Whole clusters unreachable | Graph | Neighbours chosen by distance rather than diversity | 4 |
| Latency dominated by idle waiting | Disk graph | Dependent I/O with beam width 1 | 5 |
| Top-1 wrong far more often than mean error suggests | Quantized MIPS | Parallel residual inflating a contender's score | 6 |
| Empty result pages | Any, post-filtered | Low selectivity multiplied by a small candidate count | 7 |
| Recall decays silently over months | IVF / PQ | Centroids and codebooks trained on a distribution that moved | 2, 8 |
| p99 balloons after sharding | Any, fanned out | The request waits for the slowest of S shards | 1, 8 |
| Symptom | Most likely cause | First thing to try |
|---|---|---|
| Recall plateaus and more search effort does nothing | Quantization ceiling | Bigger m, or add exact reranking on a shortlist |
| Recall is fine but latency is high | Search effort too generous | Lower ef / nprobe until recall starts to move |
| Recall collapsed after a data refresh | Stale centroids or codebooks | Retrain the coarse quantizer; check the golden set |
| Recall is fine offline, bad in production | Filters | Measure with real predicates; consider pre-filtering |
| p99 is 10× p50 | Fan-out across shards, or cold pages | Warm the index; shard by filter, not by hash; hedge requests |
| Build takes longer than the refresh interval | Wrong index family for your churn | IVF instead of a graph, or a write buffer merged periodically |
| Memory is the binding constraint | Storing float vectors you never need at full precision | PQ the payload, keep floats on SSD for rerank only |
| Everything works but it feels over-engineered | It probably is | Compute N · d · 4 / bandwidth and consider deleting the index |
| Belief | What is actually true |
|---|---|
| "You need a vector database." | Below about 105–106 vectors a numpy matrix multiply is faster, exact, and free of every operational problem an index introduces. Compute N · d · 4 / bandwidth first |
| "HNSW is the best index." | HNSW has the best recall/latency frontier when the data fits in RAM. That condition is doing all the work, and it fails above roughly 107 modern embeddings per machine |
| "Approximate means a small accuracy loss." | It means a tunable one. The same index spans 0.6 to 0.999 recall by moving one runtime knob, at a 50× latency spread |
| "PQ has bad recall." | PQ has a recall ceiling that reranking removes. A measurement of IVF-PQ without its rerank stage is a measurement of half a system |
| "More nprobe / ef always helps." | Only until you hit the quantization ceiling, after which it buys latency and nothing else. Diagnosing which regime you are in takes one sweep |
| "Recall 0.95 means 95% correct answers." | It means 5% of what should have been considered was never considered. In a pipeline needing three specific items, 0.95 recall is 0.857 end to end |
| "Sharding improves latency." | Sharding improves capacity. Fanning out to S shards makes the p99 the maximum of S latencies, which is strictly worse than one |
| "Benchmarks transfer." | The ranking of families roughly transfers. The numbers and the crossover points do not, because your dimension, clustering, and duplicate rate are not SIFT's |
| "The index is the hard part." | Embedding throughput, rebuild cadence, filters, and deletes are the hard parts. The index is the well-studied piece with four excellent papers behind it |
| If you want… | Go to |
|---|---|
| The systems layer around these indexes — storage, filtering, hybrid search | Vector databases |
| Where the vectors come from in the first place | Vector embeddings and embedding layers |
| The metric choices this lesson assumed | Similarity metrics |
| The k-means that trains every coarse quantizer and codebook | k-means |
| Truncatable embeddings — the cheapest memory win before any index | Matryoshka representation learning |
| Running all of this on a phone, where the memory budget is the whole story | On-device embeddings |
| The pipeline these indexes feed | RAG and multimodal RAG |
| Retrieval models whose scores you are indexing | Dense passage retrieval, ColBERT, E5 |
| Learning the quantizer and the retriever together | Joint search and quantization |
| How to know your retrieval actually improved | Embedding benchmarks |
| The inverted index this all borrows its name from | Storage and retrieval and hash tables |
| Step | Do this | The decision that matters |
|---|---|---|
| 1. Baseline | Write the numpy one-liner and time it on your real corpus | If it is fast enough, stop here. Roughly a third of the time it is |
| 2. Ground truth | Brute-force the true top-100 for 10,000 held-out queries | Use the metric you will actually deploy, on normalised vectors if that is what you serve |
| 3. First index | faiss.index_factory(d, "HNSW32") | No training, one knob. Sweep ef over 10, 20, 40, 80, 160, 320 and plot |
| 4. Compress | "OPQ32_128,IVF4096,PQ32" and compare curves | Watch the ceiling appear; note the memory you just saved |
| 5. Rerank | Take the top 500 by code, rescore with float vectors | This is where the ceiling disappears. Measure recall at 500, not at 10 |
| 6. Hand-encode | Implement PQ encode and the ADC table in twenty lines of numpy | Reproduce Worked Example 6's 0.06 + 0.07 = 0.13 exactly. Nothing else teaches it |
| 7. Filters | Re-run every measurement with your real predicates applied | Compute the selectivity first; it usually decides the architecture |
| 8. Harness | Wire steps 2–3 into CI | Recall regressions are silent. This is the only thing that makes them loud |
All four are short. If you read them in this order, each one answers a question the previous one raised.
| Order | Paper | Read it for | Skim |
|---|---|---|---|
| 1 | Jégou et al., PQ (2011) | Sections 2–3: the quantizer definition, the product construction, and the ADC/SDC comparison. This is the densest value per page in the field | The IVFADC experiments, unless you are reproducing them |
| 2 | Malkov & Yashunin, HNSW (2016) | Algorithms 1–5, especially Algorithm 4 (the diversity heuristic) which the abstract does not mention and which is where the quality lives | The complexity analysis on first pass; come back to it |
| 3 | Subramanya et al., DiskANN (2019) | The RobustPrune definition and the disk-layout section. Read them together — neither makes sense alone | The comparison tables, which have aged |
| 4 | Guo et al., ScaNN (2020) | Sections 3–4: the loss decomposition and the closed-form weights. The idea is portable far outside search | The SIMD implementation details, unless you are writing one |
Two supporting reads that repay the time: Johnson, Douze & Jégou on FAISS, which is the engineering context for all four, and the ann-benchmarks paper, which is the methodology you will be judged by.
| Week | Do | You will learn |
|---|---|---|
| 1 | Brute force, ground truth for 10,000 held-out queries, and the sweep harness in CI | Whether you need an index at all, and what your data actually looks like — duplicates, dimension, clustering |
| 2 | HNSW and OPQ+IVF-PQ+rerank, swept, plotted on recall/QPS and recall/bytes | Which family, at what cost, at your recall target. Usually the decision is obvious once both plots exist |
| 3 | Re-run everything with real filters, real concurrency, and a simulated model upgrade | The three things that will actually break in production, found before they do |
If week 1 ends with "brute force is fine," that is a successful outcome and you should write it down so the question does not get reopened every quarter. Include the corpus size at which you expect it to stop being fine, and the arithmetic behind it.
Without scrolling up: (1) compute the bandwidth-bound latency of an exact scan over 108 vectors at d = 1536 and say whether you need an index; (2) derive nlist = √(nprobe · N) from the IVF cost model; (3) explain why a product quantizer can represent 264 points with a 128 KB codebook, and what it gives up; (4) state the ADC bias identity and say why the bias does not hurt ranking but its variance does; (5) explain what HNSW's neighbour-selection heuristic prevents, and how Vamana's α changes the same rule and why that matters on SSD; (6) explain, with the (3,4) example, why two reconstructions of equal error are not equally good for MIPS. If any of the six stalls, its chapter is one tap away.