Jégou, Douze & Schmid 2011 · Malkov & Yashunin 2016 · Subramanya et al. 2019 · Guo et al. 2020

ANN Indexes: How to Not Look at a Billion Vectors

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.

Prerequisites: what a dot product is and what k-means does. Quantization, graph search, Voronoi cells, and every piece of arithmetic are built from zero.
10
Chapters
5
Interactive Sims
4
Landmark Papers
64×
Typical PQ Compression

Chapter 0: The Impossible Query

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.

Number one: it does not fit

A float32 is 4 bytes. One vector is 768 × 4 = 3,072 bytes, call it 3 KB. A billion of them:

1 × 109 vectors × 3,072 bytes = 3.072 × 1012 bytes = 3.07 TB

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.

Memory is the first wall, and it arrives before speed. Most people meet approximate search because they wanted it to be fast. They are usually wrong about which constraint bit first. Compression — Chapter 3 — is not a performance optimisation bolted on at the end. For large corpora it is what makes the system exist at all.

Number two: the floating-point cost

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:

109 × 1,536 = 1.536 × 1012 flops = 1.54 teraflops per query

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:

1.536 × 1012 ÷ 5 × 1010 = 30.7 seconds on one core

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.

Number three: the constraint that actually binds

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:

t = 3.072 × 1012 bytes ÷ 2 × 1011 bytes/s = 15.4 seconds per query

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.

Convert it into money, because that is the argument you will actually have to make. One machine answers 1 ÷ 15.4 = 0.065 queries per second. To serve a modest 1,000 QPS you need 1,000 ÷ 0.065 ≈ 15,400 machines. At a very optimistic $3 per machine-hour that is $46,000 per hour, $404 million per year, to run a search box. The gap between what you have and what you need is not 20%. It is more than four orders of magnitude.

Why bandwidth is the right model, in one ratio

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.

intensity = flops / bytes loaded,   machine balance = peak flops / peak bandwidth

For a single-query exact scan: each float32 loaded is used for exactly one multiply and one add, so 2 flops per 4 bytes:

intensity = 2 / 4 = 0.5 flops per byte

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.

WorkloadIntensity (flops/byte)Verdict on a 15 flops/byte machine
Single-query exact scan0.5Hopelessly memory-bound
Batch of 32 queries (a GEMM)~16Right at balance — now compute-bound, and 30× more efficient per query
PQ scan, 8-byte codes~0.9 with 64× fewer bytesStill memory-bound, but the memory is 64× smaller and fits in cache
Graph hop, M = 32 neighbours0.5, on scattered addressesLatency-bound, not bandwidth-bound — a different problem again
The row that reframes compression. PQ does not raise arithmetic intensity much — it is still a stream-and-add. What it does is shrink the stream by 64×, until the working set fits in cache and the bandwidth in question is L1's rather than DRAM's, which is roughly two orders of magnitude faster. Compression is a cache-residency technique that happens to also save RAM. Hold that framing through Chapter 3 and the design choices there stop looking arbitrary.

The latency budget makes it worse

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.

ApproachLatency for one queryMachines to hold the dataGap to a 10 ms budget
Single machine, full scan15.4 sImpossible — 3 TB1,540×
Sharded over 10 machines1.54 s10154×
Sharded over 100 machines154 ms10015×
Sharded over 1,540 machines10 ms1,5401× — 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.

The reframe. Approximate nearest-neighbour search is not "nearest-neighbour search, but sloppy." It is a different problem with a different contract: given a budget of vectors I am allowed to touch, maximise the probability that the true top-k are among the ones I touched. Every index in this lesson is a data structure whose only job is to make that probability high for a small budget. PQ shrinks what a "touch" costs. IVF, HNSW, DiskANN, and ScaNN each decide which vectors are worth touching.

Why the classical exact structures do not save you

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.

Distance concentration, with the actual numbers

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:

E[cos(u, v)] = 0,   sd[cos(u, v)] ≈ 1 / √d

Put in real dimensions and read the table. The spread is the width of the band that all random pairs fall into.

d1 / √d99.7% of random pairs land inWhat that means
20.707[−1.00, 1.00]Distances are wildly spread out; pruning is easy
160.250[−0.75, 0.75]Still plenty of structure to exploit
1280.0884[−0.265, 0.265]Everything is nearly orthogonal to everything
7680.0361[−0.108, 0.108]All one billion points are, to a first approximation, the same distance away
30720.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%.

The same fact cuts both ways, and this is the hopeful half. If almost all points are nearly equidistant from the query, then almost all points are uninteresting and the interesting ones are a tiny, sharply separated minority. You do not need a structure that can prove things about the boring 99.999%. You need a structure that reliably walks toward the interesting 0.001%. Proof is expensive in high dimensions; navigation is cheap. Every index in this lesson abandons proof and buys navigation.

Five problems that all get called "vector search"

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.

ProblemStatementWhat changes
k-nearest neighboursReturn the k closest points to qThe default. Everything in this lesson targets it
Range queryReturn every point within radius ε of qThe 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 productReturn argmax ⟨q, x⟩ over unnormalised xNot a metric. Long vectors win regardless of direction, so structures that assume the triangle inequality misbehave. Chapter 6
Filtered k-NNk closest among points satisfying a predicateChanges the architecture more than the index choice does. Chapter 7
Diverse k-NNk close points that are also unlike each otherCannot 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.

What modern embedding dimensions did to the arithmetic

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.

dBytes/vector1M corpus1B corpusExact scan of 1M, 200 GB/s
96 (DEEP1B)384384 MB384 GB1.9 ms
128 (SIFT)512512 MB512 GB2.6 ms
384 (small text encoders)1,5361.5 GB1.5 TB7.7 ms
768 (base text encoders)3,0723.1 GB3.1 TB15.4 ms
15366,1446.1 GB6.1 TB30.7 ms
3072 (large encoders)12,28812.3 GB12.3 TB61.4 ms
The cheapest optimisation in this entire lesson is choosing a shorter vector. Going from d = 3072 to d = 768 is a 4× saving on memory, on scan time, on graph-hop cost, and on PQ code length — before any index exists, with no new machinery, and at a documented accuracy cost that is often under two points on standard retrieval benchmarks. Matryoshka-trained encoders make the truncation principled rather than a gamble. Teams routinely spend a month tuning an index to recover what a dimension choice gave away in an afternoon.

The three families, named now so you can hold the map

There are exactly three ideas in this field, and everything shipped in production is a composition of them.

Family 1 — partition
Cut the space into cells in advance. At query time, decide which few cells could plausibly hold the answer and scan only those. IVF (Chapter 2), the inverted multi-index, and ScaNN's learned tree. Also LSH, historically.
↓ orthogonal to…
Family 2 — compress
Replace each vector with a short code, so that touching a vector costs bytes instead of kilobytes. Distances become approximate but the scan gets 30–100× cheaper in memory traffic. PQ (Chapter 3), OPQ, anisotropic PQ (Chapter 6), binary hashing.
↓ orthogonal to…
Family 3 — navigate
Precompute a graph whose edges connect near neighbours plus a few deliberate long-range shortcuts. At query time, greedily walk downhill toward the query. HNSW (Chapter 4), NSG, Vamana / DiskANN (Chapter 5), CAGRA.

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.

How the field got here

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.

EraThe ideaWhat it unlockedWhy it was not enough
Exact spatial structures (1970s–1990s)k-d trees, ball trees, R-trees: recursively partition space and prune by boundGenuinely logarithmic exact search in 2–10 dimensions. Still the right answer for geographic dataPruning 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 boundsThe first sublinear method with a theorem attached, and a decade of theoryConstants 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 itPractical billion-scale search for the first time; the routing layer of nearly every modern systemRouting 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 lookup64× compression, and with it a billion vectors on one machineImposes 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 scaleThe best in-memory recall/latency frontier there is, with no training stageRequires 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 answerA billion points on one commodity nodeMulti-day builds; updates needed a separate paper
Task-aware compression (2020–)ScaNN: weight the quantization loss by which error directions change the rankingBetter recall at identical code length, and an idea that generalises far beyond searchDerivation assumes a high score threshold; less useful for deep tails
Accelerator-native (2023–)CAGRA and friends: graphs built and searched with thousands of threadsOrder-of-magnitude faster builds; strong batch throughputAccelerator 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.

What "nearest" even means — pin the metric down first

Before any index, settle the distance, because the wrong choice silently produces a beautifully fast wrong answer. Three appear constantly:

ObjectiveFormulaWhere it comes fromGotcha
Squared Euclidean (L2)‖q − x‖2SIFT descriptors, image features, anything geometricSensitive to vector norm; a long vector is far from everything
Cosine / angular1 − ⟨q, x⟩ / (‖q‖‖x‖)Text embeddings, almost all modern encodersImplemented by normalising once and then using inner product
Maximum inner product (MIPS)argmax ⟨q, x⟩Recommenders, two-tower retrieval, softmax output layersNot 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

‖q − x‖2 = ‖q‖2 − 2⟨q, x⟩ + ‖x‖2 = 2 − 2⟨q, x⟩

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.

The family that theory loved and practice left behind

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,

hr(x) = sign( ⟨r, x⟩ )

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:

P[ hr(p) = hr(q) ] = 1 − θ/π

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:

Pbit = 1 − 51.7/180 = 0.713

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

0.71316 = e16 · ln 0.713 = e−5.413 = 0.00446

Under half a percent. To reach 95% recall you need enough independent tables that at least one hits:

1 − (1 − 0.00446)L ≥ 0.95  ⇒   L ≥ ln(0.05) / ln(0.99554) = 670 tables

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.

The lesson generalises beyond LSH. Its hash family is data-independent — the random hyperplanes know nothing about where your points actually are. That is exactly what makes the theory clean, and exactly what makes the constants terrible: it must work for adversarial data, so it cannot exploit the fact that real embeddings are wildly clustered. Every method in the rest of this lesson is data-dependent: k-means centroids, learned codebooks, and graphs built from the actual points. They come with no worst-case guarantee and they win by one to three orders of magnitude on real corpora. When the data has structure, refusing to look at it is not rigour, it is waste.

What a query actually looks like in production

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.

WorkloadShapeBound byEffective cost per query
Interactive search box(1, d) × (d, N)Memory bandwidthFull stream. The hard case
Batch re-scoring, offline jobs(B, d) × (d, N)Compute, once B > ~20Stream / B — often 20–50× cheaper
Recommendation pre-compute(N, d) × (d, N)ComputeA 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.

A sanity check before we build anything

The arithmetic above should also tell you when not to read the rest of this lesson. Redo Number Three for a smaller corpus:

N = 100,000, d = 768:   100,000 × 3,072 = 3.07 × 108 bytes = 307 MB
t = 3.07 × 108 ÷ 2 × 1011 = 1.5 ms

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.

Hold this claim to account as you read. By Chapter 8 you should be able to take a billion 768-dimensional vectors, put them on one machine with 64 GB of RAM and an NVMe drive, and answer queries in about 5 ms at 95% recall — a factor of 3,000 in latency and 50 in hardware cost against the numpy one-liner, at the price of being wrong about one result in twenty. Everything between here and there is the derivation of that trade.
A billion 768-dimensional float32 vectors sit in the RAM of one large server. Why does adding more CPU cores barely reduce the time for one exact search?

Chapter 1: The Recall–Latency–Memory Triangle

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.

Recall, defined precisely enough to argue about

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

recall@k = |Rk(q) ∩ Gk(q)| / k,   averaged over a held-out query set

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.

Recall is not accuracy, and the difference is where systems get built. A recall of 0.90 does not mean "the answer is 90% right." It means one of every ten items that should have been in the candidate pool never got a chance to be considered by anything downstream. Whether that matters depends entirely on what happens next — which is why recall must always be quoted with the k it was measured at, and why the recall you need is a property of your pipeline, not of your index.

Worked example 3 — recall compounds, and it compounds badly

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):

P(all three chunks survive retrieval) = 0.903 = 0.729

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:

0.983 = 0.941  —  failure rate drops from 27.1% to 5.9%, a 4.6× reduction

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.

Concept check — answer before reading on. Your index has recall@10 = 0.95 and you serve a "find similar images" feature that shows nine results. Is 0.95 fine? … Almost certainly yes: a user glancing at a grid does not notice that result seven was swapped for result twelve, and there is no conjunction of independent events to compound. The same 0.95 in the RAG pipeline above gives 0.86 for a three-chunk question. Identical index, identical number, and the correct engineering decision is opposite. Recall targets are a property of the consumer.

What recall does not capture

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.

MetricSensitive toUse when
recall@kSet membership onlyTuning an index, or when a reranker downstream will fix the order anyway
nDCG@k against exact rankingOrder and positionThe index output is the result page
Mean distance ratio — d(returned)/d(true), averagedHow badly you missedDiagnosing whether misses are near-neighbours or genuine failures
Rank of the true nearest in your outputWhere the best item actually landedDebugging a specific bad query
The practical rule. If a reranker follows, measure recall at the shortlist size and ignore order entirely — ordering is the reranker's job and the index need only deliver the candidates. If the index output goes straight to the user, add nDCG. Picking the wrong one of those two costs you either wasted tuning effort or an invisible quality regression, and which mistake you make is determined by your architecture, not your preference.

The other two axes

Latency and throughput are different numbers and conflating them is the most common benchmarking error in this field.

MetricWhat it measuresWhy it can mislead
Mean latencyAverage wall-clock time per queryHides the tail entirely; a p99 of 200 ms can hide behind a 4 ms mean
p99 latencyThe slow 1% — the number your users complain aboutGrows when you shard, because the query waits for the slowest shard
QPS, single threadThe ann-benchmarks convention: 1 ÷ mean latency, one coreRewards algorithms with no intra-query parallelism; penalises GPU and heavily-SIMD designs
QPS per core / per dollarWhat the capacity plan actually needsRarely published, because it depends on the machine
Batch throughputMany queries at once, amortising the memory streamEnormously 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):

IndexPayload per vectorStructure overhead per vectorTotalFor 1M
Flat float32128 × 4 = 512 B0512 B512 MB
HNSW, M = 16512 B32 links × 4 B = 128 B (layer 0) + ~4 B upper644 B644 MB
HNSW, M = 48512 B96 links × 4 B = 384 B + ~8 B904 B904 MB
IVF4096, flat vectors512 B8 B id + centroids amortised~520 B520 MB
IVF4096, PQ m = 1616 B code8 B id24 B24 MB
IVF4096, PQ m = 88 B code8 B id16 B16 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.

Why QPS is always plotted on a log axis

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.

efRecall@10QPSRecall changeQPS change
100.8032,000
640.968,500+0.16÷ 3.8
5120.9951,200+0.035÷ 7.1
20480.999310+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.

Why it is a triangle and not three dials

The three quantities are not independent; each index family fixes one relationship and lets you trade the other two.

Fix recall = 1.0
You must touch every vector, or prove you need not. In high dimensions proof is impossible, so: full scan. Latency and memory both go to their maximum. This is the flat index.
Fix memory = large
Keep full-precision vectors resident, spend the memory on a navigation graph. Now you can have very high recall and very low latency. This is HNSW, and it is the right answer whenever the data fits.
Fix memory = tiny
Compress to a short code. Distances are now estimates, so recall has a ceiling no amount of search effort can exceed. Latency stays low. This is IVF-PQ, and the ceiling is why reranking exists.
Fix cost = one machine
Put the codes in RAM for navigation and the full vectors on SSD for the answer. Latency now includes storage round trips, so the design goal shifts from "fewer distance computations" to "fewer dependent I/Os". This is DiskANN.
The single most useful mental model in this whole field: quantization sets a ceiling on recall; search effort determines how close to the ceiling you get. If your recall plateaus at 0.82 no matter how much you raise nprobe or ef, you are not searching too little — your codes are too short. Conversely, if raising the effort knob still moves recall, quantization is not yet your bottleneck. Diagnosing which regime you are in takes one experiment and saves weeks.

Recall per byte, and recall per millisecond

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:

ConfigurationRecall@10Bytes/vectorp50Marginal cost of the gain
PQ 32 B, nprobe 80.812401.1 msbaseline
PQ 32 B, nprobe 320.874403.4 ms+6.2 pts for +2.3 ms, 0 bytes
PQ 64 B, nprobe 320.931724.1 ms+5.7 pts for +32 B/vector = +320 MB
PQ 64 B + rerank 5000.97872 hot6.5 ms+4.7 pts for +2.4 ms and an SSD read path
HNSW M=32, ef 1280.9813,2001.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.

Two ratios worth computing for every candidate configuration. Bytes per point of recall tells you what the last increment costs in machines. Milliseconds per point of recall tells you what it costs in latency budget. Configurations cluster into an obvious knee and an obvious tail, and the tail is where teams spend money without noticing. If a change costs more than about a gigabyte per point of recall on a ten-million corpus, it should be an explicit decision, not a default.

Formalising approximate search — and why the formalism is not used

The theory community defines the problem cleanly. A (1 + ε)-approximate nearest neighbour query returns a point p such that

d(q, p) ≤ (1 + ε) · d(q, p*),   where p* is the true nearest neighbour

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:

(1 + 0.05) × 0.87 = 0.914

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.

This is why the field is empirical. The distance ratio is a bad summary of quality in high dimensions because distances are nearly all the same; the identity of the returned items is what matters. So the community measures recall against brute-force ground truth on standard datasets and plots curves. There is no theorem you can cite instead of running the benchmark. Chapter 7 teaches you to read those plots properly, which is the actual professional skill here.

The index card — four numbers per candidate

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.

FieldExampleWhy it is on the card
Recall@k, with k stated10-recall@10 = 0.962The strictness matters more than the value; without k the number is not comparable
p50 / p99 at real concurrency2.1 ms / 8.4 ms at 16 threadsSingle-thread QPS predicts nothing about a loaded server
Resident bytes per vector96 B hot + 3,072 B coldMultiplied by N, this is the machine bill. Split hot and cold explicitly
Build time and peak build RAM42 min, 61 GBDecides the rebuild cadence and the build machine, and never appears in benchmarks
The knob and its valuenprobe = 24Without 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.

Sim — the triangle, explorable

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.

Recall · latency · memory — the triangle explorer

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.

Bytes / vector 32 B
Vectors visited 500

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.

Worked example 4 — tail latency, and what sharding does to it

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

P(all S shards ≤ 20 ms) = 0.99S
Shards SP(request ≤ 20 ms)What used to be p99 is now…
10.990p99
50.951p95
100.904p90
500.605roughly the median
1000.366worse 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.

The practical consequences, in order of usefulness. (1) Shard by a dimension the query filters on — tenant, language, region — so a query touches one shard rather than all of them. This is worth more than any index choice. (2) If you must fan out, hedge: send a duplicate request to a replica after the p95 deadline and take whichever answers first, which converts a tail into extra load. (3) Never compare a sharded system's latency to a single-machine benchmark; you are measuring different distributions.

The measurement harness, which you should build before any index

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.

What an index actually is, as a data structure

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.

PropertyPartition (IVF)Compression (PQ)Graph (HNSW / Vamana)
Needs training on a sampleYes — k-means over nlist centroidsYes — m k-means runsNo
Build cost for 1M × 128Seconds to a minuteSecondsMinutes
Insert one vectorO(nlist) — cheapO(m · k*) — cheapA full search — expensive
Delete one vectorRemove from a list — easyN/AHard — tombstone and rebuild
Degrades as data driftsYes — centroids go staleYes — codebooks go staleBarely
Memory overheadTinyNegative — it saves memoryLarge — 128–400 B/vector
Recall ceilingNone — raise nprobe to 100%Yes — set by code lengthNone — 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.

Your IVF-PQ index reports recall@10 = 0.81. You raise nprobe from 16 to 128 — an eightfold increase in vectors scanned — and recall moves to 0.815. What is the diagnosis and the fix?

Chapter 2: IVF From Zero

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?

Where the name comes from, because it explains the design

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.

Train — once, offline
Run k-means on a sample of the corpus to get nlist centroids c1…cnlist. This is the coarse quantizer.
Add — once per vector
For each x, find its nearest centroid and append x's id to that centroid's inverted list. Embarrassingly parallel, one pass, no ordering constraints.
Search — per query
Compute d(q, ci) for all nlist centroids. Take the nprobe closest. Scan exactly those inverted lists and keep the best k. Everything else in the corpus is never touched.
The coarse quantizer is just a tiny nearest-neighbour problem you solve exactly. You have replaced "find the nearest of a billion vectors" with "find the nearest of a few thousand centroids, then scan a small fraction of the billion." That recursion — solve a small exact problem to prune a huge one — is the load-bearing idea of the entire partition family, and Chapter 8 shows it applied recursively three deep.

Worked example 5 — the cell arithmetic, done fully

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:

average list length = N / nlist = 1,000,000 / 4,096 = 244.14 vectors

Step two: the coarse step. Comparing the query against every centroid is itself a brute-force scan — a small one:

4,096 centroids × 128 dims = 524,288 multiply–adds

Step three: the fine step. nprobe = 8 lists, each about 244 vectors:

8 × 244.14 = 1,953 vectors,   1,953 × 128 = 250,000 multiply–adds

Step four: the total, and the speedup.

524,288 + 250,000 = 774,288 vs brute force 1,000,000 × 128 = 128,000,000
165× fewer operations, touching 1,953 / 1,000,000 = 0.195% of the corpus

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.

The coarse quantizer eventually becomes the bottleneck, and the fix is beautiful. As you scale up nlist to keep lists short, the centroid scan grows linearly. At nlist = 262,144 — a sensible choice for a billion points — the coarse step alone is 262,144 × 128 = 33.6 million multiply–adds, which is a quarter of the original brute-force cost. The standard fix is to build an ANN index over the centroids: FAISS's 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.

Deriving the √N rule of thumb

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:

C(nlist) = d · nlist + d · nprobe · (N / nlist)

The first term grows with more cells, the second shrinks. Differentiate with respect to nlist and set to zero:

dC/dnlist = d − d · nprobe · N / nlist2 = 0  ⇒   nlist = √(nprobe · N)

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.

Training cost, and the warning FAISS prints at you

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.

nlistMinimum training vectors (39×)Comfortable (100×)Typical N this suits
1,02439,936102,400105 – 106
4,096159,744409,600106
65,5362,555,9046,553,600107 – 108
262,14410,223,61626,214,400109

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.

The failure mode, hand-worked: the Voronoi boundary

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?

d(q, c1)2 = 4.92 + 0.22 = 24.01 + 0.04 = 24.05  →  d = 4.904
d(q, c2)2 = 5.12 + 0.22 = 26.01 + 0.04 = 26.05  →  d = 5.104

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:

d(p, c1)2 = 5.052 + 0.252 = 25.5025 + 0.0625 = 25.565  →  d = 5.056
d(p, c2)2 = 4.952 + 0.252 = 24.5025 + 0.0625 = 24.565  →  d = 4.956

p was filed under c2. And yet:

d(q, p)2 = (5.05 − 4.9)2 + (0.25 − 0.2)2 = 0.0225 + 0.0025 = 0.025  →  d = 0.158

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.

This is the entire recall story of IVF, and it generalises exactly. A query near a cell boundary has its true neighbours split across cells. The fraction of queries in that situation is what determines recall at a given nprobe, and it grows with dimension — in high dimensions a cell has enormously many neighbouring cells, so "just next door" has many more doors. That is why nprobe = 1 gives roughly 0.6 recall@1 on SIFT1M while nprobe = 8 gives roughly 0.9 and nprobe = 64 roughly 0.98: you are buying coverage of more and more of the boundary.

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.

Why k-means, and what else has been tried

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.

PartitionHow it routesVerdict
k-means (IVF)Nearest of nlist learned centroidsThe standard. Adapts to the data's density; the coarse scan is its only weakness, and a graph fixes that
Random projections / LSH bucketsSign pattern of a few random hyperplanesData-independent, so the buckets are wildly unbalanced on clustered data. Chapter 0's constants
k-d tree over the top principal componentsAxis-aligned splits on high-variance directionsWorks passably at low intrinsic dimension; cells are boxes, and boxes are a poor fit to the shape of embedding clusters
Inverted multi-indexNearest pair of centroids from two half-space codebooksK2 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 codebooksRoutes to preserve the ranking rather than the coordinates — Chapter 6's idea applied one level up
Graph, used as a partitionerFind the nearest few centroids by walking a graph over themNot 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.

Residuals — the detail that makes IVF-PQ work at all

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:

r = x − c(x),   where c(x) is the centroid of x's cell

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.

The arithmetic of soft assignment

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 assignmentDouble assignment
Entries stored1,000,0002,000,000
Average list length244488
Vectors scanned at nprobe = 81,9533,906
Vectors scanned at nprobe = 49771,953
Memory (PQ 16 B + 8 B id)24 MB48 MB
Boundary missesEvery query near a face losesPoints 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.

Living with an index that grows

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.

ElapsedWhat happensObservable symptom
Day 0Centroids trained on a representative sampleRecall matches the benchmark
Week 4New content lands unevenly — a new product line, a new languageA few lists grow much faster than the rest; p99 creeps up
Month 3Some cells hold 10× the average; the partition no longer reflects the dataRecall down several points, uniformly, with no code change to blame
Month 6The embedding model is upgradedEvery 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.

Sim — cells, probes, and the boundary problem

IVF explorer — Voronoi cells, nprobe, and what you miss

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.

nlist 16
nprobe 1

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.

The continuum, from exact to reckless

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 scannedCharacter
10.024%Fast and boundary-fragile. Around 0.6 recall@1 on typical data
80.20%The common default. Around 0.9
641.6%High recall, still 60× cheaper than exact. Around 0.98
51212.5%Diminishing returns; the coarse step is now cheap by comparison
4,096100%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.

List imbalance, the problem the average hides

"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.

SymptomCauseRemedy
p99 latency far above p50 with stable nprobeA few very large listsCap list length; split oversized cells with a second-level partition
Recall varies wildly by query typeSome queries land in cells that are too coarse to be selectiveMore cells, or balanced assignment
Several cells are nearly emptyk-means initialised badly, or the data has outliersRe-train with k-means++ init and more iterations
Recall drifts down over weeksNew content lands disproportionately in a few cellsScheduled 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-Flat versus IVF-PQ, and what the choice actually decides

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-FlatIVF-PQIVF-PQ + refine
Stored per vectorFull float32 vector + idm-byte code + idm-byte code + id, floats elsewhere
Bytes at d = 128, m = 165202424 hot + 512 cold
DistancesExact within probed cellsADC estimatesADC to shortlist, exact to rank
Recall ceilingNone — raise nprobe to 100%Set by code lengthNone, in practice
Where it shines106–107 with RAM to spare108–109, memory-boundLarge 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.

IVF in one table

AspectDetail
Build-time parameternlist — number of cells. Changing it requires retraining and re-adding everything
Query-time parameternprobe — cells to scan. Free to change per query, even per user tier
Rule of thumbnlist ≈ √(nprobe · N); train on 100–256 vectors per centroid
Recall ceilingNone. nprobe = nlist is exact brute force
Memory overheadnlist × d floats for centroids, plus one id per vector. Negligible
InsertsCheap and parallel: one coarse search, one list append
DeletesGenuinely easy — remove the id from its list. A real advantage over graphs
WeaknessRecall depends on how the query sits relative to cell boundaries; centroids go stale as data drifts
Natural partnerPQ for the payload (next chapter), HNSW for the coarse quantizer (Chapter 4)
You have N = 108 vectors and you are targeting nprobe = 16. Roughly what nlist minimises query cost, and how many training vectors should you sample?

Chapter 3: Product Quantization, Derived

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.

Start with plain vector quantization, and watch it die

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?

k = 264 = 1.845 × 1019 centroids
storage = 1.845 × 1019 × 128 dims × 4 bytes = 9.4 × 1021 bytes

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.

The wall, stated cleanly. Vector quantization has exponential cost in the code length: k centroids need k · d floats of storage and at least k training points. Doubling the code length squares the codebook. A flat quantizer is stuck at about 16 bits — 65,536 centroids, which is 32 MB of codebook and perfectly trainable, but 16 bits is far too coarse to rank a billion vectors.

The product trick

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:

x = [ x(1) | x(2) | … | x(m) ],   each x(j) ∈ Rd*, d* = d/m

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:

code(x) = ( i1, i2, …, im ),   ij = argmini ‖x(j) − ci(j)2

The reconstruction is the concatenation of the chosen sub-centroids: x̂ = [ ci1(1) | … | cim(m) ]. And now count what you have.

QuantityFlat VQ, 64-bit codeProduct quantizer, m = 8, k* = 256
Distinct reconstructions264(k*)m = 2568 = 264identical
Centroids to store1.845 × 1019m × k* = 8 × 256 = 2,048
Floats of codebook2.36 × 1021m × k* × d* = 8 × 256 × 16 = 32,768 = 128 KB
k-means runs needed1, over 1019 centroids — impossible8, each over 256 centroids in 16-D — seconds
Code size per vector8 bytes8 bytes
Look at the first row against the third. The product quantizer represents exactly as many distinct points as the impossible flat quantizer — 264 of them — using 128 kilobytes of codebook and eight trivial k-means runs. The codebook size is m · k* · (d/m) = k* · d floats, which does not depend on m at all. You can push m to 16 or 32 and the codebook stays the same size while the number of representable points grows to 2128 or 2256. This is the whole paper, and it is genuinely one of the best ideas in applied computer science.

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.

Worked example 6 — the complete hand calculation

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:

x = ( 0.9, 0.1, 0.2, 1.1 | 2.8, 0.2, 0.1, 3.2 )

Encoding, subspace 1. x(1) = (0.9, 0.1, 0.2, 1.1). Squared distance to each centroid, term by term:

to c(1)0: (0.9−1)2 + (0.1−0)2 + (0.2−0)2 + (1.1−1)2 = 0.01 + 0.01 + 0.04 + 0.01 = 0.07
to c(1)1: 0.81 + 0.81 + 0.64 + 1.21 = 3.47
to c(1)2: 1.21 + 3.61 + 0.04 + 1.21 = 6.07
to c(1)3: 0.81 + 0.01 + 3.24 + 0.81 = 4.87

The winner is index 0, at squared error 0.07.

Encoding, subspace 2. x(2) = (2.8, 0.2, 0.1, 3.2):

to c(2)0: 3.24 + 0.64 + 0.81 + 4.84 = 9.53
to c(2)1: 0.04 + 0.04 + 0.01 + 0.04 = 0.13
to c(2)2: 7.84 + 7.84 + 8.41 + 10.24 = 34.33
to c(2)3: 7.84 + 0.04 + 0.01 + 10.24 = 18.13

The winner is index 1, at squared error 0.13.

code(x) = (0, 1)  —  two 2-bit integers = 4 bits, versus 8 floats = 256 bits. 64× compression.

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:

‖x − x̂‖2 = 0.07 + 0.13 = 0.20,   ‖x − x̂‖ = 0.447

Asymmetric distance computation, hand-worked

Now the query arrives:

q = ( 1.0, 0.2, 0.1, 0.9 | 2.9, 0.1, 0.2, 3.1 )

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):

T1[0] = 0 + 0.04 + 0.01 + 0.01 = 0.06
T1[1] = 1.00 + 0.64 + 0.81 + 0.81 = 3.26
T1[2] = 1.00 + 3.24 + 0.01 + 0.81 = 5.06
T1[3] = 1.00 + 0.04 + 3.61 + 1.21 = 5.86

Table for subspace 2, from q(2) = (2.9, 0.1, 0.2, 3.1):

T2[0] = 3.61 + 0.81 + 0.64 + 4.41 = 9.47
T2[1] = 0.01 + 0.01 + 0.04 + 0.01 = 0.07
T2[2] = 8.41 + 8.41 + 7.84 + 9.61 = 34.27
T2[3] = 8.41 + 0.01 + 0.04 + 9.61 = 18.07

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):

2(q, x) = T1[0] + T2[1] = 0.06 + 0.07 = 0.13

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):

‖q − x‖2 = (0.01 + 0.01 + 0.01 + 0.04) + (0.01 + 0.01 + 0.01 + 0.01) = 0.07 + 0.04 = 0.11

Estimated 0.13, true 0.11. The estimate is 18% high. That direction is not an accident, and the exact decomposition explains it.

Why ADC is biased upward, exactly

Write the true distance in terms of the reconstruction, by adding and subtracting x̂:

‖q − x‖2 = ‖(q − x̂) − (x − x̂)‖2 = ‖q − x̂‖2 − 2⟨q − x̂, x − x̂⟩ + ‖x − x̂‖2

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):

⟨q − x̂, x − x̂⟩ = 0 + 0.02 + 0.02 − 0.01 + 0.02 + 0.02 + 0.02 + 0.02 = 0.11
0.13 − 2(0.11) + 0.20 = 0.13 − 0.22 + 0.20 = 0.11

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

E[ d̂2(q, x) ] ≈ d2(q, x) + E[ ‖x − x̂‖2 ]

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.

The alternative that loses: symmetric distance computation

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:

SDC2(q, x) = ‖c(1)0 − c(1)02 + ‖c(2)1 − c(2)12 = 0 + 0 = 0

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).

The general principle, worth carrying out of this chapter. Compress the thing you have many of; keep full precision on the thing you have one of. You have a billion database vectors and one query, so quantize the database and leave the query alone. This same asymmetry reappears in DiskANN (PQ codes for navigation, exact vectors for the final answer) and in every rerank stage ever built.

Cost accounting for a real scan

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.

StepCostNotes
Build the tablesm × k* × d* = 8 × 256 × 16 = 32,768 multiply–addsOnce per query (per probed list, if using residuals)
Score 1,953 codes1,953 × 8 = 15,624 lookups + 13,671 addsZero multiplications
Exact float equivalent1,953 × 128 = 250,000 multiply–adds16× more arithmetic
Memory streamed, PQ1,953 × 8 B = 15.6 KBFits in L1 cache
Memory streamed, float1,953 × 512 B = 1.0 MBBlows 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.

OPQ — fixing the independence assumption for free

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.

encode: code(Rx)   query: build the tables from Rq   cost at query time: one d × d matrix–vector product

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.

The ceiling, and the rerank that breaks it

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:

Stage 1 — shortlist, on codes
Scan the probed lists with ADC and keep the best r candidates, where r is 10–50× the k you actually want. Cheap: 8 bytes per vector touched.
↓ r candidates, say 500 for k = 10
Stage 2 — rerank, on real vectors
Fetch the r full-precision vectors (from RAM, SSD, or object storage) and compute exact distances. 500 × 512 B = 256 KB of reads and 64,000 multiply–adds. Sort, return the true top k.

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.

Worked example 7 — a billion vectors on one machine

Chapter 0's impossible corpus, re-costed with PQ at d = 128 (the classic SIFT1B / BIGANN setting):

ConfigurationBytes per vectorTotal for 109Fits on
Flat float32512512 GBA very large, very expensive machine
PQ m = 8, plus int64 id8 + 8 = 1616 GBA laptop
PQ m = 16, plus int64 id16 + 8 = 2424 GBA small server
PQ m = 32, plus int32 id32 + 4 = 3636 GBA small server
PQ m = 64, plus int32 id64 + 4 = 6868 GBA 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.

Sim — build the distance table yourself

PQ distance-table builder — the worked example, live

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.

Estimator:

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.

Choosing m, as a bit-allocation problem

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.

dmd*Bytes/vectorCompressionCharacter
128816864×Very aggressive; shortlist only, rerank mandatory
1281681632×The billion-scale default
1283243216×High recall without rerank on many datasets
7689689632×Modern text embeddings, memory-conscious
768192419216×When recall matters more than RAM
153696169664×Consider truncating d first — see Chapter 8
A cheaper lever than m, hiding in plain sight. If your encoder was trained with Matryoshka representation learning, you can truncate 1536 dimensions to 384 before quantizing at all — a 4× saving that costs a couple of points of accuracy, applied before PQ's 32×. Reducing d and reducing bytes-per-dimension are independent multipliers, and the first is usually the cheaper one. Teams reach for larger m when they should have reached for a shorter vector.

Product quantization for inner product, which is not the same thing

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:

⟨q, x̂⟩ = ∑j=1m ⟨ q(j), cij(j)

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.

Write it yourself — product quantization in twenty lines

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.

Product quantization in one table

SymbolMeaningTypical value
dVector dimension128, 768, 1536
mNumber of subquantizers (chunks)8–64; must divide d
d* = d/mDimension of each chunk4–32
k*Centroids per sub-codebook256 (8 bits) or 16 (4 bits, SIMD)
code lengthm · log2(k*) bits64 bits = 8 bytes at m = 8, k* = 256
codebook storagek* · d floats — independent of m128 KB at k* = 256, d = 128
representable points(k*)m264 at m = 8, k* = 256
table buildk* · d multiply–adds per query32,768
per-code scorem lookups + (m−1) adds8 lookups
estimator bias+E[‖x − x̂‖2], constant across candidatesHarmless for ranking; its variance sets the ceiling
Why can a product quantizer represent 264 distinct points while a flat quantizer with the same code length is impossible to build?

Chapter 4: HNSW — Navigation Instead of Partition

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.

Idea one: the skip list

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.

P(element reaches level ℓ) = p,   expected height = log1/p(n)

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:

LevelNodes at this levelWalkComparisons
31, 221 → is 22 ≤ 13? No. Drop.1
21, 7, 18, 301 → 7 (7 ≤ 13, step) → 18 > 13, drop.2
11, 4, 7, 13, 18, 22, 307 → 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.

The transferable insight is not "sorted lists." It is: most of a search is spent covering distance, and covering distance is cheap if you have long links. Precision is only needed at the end, and precision is cheap if you have short links. So build a structure with both, and use them in that order. HNSW is that sentence applied to a space with no total order.

Idea two: navigable small worlds

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.

HNSW: put the long links on their own floor

Malkov & Yashunin's insight (arXiv:1603.09320) is to separate the scales explicitly, exactly as a skip list does. Build a hierarchy of graphs:

Layer assignment
Each inserted element gets a maximum layer ℓ = ⌊−ln(U) · mL⌋ with U uniform on (0,1) and mL = 1/ln(M). This makes P(level ≥ ℓ) = M−ℓ: layer 1 holds 1/M of the points, layer 2 holds 1/M2, and so on.
Search, upper layers
Start at the single top entry point. At each layer run greedy routing with beam width 1 — just walk downhill until no neighbour improves. Use the result as the entry point for the layer below. These layers are sparse, so hops are long and cheap.
Search, layer 0
Every element lives here. Run a beam search keeping the ef best candidates found so far, expanding the closest unexpanded one, until the closest unexpanded candidate is farther than the worst kept result. Return the top k of the beam.

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.

The knobs, and exactly what each one does

ParameterWhen it appliesWhat it controlsTypical
MBuildLinks per node per layer. Layer 0 gets Mmax0 = 2M. Sets memory and the recall ceiling of the graph16–48
efConstructionBuildBeam width while inserting. Higher = better-chosen neighbours = better graph. Costs build time only, never query time100–500
ef (efSearch)QueryBeam width at layer 0. Must be ≥ k. The runtime dial that traces the recall/QPS curvek…1000
mLBuildLayer-assignment scale. The paper shows 1/ln(M) is near-optimal; almost nobody changes it1/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:

106 × 32 × 4 B = 128 MB

Upper layers: the expected number of nodes summed over all layers above zero is

N · (1/M + 1/M2 + …) = N / (M − 1) = 106 / 15 = 66,667 nodes

each with up to M = 16 links:

66,667 × 16 × 4 B ≈ 4.3 MB — about 3% of the layer-0 cost

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.

Why HNSW cannot reach a billion points on one machine, in one line. 109 × (512 B vector + 128 B graph at M = 16) = 640 GB, all of which must be in RAM, because every hop reads a full-precision vector at an unpredictable address. You cannot page it from SSD: the hops are sequentially dependent, so each one is a separate round trip, and 50 hops × 100 µs = 5 ms of pure I/O per query with no way to overlap it. This precise problem is what DiskANN was built to solve, and Chapter 5 solves it by changing what you read on each hop.

The neighbour-selection heuristic — the part everyone skips

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

d(e, c) < d(c, r)   for every r already accepted

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.

Concept check. Why not simply keep more neighbours, say 4M, and avoid the whole question? … Because degree is the dominant cost of a hop. Each expansion computes distances to all of a node's neighbours, so quadrupling M quadruples both memory and the arithmetic per hop, while the diversity problem — that the extra slots keep going to the same dense direction — is not solved by having more slots. The heuristic makes a small budget of edges cover many directions. Degree buys you nothing that diversity has not already bought better.

Worked example 8 — a complete search trace, by hand

Twelve points in the plane. Coordinates:

NodeABCDEFGHIJKL
x124675389629
y132145672889

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.

d(A, q) = √(7.22 + 7.42) = √(51.84 + 54.76) = √106.60 = 10.325
d(H, q) = √(0.22 + 1.42) = √(0.04 + 1.96) = √2.00 = 1.414

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:

d(C, q) = √(4.22 + 6.42) = √58.60 = 7.655
d(F, q) = √(3.22 + 3.42) = √21.80 = 4.669
d(J, q) = √(2.22 + 0.42) = √5.00 = 2.236

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:

d(E, q) = √(1.22 + 4.42) = √20.80 = 4.561
d(I, q) = √(0.82 + 6.42) = √41.60 = 6.450
d(J, q) = 2.236
d(L, q) = √(0.82 + 0.62) = √1.00 = 1.000

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:

d(F, q) = 4.669,  d(G, q) = √(5.22 + 2.42) = √32.80 = 5.727,  d(K, q) = √(6.22 + 0.42) = √38.60 = 6.213

All worse than 2.236; discarded. H and L already visited. Queue is empty. Halt.

W = { L: 1.000, H: 1.414, J: 2.236 } — exactly the true top 3.

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.

Be honest about what a twelve-point example can and cannot show. Touching 10 of 12 is not a speedup; on twelve points nothing is. What the trace demonstrates is the shape: one long hop at the top covered 89% of the distance, the middle layer cost three comparisons and confirmed the position, and layer 0 did local refinement with a beam. That shape is scale-free. On a million points the same trace expands about 30–60 nodes with ef = 64 — roughly 2,000 distance computations out of 1,000,000, or 0.2% — and on a billion it grows only logarithmically, to perhaps 3,000. The number of hops grows with log N; the corpus grows with N. That divergence is the entire value proposition.

Setting M and efConstruction without guessing

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:

bytes per vector = 4d  +  4 · ( 2M  +  M/(M−1) )

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:

MGraph bytes/vectorTotal at d = 76810M corpusCharacter
8693,14131.4 GBCheap, and recall tops out lower — too few directions per node
161323,20432.0 GBThe sensible default for most corpora
322603,33233.3 GBBetter high-recall behaviour; the usual production choice
483883,46034.6 GBFor hard, high-dimensional data. Diminishing returns beyond
645163,58835.9 GBRarely justified; hops get expensive faster than recall improves
Notice what that table actually says at modern dimensions. At d = 768 the vector is 3,072 bytes and the graph at M = 32 is 260 — under 8% of the total. The folk wisdom that "HNSW's graph overhead is prohibitive" comes from the d = 128 era, where 260 bytes sat against a 512-byte vector and was a 50% surcharge. On modern embeddings the graph is nearly free and the vectors are the memory problem — which points at compression or a shorter d, not at a smaller M. Re-derive the ratio for your own d before inheriting a conclusion.

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 efDiagnosisAction
Recall rises smoothly and saturates near 0.99Healthy graphPick the knee and ship
Recall saturates at 0.90 and will not moveGraph quality, not search effortRaise efConstruction, or M, and rebuild
Recall is fine but QPS falls off a cliff past some efThe beam no longer fits in cacheAccept the knee; or shard so each index is smaller
Recall varies enormously between query typesSome regions are poorly connectedCheck for duplicate clusters; deduplicate and rebuild

Sim — watch the layers do their jobs

HNSW layer-hop visualiser — the twelve-point trace, animated

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.

ef (beam width) 3

Why greedy routing works at all — the ideal graph, and the one we can afford

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.

So every graph index is an approximation of the Delaunay graph under a degree budget. That single sentence explains the whole design space. Keep M edges per node and you cannot preserve every Delaunay edge, so the question becomes which M to keep. Keeping the M nearest keeps the shortest Delaunay edges and throws away the long ones — which is precisely how you create local minima at cluster boundaries. HNSW's diversity heuristic and Vamana's α-rule are two answers to the same question: given a budget of M edges, choose the subset that preserves navigability rather than the subset that minimises edge length.

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.

Insertion, which is just a search with a write at the end

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.

The ef ≥ k rule, and the mistake it causes

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.

GoalkSensible efMetric to track
Result page, no reranker1064–12810-recall@10, plus nDCG
Feed a cross-encoder100200–40010-recall@100
Feed an exact rerank over codes500500–100010-recall@500
Offline candidate generation10001000–2000Whatever the consumer needs

The implementations you will actually meet

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.

ImplementationWhat is distinctiveWatch out for
hnswlibThe reference, by the paper's authors. Small, fast, header-onlyFixed 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 quantizerParameter names differ from the paper's (efConstruction, efSearch live on the index, not the search call)
Lucene / OpenSearch HNSWSegment-based: each segment holds its own graph, and merges rebuild themSearch fans out across segments, so recall and latency depend on merge state — a moving target under heavy indexing
pgvector hnswInside Postgres, so filters, joins, and transactions come freeFiltered queries hit exactly Chapter 7's problem; check the plan, and consider partial indexes per filter value
usearch, and similarQuantized storage inside the graph (f16, i8), which cuts the dominant memory termDistances are now approximate during the walk as well as the ranking — measure, do not assume
The one in the third row is the most commonly misdiagnosed. A segment-based engine does not hold one graph; it holds one per segment, and a query searches all of them. Recall therefore depends on how many segments exist, which depends on how recently merges ran, which depends on your write rate. Teams see recall and latency "randomly" fluctuate and go looking for a bug in the index, when the answer is the merge policy. If your engine is Lucene-derived, force-merge before benchmarking or you are measuring segment count.

Where HNSW hurts

ProblemWhy it happensWhat people do
MemoryFull-precision vectors must stay resident for hop distances, plus 128–400 B/vector of graphCap the corpus, shard, or move to DiskANN / IVF-PQ
Slow buildEvery insert is a full search with beam efConstruction. Build is often 10–100× the cost of an IVF buildParallel insert (with locking), lower efConstruction, or build once and treat as immutable
DeletesRemoving a node can disconnect the graph; there is no cheap repairTombstone and filter at read time; rebuild periodically. This is why "just use HNSW" fails on mutable corpora
Filtered queriesGreedy routing follows the graph, not the predicate. With a selective filter, whole neighbourhoods are ineligible and the walk stallsPre-filter and brute force when selectivity is high; filter-aware graphs (ACORN, filtered-DiskANN) otherwise. See Chapter 7
ef must exceed kThe beam is the result set; you cannot return 100 results from a beam of 10Always set ef ≥ k, in practice 2–10× k
Cold start / mmapRandom access across the whole graph means page faults everywhere until warmWarm 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.

HNSW's neighbour-selection heuristic rejects a candidate c when some already-accepted neighbour r satisfies d(c, r) ≤ d(e, c). What failure does this prevent?

Chapter 5: DiskANN — When the Vectors Live on SSD

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.

Start with why the obvious thing fails

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:

vectors: 109 × 512 B = 512 GB
graph at M = 32 (64 links × 4 B): 109 × 256 B = 256 GB
total = 768 GB of RAM

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.

120 dependent reads × 100 µs = 12 ms of pure I/O, with the CPU idle throughout

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.

Reframe the objective. In RAM, the cost of a graph search is the number of distance computations. On SSD, it is the number of dependent round trips. Those are different quantities and they are optimised by different graph properties. RAM wants low degree; disk wants low diameter — fewer hops, even if each hop is fatter. Every DiskANN design decision follows from that swap.

The three-part answer

1. Split precision by role
Keep compressed PQ codes for every point in RAM (navigation) and the full vectors on SSD (the final answer). During the walk, candidate distances come from the in-memory codes, so deciding where to step costs no I/O at all.
2. Co-locate vector and adjacency
On disk, each node's full vector and its neighbour list live in the same sector. One read gives you both the exact coordinates for reranking and the edges for continuing the walk. Never two reads where one will do.
3. Build a low-diameter graph
Vamana, a graph construction with a tunable pruning parameter α that deliberately retains long edges, shrinking the number of hops a search needs — because hops are now the expensive unit.

Worked example 9 — the memory budget, line by line

A billion 128-dimensional points on a 64 GB machine.

WhatWhereSizeArithmetic
PQ codes, m = 32RAM32 GB109 × 32 B
PQ codebooksRAM128 KB256 × 128 floats
Cached graph near the entry pointRAM~1–4 GBAll nodes within 3–4 hops of the medoid
Full vectors + adjacencySSD~772 GB109 × (512 B vector + 4 B degree + 64 × 4 B links) = 772 B/node
RAM total≈ 36 GBComfortably 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, and the α that makes it different

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

α · d(p*, v) ≤ d(p, v)

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).

d(p, v) = √(1.52 + 2.42) = √(2.25 + 5.76) = √8.01 = 2.830
d(a, v) = √((1.5−1)2 + 2.42) = √(0.25 + 5.76) = √6.01 = 2.452

Now apply the rule at two settings:

α = 1.0:  1.0 × 2.452 = 2.452 ≤ 2.830  ⇒ prune the edge p → v
α = 1.2:  1.2 × 2.452 = 2.942 ≤ 2.830?  No ⇒ keep the edge p → v

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.

What that buys, in the only currency that matters on disk. Every retained long edge is a hop the search does not have to take, and every hop is a 100 µs SSD round trip. Vamana graphs at α = 1.2 have measurably smaller diameter than α = 1 graphs of the same degree; the paper's ablation shows the two-pass α = 1 then α = 1.2 construction converging to higher recall at lower hop counts than either pass alone. The extra edges cost bytes on a device you have plenty of, and save microseconds on a device you do not.

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.

The medoid, and why one fixed entry point is enough

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:

ConsequenceWhy it follows
The graph file has exactly one record per pointNo layers to store, no per-layer adjacency. Simple sector-aligned layout
The hot cache is trivially identifiableEvery 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 varianceEvery 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.

Beam search: turning dependent reads into parallel ones

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 WRounds (≈ visits / W)Latency (rounds × 100 µs)I/Os per queryQPS ceiling at 600k IOPS
112012.0 ms1205,000
2606.0 ms~1304,600
4303.0 ms~1504,000
8151.5 ms~1903,150
1680.8 ms~2802,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:

600,000 IOPS ÷ 120 reads per query ≈ 5,000 QPS

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.

Two-stage scoring: approximate for routing, exact for ranking

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.

The pattern, stated generally, because you will use it far outside vector search. Use a cheap, biased estimate to decide where to spend expensive exact work; never let the cheap estimate produce the final answer. Query planners do it with cardinality estimates. Renderers do it with bounding volumes. Retrieval pipelines do it with bi-encoder shortlists feeding cross-encoder rerankers. DiskANN does it with PQ codes feeding SSD reads.

Worked example 11 — how much RAM the codes really need, and why m is chosen there

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 codesFits in 64 GB with the graph cache?Routing quality
88 GBEasilyPoor — the walk wanders, so more hops, so more I/O
1616 GBYesAdequate
3232 GBYes, with ~30 GB to spareGood — the paper's operating point
6464 GBNo — nothing left for the cacheExcellent, 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."

Building a billion-point graph on a machine that cannot hold it

Vamana's construction is itself memory-hungry — it needs random access to vectors. The paper's answer is a merge-based build:

StepWhat happensWhy
1. Clusterk-means the corpus into k ≈ 40 shardsEach shard is small enough to build in RAM
2. OverlapSend each point to its two nearest shardsPoints near shard boundaries need edges on both sides — the same soft-assignment trick as multi-probe IVF in Chapter 2
3. BuildRun full Vamana independently per shardParallel, in RAM, no coordination
4. MergeUnion the edge lists per point, then RobustPrune back down to RA point present in two shards contributes both neighbourhoods; pruning restores the degree bound and the diversity property
5. Lay outWrite vector + adjacency per node into sector-aligned recordsOne 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 on-disk record, byte by byte

The layout is the design, so it is worth seeing as bytes. For d = 128 and R = 64:

OffsetFieldBytesWhy it is here
0vector, float32 × 128512Exact coordinates for the final ranking — the whole reason to touch the disk
512degree, uint324Vamana's out-degree is variable up to R
516neighbour ids, uint32 × up to 64256Where to walk next, delivered by the same read
node record772Five 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.

Caching the top of the graph — a free 20–30% of the I/O

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:

2 × 106 nodes × 772 B = 1.5 GB of RAM

— 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.

The generalisation: in any structure with a fixed entry point, the nodes near the entry are read by every request and the nodes far from it are read by almost none. That is a power law, and power laws are what caches are for. A fixed medoid is often described as a limitation of Vamana next to HNSW's hierarchy — but it is also what makes this cache trivially correct, which on a disk-resident index is worth more than the hierarchy was.

The search loop, with the two precisions made explicit

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.

The IOPS budget under concurrency

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 loadIOPS demandedBandwidth demandedBinding constraint
500 QPS60,0000.24 GB/sNeither — latency is 3 ms, comfortably
2,000 QPS240,0000.98 GB/sNeither, but queueing begins to show in the tail
5,000 QPS600,0002.46 GB/sIOPS, exactly at the rating. Latency starts climbing
7,500 QPS900,0003.69 GB/sBoth, 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.

When DiskANN is the wrong answer

SituationWhy it failsWhat to use instead
Corpus under ~107All the machinery buys nothing; the data fits in RAMHNSW, or a flat index
High write rateBuilds are batch jobs measured in hours or daysIVF with a write buffer, or FreshDiskANN with a compaction budget
Sub-millisecond latency requiredAn SSD round trip is 100 µs and you need several roundsIn-memory anything
Network storage instead of local NVMeNetwork latency is 10–50× a local read, and the design is a latency budgetLocal NVMe, or a different architecture entirely
Highly selective filtersThe graph walk lands on ineligible nodes and stalls, and each stall cost a disk readPartition by the filter; brute-force the subset

Updates, and why the paper needed a sequel

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:

OperationWhy it is hard on a disk graphFreshDiskANN's answer
InsertRequires a search plus edge rewrites on neighbours — random writes across the SSDBuffer new points in a small in-memory Vamana index; search both and merge results
DeleteRemoving a node can strand the nodes that routed through itTombstone immediately; during the periodic merge, reconnect each deleted node's in-neighbours to its out-neighbours and re-prune
MergeRewriting a 772 GB file is not an online operationBackground compaction of the in-memory delta into the on-disk graph, at a cadence set by the write rate
If that architecture feels familiar, it should. A small fast in-memory structure absorbs writes; a large read-optimised structure on disk serves the bulk; a background process merges one into the other; reads consult both and combine. That is a log-structured merge tree, the design behind essentially every modern write-heavy storage engine. The vector-search community arrived at it independently, from the other direction, because the constraints are the same: random writes to a large disk-resident structure are unaffordable, and the only escape is to batch them.

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.

DiskANN against HNSW, honestly

HNSWDiskANN / Vamana
StructureHierarchical, multi-layerFlat, single graph
Entry pointTop-layer node, effectively random within itFixed medoid
Long edges come fromLayer assignment plus insertion orderα > 1 in RobustPrune, deliberately
Optimised forFewest distance computationsFewest dependent I/Os — low diameter
Typical degreeM = 16–48 (32–96 at layer 0)R = 64–128
Where vectors liveRAM, full precisionSSD full precision + RAM PQ codes
RAM for 109 × 128~768 GB~36 GB
Latency~0.1–1 ms~2–5 ms
Build timeMinutes at 106Days at 109
Best whenCorpus fits in RAMCorpus 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.

DiskANN uses α = 1.2 rather than α = 1 in RobustPrune. Why does the extra edge retention pay for itself specifically in the disk-resident setting?

Chapter 6: ScaNN — Quantize for the Decision, Not the Vector

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.

First, the objective this is really about

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

⟨q, x⟩ − ⟨q, x̂⟩ = ⟨q, r⟩

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.

The decomposition, built slowly

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.

The pivot, in one sentence. Errors on items that were going to lose do not matter; errors on items that were going to win decide the answer. So weight the quantization loss by how much a given error direction can change the score of a query that would have ranked this item highly. And the queries that rank x highly are the ones aligned with x. Therefore: the component of the residual parallel to x is dangerous, and the component perpendicular to x is nearly harmless.

Make that precise. Split the residual into its component along x and the rest:

r = ( ⟨r, x⟩ / ‖x‖2 ) x,   r = r − r,   r = r + r,   ⟨r, r⟩ = 0

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:

⟨q, r⟩ = ⟨q, r⟩ = ± ‖r

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.

Laniso(x, x̂) = h ‖r2 + h ‖r2,   with h > h

Set h = h and you recover ordinary k-means exactly, since ‖r2 + ‖r2 = ‖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?

Worked example 12 — two errors of identical size, one fatal

This is the whole paper in eight lines of arithmetic. Take a two-dimensional database point:

x = (3, 4),   ‖x‖ = √(9 + 16) = 5

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):

rA = x − x̂A = (−0.6, −0.8),   ‖rA‖ = √(0.36 + 0.64) = 1.0
rA = −0.2 · (3, 4) ⇒ exactly parallel to x: ‖r‖ = 1.0, ‖r‖ = 0

Candidate B — a pure sideways error. x̂B = (3.8, 3.4):

rB = x − x̂B = (−0.8, 0.6),   ‖rB‖ = √(0.64 + 0.36) = 1.0
⟨rB, x⟩ = (−0.8)(3) + (0.6)(4) = −2.4 + 2.4 = 0 ⇒ exactly perpendicular: ‖r‖ = 0, ‖r‖ = 1.0

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):

QuantityValueError 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.0000.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):

QuantityValueError vs truth
⟨q′, x⟩ = 0.8(3) − 0.6(4)2.4 − 2.4 = 0.000— (the truth)
⟨q′, x̂A2.88 − 2.88 = 0.0000.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 = η:

L(A) = η · 1.0 + 1 · 0 = η     L(B) = η · 0 + 1 · 1.0 = 1

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 systematic consequence, worth naming. Plain k-means tends to shrink reconstructions toward the centre of mass, because averaging shortens vectors — and shortening is a parallel error, exactly the fatal kind. Anisotropic quantization pushes back, effectively preserving the norms of database vectors better than their sideways positions. The resulting codebooks look visibly different from k-means codebooks: they spread out along the data's radial direction instead of packing tightly around cluster means.

Why the weighting grows with dimension

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‖.

roughly:  h/h grows with d and with the score threshold T

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.

Worked example 12b — the threshold, and why it sets the weight

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

⟨q(φ), x⟩ = 5 cos φ

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?
5.001.0000.000Yes — the best possible query for x
15°4.830.9660.259Yes
30°4.330.8660.500Probably
60°2.500.5000.866Unlikely
85°0.440.0870.996No — 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.

higher d, or higher T  ⇒  the surviving queries hug x's direction more tightly  ⇒  h/h should be larger

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.

What it does to the codebook

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.

Worked example 13 — a ranking that actually flips

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:

x = (3, 4), ‖x‖ = 5    y = (3.4, 4.2), ‖y‖ = √(11.56 + 17.64) = √29.20 = 5.404
q = (0.6, 0.8)

The true scores put y ahead:

⟨q, x⟩ = 1.8 + 3.2 = 5.000
⟨q, y⟩ = 0.6(3.4) + 0.8(4.2) = 2.04 + 3.36 = 5.400  →  y wins by 0.400

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:

x̂ = (3.6, 4.8),  rx = (−0.6, −0.8),  ‖rx‖ = 1, entirely parallel to x

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:

ŷ = (3.4 + 0.777, 4.2 − 0.629) = (4.177, 3.571),  ‖ry‖ = 1, entirely perpendicular to y

Both reconstructions carry exactly the same reconstruction error. Score them:

⟨q, x̂⟩ = 0.6(3.6) + 0.8(4.8) = 2.160 + 3.840 = 6.000
⟨q, ŷ⟩ = 0.6(4.177) + 0.8(3.571) = 2.506 + 2.857 = 5.363

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.

Why rescoring is not optional here

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.

Concept check. If reranking fixes ordering errors anyway, why not use the shortest possible codes and a very large shortlist r? … Because scanning is cheap and rescoring is not. Doubling r doubles the number of full-precision vectors you must fetch and score, and those fetches are random-access reads — the expensive operation. A better quantizer lets you hit the same recall with a smaller r, which is a saving on the expensive stage. The whole point of spending effort on the cheap stage is to shrink the work handed to the expensive one.

Worked example 13b — the reduction people try instead, and why it fails

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:

x̃ = ( x , √(M2 − ‖x‖2) ),   q̃ = ( q , 0 )

Every augmented database vector now has norm exactly M, because ‖x‖2 + (M2 − ‖x‖2) = M2. And so:

‖q̃ − x̃‖2 = ‖q‖2 + M2 − 2⟨q, x⟩

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

√(100 − 4) = 9.798    and    √(100 − 9) = 9.539

Let q be a unit vector with ⟨q, x1⟩ = 1.8 and ⟨q, x2⟩ = 1.5. The augmented squared distances are

to x̃1: 1 + 100 − 3.6 = 97.4    to x̃2: 1 + 100 − 3.0 = 98.0

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.

Why that is fatal, precisely. Any quantizer with a relative error above 0.6% destroys the ranking completely, and a PQ code at realistic compression is nowhere near that accurate. Any partition method sees points that are all nearly the same distance from everything and produces useless cells. The reduction is mathematically exact and numerically hopeless: it converts a discriminative problem into a near-degenerate one. Reductions that preserve the argmax do not necessarily preserve the structure an index depends on — which is exactly why ScaNN attacks MIPS natively, by changing the quantization objective, rather than transforming the data and reusing a Euclidean index.

What ScaNN is, as a shipped system

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.

1. Partition
A learned tree over the dataset — a coarse quantizer, trained with a MIPS-aware assignment rather than plain k-means. Only a few branches are searched per query. This is Chapter 2's idea with a better-chosen objective.
2. Score
Anisotropic product quantization with 4-bit codes, scored with in-register SIMD lookup tables (the LUT16 trick from Chapter 3). Hundreds of candidates per instruction.
3. Rescore
Take the top candidates and recompute exact inner products with full-precision vectors. Approximate for routing, exact for ranking — the same pattern as DiskANN, one more time.

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 rest of the system, briefly

The anisotropic loss is applied twice, at two granularities, which is easy to miss:

StageWhat is quantizedEffect of the anisotropic objective
Partitioning treeThe whole vector, into one of a few thousand branchesBranches are chosen so that the branch centre preserves scores, not coordinates — the routing decision itself becomes MIPS-aware
Residual PQ codesThe residual after the branch centre, into m 4-bit sub-codesThe main use, as derived above
RescoringNothing — full precisionUnaffected. 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.

The generalisable lesson

Quantize for the decision, not for the vector. Compression objectives are usually chosen for mathematical convenience (squared error has a closed-form optimum) rather than for the downstream task. Ask instead: which errors change my output, and which do not? Then weight the loss accordingly. The same reasoning shows up as task-aware post-training quantization for neural networks (minimise output KL, not weight error), as perceptual codecs in audio and images (spend bits where the ear and eye look), and as learned-index calibration. If you take one idea from this lesson into unrelated work, take this one.

"But my vectors are normalised" — does any of this apply?

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.

⟨q, x̂⟩ = ⟨q, x⟩ − ⟨q, r⟩,  and the radial part of r shrinks every score by roughly the same relative amount

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.

Concept check. Would re-normalising x̂ after decoding fix it? … Partly, and it is a real trick — but you never decode. The whole point of ADC is to score directly from lookup tables without reconstructing anything, and a per-vector normalisation constant cannot be folded into a sum of per-subspace table entries. You can store a scalar correction per code, which costs a byte per vector and is what some implementations do. Or you can train the codebook so the bias is small in the first place, which is what the anisotropic loss does at zero query-time cost.

Setting the weight in practice

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.

StepWhat to doWhy
1Pick T as the score of the k-th result on a sample of real queriesThe derivation conditions on "this item could be in the top k". T is that boundary, measured rather than guessed
2Compute 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
3Evaluate with recall@k against exact MIPS ground truth — not with reconstruction errorReconstruction error will get worse as η rises. That is the intended behaviour, not a regression
4Re-check after any embedding-model changeT 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.

The same idea, in four other places

"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.

DomainThe naive objectiveThe task-aware one
Neural network quantizationMinimise weight error ‖W − Ŵ‖2Minimise 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 codecsMinimise sample or pixel errorSpend bits where the ear and eye are sensitive; tolerate error where perception cannot detect it
Dimensionality reduction for retrievalMaximise 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 databasesMinimise error in the estimated row countMinimise the probability of choosing a different plan — being wrong by 2× matters only when it flips a join order
The question that unlocks all four rows: what decision does this approximation feed, and which errors can change that decision? Then weight the loss by the answer. It is a five-minute question that people routinely skip in favour of the objective with a closed-form solution — and the closed-form solution is optimal for a problem nobody has.

Where ScaNN fits — and where it does not

SituationDoes anisotropic quantization help?Why
Un-normalised MIPS, two-tower recommenderYes, stronglyThe exact setting it was derived for: norms carry signal and parallel errors distort them
Normalised cosine embeddings, high dYes, moderatelyAll 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)LessThe 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)LittleThe concentration argument that pins q near x's direction is weak, so the parallel and perpendicular weights converge
Long result lists, deep ranking tailsLessThe derivation conditions on a high score threshold; if you need item 5,000 ranked correctly, that conditioning is not valid
Two quantized reconstructions of x = (3, 4) each have reconstruction error exactly 1.0: A = (3.6, 4.8) and B = (3.8, 3.4). Why does anisotropic quantization prefer B?

Chapter 7: Benchmark Literacy

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?

The plot, and how to read it

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.

DatasetNdMetricCharacter
SIFT-128-euclidean1,000,000128L2Classic image descriptors; well clustered, forgiving
GloVe-100-angular1,183,514100cosineWord vectors; harder, and the usual leaderboard battleground
GIST-960-euclidean1,000,000960L2High dimension; where PQ and OPQ separate
Fashion-MNIST-78460,000784L2Small enough that brute force is competitive — a useful reality check
NYTimes-256-angular290,000256cosineSparse-ish text features
DEEP1B (big-ann)10996L2The 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.

The single rule that prevents most benchmark mistakes: compare curves, never points. An index is a family of operating points, not a performance number. "Our index does 12,000 QPS" is meaningless without the recall it achieved, and "our index reaches 0.99 recall" is meaningless without the QPS it cost. Any claim quoting one without the other is either careless or deliberate.

How to actually read two curves:

What you seeWhat it meansWhat to do
Curve A is above curve B everywhereA genuinely dominates on this datasetUse A, subject to the invisible axes below
The curves crossA 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.95That configuration cannot reach higher recall at any knob setting — usually a quantization ceilingTreat it as disqualified if you need high recall, or add reranking
A curve is nearly vertical at the rightThe last few points of recall cost enormous throughputThis is normal and it is where you will live. Budget for it
Only one point is plottedSomeone is selling you somethingAsk for the sweep

Three deliberate choices in the standard protocol

ann-benchmarks makes three decisions that surprise people, and each one is defensible and consequential.

ChoiceRationaleWho it penalises
Single-threaded, one query at a timeRemoves the confound of threading quality and machine size, so the comparison is of algorithms rather than engineering teamsAnything 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 curveAn index is a family; a single point is not a measurementNobody — this is simply correct, and it is the norm this field should be proudest of
Containerised per algorithmEach implementation gets its own dependencies and build flags, so nobody loses to a packaging problemNobody, 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.

The axes the plot does not have

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.

Recall definitions, one more time, because this is where papers cheat

NotationQuestion it asksStrictness
10-recall@10How many of the true top 10 are in my 10 results?Strict. The honest default
1-recall@10Is the single true nearest neighbour anywhere in my 10?Much easier — often 10–20 points higher on the same run
1-recall@1Did I return exactly the right item?Strict, and the standard for the billion-scale tracks
10-recall@100Are 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.

Worked example 14 — when brute force wins, calculated

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:

texact = N × d × 4 bytes ÷ bandwidth

Take a single modern core streaming at roughly 20 GB/s, and a whole server at 200 GB/s:

NdBytes scanned1 core @ 20 GB/sServer @ 200 GB/sVerdict
10,00076830.7 MB1.5 ms0.15 msIndex it and you have added a dependency for nothing
100,000768307 MB15 ms1.5 msBrute force is fine for most products
1,000,0007683.07 GB154 ms15 msThe crossover. Depends on your budget
1,000,000128512 MB26 ms2.6 msStill arguable at low d
10,000,00076830.7 GB1.5 s154 msBuild 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

3.07 × 109 bytes ÷ 1.5 × 1012 B/s ≈ 2 ms

— 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.

The decision rule, memorisable. Compute N · d · 4 / bandwidth. If it fits inside your latency budget with room to spare, do not build an index. You are trading perfect recall, zero build time, free deletes, free filters, and zero tuning for a speedup you did not need. The most common architectural mistake in retrieval systems is reaching for a vector database at 50,000 documents.

The trap benchmarks hide completely: filtered search

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:

StrategyHowGood whenFails when
Pre-filterMaterialise the matching subset and brute-force its is small — a 0.1% subset of 10M is 10,000 vectors, which Worked Example 10 says is 1.5 mss is large; you are back to a full scan
Post-filterSearch for k′ > k, then drop non-matching resultss is large — almost everything passess is small; see the arithmetic below
Filter-aware indexPush the predicate into traversal (ACORN, filtered-DiskANN, per-partition indexes)Filters are known in advance or low-cardinalityArbitrary 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

100 × 0.001 = 0.1 results

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.

Measuring throughput without fooling yourself

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.

MistakeWhat it inflatesThe fix
Measuring a cold indexLatency, badly — every hop is a page faultRun a few thousand warm-up queries and discard them
Querying with vectors from the indexRecall, enormously — the answer is at distance zero and every method finds itHold out queries that were never added
Reusing one queryEverything — the whole working set is in cache after the first callCycle through thousands of distinct queries
Reporting 1/mean as QPS under concurrencyThroughput, by ignoring contentionMeasure completed queries per second at your real thread count
Comparing across machinesEverything, silentlyOne 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 is a proxy. Sometimes measure the thing itself

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.

MetricMeasuresUse it when
recall@k vs brute forceIndex fidelity to exact searchTuning the index. Fast, cheap, fully automatic
nDCG / MRR vs human labelsEnd-to-end retrieval qualityComparing embedding models, or deciding whether a recall drop actually hurt
Answer accuracy of the full pipelineWhat the user experiencesThe only metric that settles a product argument. Slow and expensive
Click-through / dwell in productionRealityThe final word, and the slowest loop
The relationship between the top two rows is the useful part. Measure both once, on the same sweep, and you learn how many points of nDCG a point of recall is worth on your data. Sometimes the answer is "almost none" — because the items you dropped at recall 0.93 were near-duplicates of items you kept — and you can then run a far cheaper index with confidence. Sometimes it is "one for one," and you now have a numeric case for the memory budget. Either way it converts a recall target from a superstition into a measurement.

Ground truth for filtered queries, which nobody computes and everybody needs

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:

StepWhat to do
1Sample 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
2For each (query, predicate) pair, brute-force the top k within the matching subset. This is cheap precisely because the subsets are small
3Measure recall of your production path against that, bucketed by selectivity
4Plot 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.

Concept check. Where does the estimated matching-set size come from? … From the same place a database gets it — cardinality statistics on the filter columns, maintained as the corpus changes. You have just rebuilt a query planner, which is the bridge this lesson closes with. If you already have a relational store holding the metadata, ask it for the count first; that is frequently cheaper and always more accurate than estimating.

Sim — the tuning playground

nprobe / ef tuning — read the curve, pick the point

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.

IVF-PQ nprobe 8
HNSW ef 64
Corpus size N 1M

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.

Why public datasets flatter every algorithm

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.

PropertySIFT / GloVeA 2020s text encoderConsequence
Dimension96–128768–3072Everything memory-bound gets 6–24× worse; PQ's d* choice changes entirely
ClusteringStrongly clustered by constructionOften much flatter after normalisation, especially post-contrastive trainingIVF's cells are less selective; recall at a given nprobe drops
Intrinsic dimensionLow — a manifold inside the ambient spaceHigher, and rising with model qualityEvery method's constants get worse; the honest fix is a shorter vector, not a cleverer index
StabilityFrozen foreverReplaced whenever the model is upgradedBuild time and rebuild cadence become first-class, and no benchmark measures them
DuplicatesDeduplicatedFull of near-identical boilerplateHuge, 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.

The concrete instruction. Never choose an index from a published plot. Use the published plots to decide which two or three families are worth testing, then run your own sweep on your own vectors with your own filters. The ranking of algorithms is stable enough that the shortlist transfers; the numbers are not, and the crossover points are not.

The three plots to make, and what each one answers

PlotAxesThe question it settles
1. The frontierrecall (x) vs QPS, log (y)Which family, and at what knob setting, for my recall target
2. The memory frontierrecall (x) vs bytes per vector (y)What my recall target costs in machines — the plot public benchmarks never draw
3. The latency histogramlatency (x) vs count (y), per configurationWhether 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.

Reading four curves, in order

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.

CandidateCurve shapeWhat you conclude, and when
A: HNSW M=32Highest QPS everywhere, reaches 0.99, stops thereThe frontier. But look up its bytes/vector before celebrating — on a 100M corpus this row may be unaffordable
B: IVF-PQ 32 BRises fast, flattens hard at 0.86, never moves againA 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 500Same left half as B, then keeps climbing to 0.98 with a visible cost stepThe 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-FlatCrosses A: better below 0.9, worse aboveThe 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.

A recall regression, debugged

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:

#CheckWhat a "yes" means
1Did 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
2Did the embedding model or its preprocessing change?Everything downstream is invalid: centroids, codebooks, graph. Rebuild, do not tune
3Is normalisation still applied on both sides?A query normalised and a corpus not (or vice versa) silently converts cosine into something meaningless
4Has 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
5Has the corpus grown a lot since the last training?Cells and codebooks have drifted. Retrain the coarse quantizer
6Has the corpus gained many near-duplicates?One cell is now enormous; recall and p99 move together. Deduplicate
7Are queries now hitting a filter path that was not there before?Post-filtering with new selectivity. Chapter 7's arithmetic, arriving in production
8Is 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
Notice that none of the first seven checks are about the algorithm. Recall regressions are almost always data, configuration, or measurement problems. The index is the part with four peer-reviewed papers behind it; the pipeline around it is the part somebody wrote on a Thursday. Investigate in that order.

One more trap: the k inside your ground truth

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.

A cheap habit that pays repeatedly: store ground truth as (ids, distances) rather than ids alone. It makes tie-tolerant recall possible, it lets you compute the distance-ratio metric from Chapter 1 for free, and it lets you detect a corpus change immediately — if the distances shift, the data moved, and any recall comparison against the old truth is meaningless.

A benchmarking checklist you can actually run

#StepWhy it is on the list
1Use your vectors and your queriesSIFT and GloVe are well-behaved in ways your embeddings may not be. Distribution shape decides everything
2Compute ground truth by brute force, with the deployed metric10,000 held-out queries is plenty and takes minutes
3Sweep the runtime knob over at least six settingsYou are measuring a curve; six points is the minimum that shows a shape
4Report recall at the shortlist size if a reranker followsOtherwise you will condemn a perfectly good index
5Record build time and peak build memoryThey decide your rebuild cadence and your machine size
6Measure p50 and p99 under the concurrency you will actually serveSingle-thread QPS does not predict a loaded server
7Include brute force as a baseline rowRoughly one time in three it wins, and you want to find that out now
8Test with your real filters appliedThe most common production surprise, and invisible in every public benchmark
You serve 10 million vectors and a query filters to a tenant holding 0.05% of them. Post-filtering an HNSW search with k′ = 200 returns almost nothing. What is the right fix and why?

Chapter 8: The Composed Stack

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.

The pattern, stated once

Approximate for routing, exact for ranking — applied recursively. At every level, use the cheapest scoring function that is good enough to choose where to look, and reserve expensive scoring for the small set you have narrowed to. You have now seen this three times independently: PQ's ADC shortlisting before float reranking (Chapter 3), DiskANN's in-memory codes routing SSD reads (Chapter 5), ScaNN's 4-bit scoring before exact rescoring (Chapter 6). It is not a coincidence; it is the design principle of the field.

The canonical five-stage stack

Stage 0 — embed
Encode with a model. If it was trained with Matryoshka representation learning, you can truncate the vector to a prefix — 3072 → 256 dims — and keep most of the accuracy, which is a 12× memory cut before any index exists.
↓ N candidates
Stage 1 — partition
IVF with nlist cells, or ScaNN's learned tree. Reduces the candidate set by 100–1000× for the cost of a small exact search over centroids — itself often an HNSW graph.
↓ ~0.1–1% of N
Stage 2 — compressed scan
OPQ rotation, then PQ or anisotropic PQ with 4-bit codes and SIMD lookup tables. Scores thousands of candidates from an 8–64 byte-per-vector stream that stays in cache.
↓ r ≈ 10–50× k
Stage 3 — exact rerank
Fetch full-precision vectors for the shortlist — from RAM, SSD, or object storage — and compute true distances. This is what breaks the quantization ceiling.
↓ k results
Stage 4 — semantic rerank
Optionally, a cross-encoder that reads the query and each candidate together. Orders of magnitude more expensive per item and far more accurate — affordable only because stages 1–3 delivered 50 items instead of 109.

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.

The funnel, with real numbers on every arrow

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.

StageItems inItems outCost per itemStage cost
Coarse quantizer (HNSW over 65,536 centroids)65,53616 cells~40 graph hops total~0.1 ms
PQ scan of the probed lists~24,40050064 lookups + adds, 64 B streamed~1.5 ms
Exact rerank on full vectors50050768 multiply–adds + a 3 KB fetch~2.0 ms
Cross-encoder rerank5010A transformer forward pass over query + document~25 ms
Whole pipeline10810~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 funnelDiagnosisFix
One stage costs 10× the othersIts width is wrong for its per-item costNarrow its input, or make it cheaper
Rerank changes almost nothing about the final orderThe previous stage was already accurate enoughShrink the shortlist, or drop a stage
Rerank changes the order completelyThe previous stage's ranking is noiseWiden the shortlist — the winner may not even be reaching the reranker
The first stage dominatesClassic coarse-quantizer growth from Chapter 2Put a graph index over the centroids
The single most useful diagnostic in a retrieval pipeline. Measure recall of the shortlist at every stage boundary — how often is the true best item still present after stage 1, after stage 2, after stage 3? The stage where it disappears is your bug, and it is very often not the one anybody suspected. Systems are usually tuned by staring at final output quality, which cannot distinguish "the reranker is bad" from "the reranker never saw it."

Reading a FAISS index string, which is the real literacy test

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:

OPQ64_256,IVF65536_HNSW32,PQ64x4fsr
TokenMeaningChapter
OPQ64_256Learn an orthogonal rotation, and also project to 256 dimensions, arranged for 64 subquantizers — balancing variance across the chunks that PQ will use3
IVF65536Coarse partition into 65,536 Voronoi cells. Suits N around 108 by the √(nprobe · N) rule2
_HNSW32The 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 scan4 inside 2
PQ6464 subquantizers over the 256 rotated dimensions — so d* = 4 dimensions per chunk3
x44 bits per sub-code (k* = 16), giving 64 × 4 = 256 bits = 32 bytes per vector3
fs"Fast scan" — codes packed so the lookup tables fit in SIMD registers and are applied with shuffle instructions3
rRefine: keep full or higher-precision vectors and rerank the shortlist exactly3, 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.

Three more strings, for practice

Decoding index strings is the fastest way to check whether the composition really landed. Cover the right column and work through these.

StringWhat it builds, and when you would want it
FlatExact brute force. No training, no parameters, perfect recall. The baseline every other row must beat, and the right answer below about 105 vectors
HNSW32A 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,FlatPartition 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,PQ32Rotate 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,SQ8PCA 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.

Worked example 15 — the same corpus, costed three ways

100 million vectors from a modern text encoder, d = 768, cosine, target recall@10 ≥ 0.95, 500 QPS.

Option A — pure HNSW in RAM.

vectors: 108 × 768 × 4 = 307 GB
graph at M = 32 (64 links × 4 B): 108 × 256 B = 25.6 GB
total ≈ 333 GB → a 384 GB instance

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:

codes: 108 × 64 B = 6.4 GB
ids (int32): 108 × 4 B = 0.4 GB
centroids for nlist = 65,536 at d = 256: 65,536 × 256 × 4 = 67 MB
total resident ≈ 7 GB → a 16 GB instance

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

307 GB ÷ (4 × 1.5 TB/s) ≈ 51 ms per query — and only ~20 QPS

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: HNSWB: OPQ+IVFPQ+rerankC: GPU flat
Resident memory333 GB7 GB307 GB across devices
Latency1–2 ms3–6 ms~51 ms
Recall@100.97+0.95 with rerank1.000
Annual compute~$35,000~$2,200> $100,000
BuildHoursUnder an hourNone
DeletesPainfulEasyTrivial

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.

Cost per query, in actual money

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.

ComponentThroughput per unitUnit cost$ per million queries
ANN search (16 GB instance)500 QPS$0.25/hr$0.14
Rerank fetch (NVMe, 500 reads/query)bundledbundled~$0.05
Query embedding (small encoder, accelerator)2,000 QPS$1.00/hr$0.14
Cross-encoder rerank, top 5040 QPS$1.00/hr$6.94
Generation, if this feeds a language model$100–$2,000
Read the column downward and the priorities reorder themselves. The vector index is fourteen cents per million queries. The cross-encoder is fifty times that. If a language model reads the results, it is another one to four orders of magnitude beyond. Halving your ANN cost saves seven cents per million queries; dropping the cross-encoder from 50 candidates to 20 saves four dollars. This does not make the index unimportant — without it the first row is not $0.14 but the cost of a 15,000-machine fleet from Chapter 0. It makes the index a solved problem whose solution should be chosen quickly and correctly and then left alone.

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.

GPU graph indexes: CAGRA

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.

The write path, which is half the system and gets a tenth of the attention

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.

1. Change capture
A document is created, edited, or deleted. You need a durable log of these — not a nightly diff — or you will never be able to reason about staleness.
2. Chunk and embed
The expensive step, and the one that decides throughput. Batch aggressively: a GPU embedding 512 chunks at once is 30–50× more efficient per chunk than one at a time.
3. Buffer
New vectors land in a small, mutable index — flat or IVF-Flat — that is searched alongside the main one. This is what makes writes visible in seconds instead of hours.
4. Compact
Periodically fold the buffer into the main index and drop tombstones. Nightly for most systems; hourly if the buffer grows fast enough to slow queries.
5. Full rebuild
On model upgrade, or when centroids have drifted. Re-embed everything, retrain, rebuild, validate against the golden set, then swap atomically.

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.

Worked example 16 — a capacity plan on one page

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.

DecisionChoiceArithmetic
DimensionTruncate 768 → 256 (Matryoshka)3× on every downstream number, for ~1–2 points of nDCG
Index familyOPQ + IVF-PQ + rerank2 × 109 is far past HNSW's single-machine ceiling
Code sizem = 64 bytes2 × 109 × 64 B = 128 GB of codes
Idsint32 positional2 × 109 × 4 B = 8 GB. int64 would have cost 16 GB
Shards4, sharded by tenant136 GB / 4 = 34 GB per shard — fits a 64 GB machine with headroom
nlist per shard65,536√(nprobe · N) with N = 5 × 108, nprobe = 8 → 63,246
Coarse quantizerHNSW32 over the centroids65,536 × 256 = 16.8M multiply–adds becomes a ~40-node walk
Rerank sourceFull 768-d vectors on NVMe2 × 109 × 3,072 B = 6.1 TB across 4 nodes = 1.5 TB each
Replicas2 per shard8 machines total, for availability and for hedging the tail
Query fan-out1 shard per queryBecause we sharded by tenant — the p99 is one machine's p99, not the max of four
Embedding fleetSized for the monthly refresh, not the write rate2 × 109 chunks / month = 772 chunks/s sustained. This is the largest line item
Read the last row again. The embedding fleet, sized by the refresh cadence, costs more than every search machine combined. This is the normal outcome and it is the reason the index-choice debate is usually not the most valuable conversation in the room. Chapter 0's arithmetic tells you an index is necessary; this table tells you it is not where the money goes.

Choosing, as a decision procedure

SituationIndexWhy
N < 105, any dFlat / brute forceUnder a few ms exact. Perfect recall, no build, free deletes and filters
105–107, RAM is fine, mostly staticHNSWBest recall/latency frontier when everything fits. One knob
105–107, heavy churnIVF-Flat or flat with a write bufferCheap inserts and real deletes; graphs hate mutation
107–109, memory constrainedOPQ + IVF-PQ + rerank16–64 B/vector puts a billion points on one small machine
≥ 109, one node, SSD availableDiskANN~36 GB RAM for a billion points; latency in single-digit ms
MIPS, un-normalised, throughput criticalScaNNAnisotropic loss targets the score, not the coordinates
Batched queries, GPU present, rebuilds frequentCAGRAParallel build and batch search; convert to HNSW to serve on CPU
Highly selective metadata filtersPartition by the filter, then any of the aboveAn index per tenant beats a filter-aware traversal almost every time
You do not know yetFlat, and measureThe baseline you must beat, and it is free to build

The millisecond budget of a real query

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:

Stagep50p99Notes
Request parse, auth, routing1 ms4 msFixed overhead nobody profiles
Embed the query8 ms25 msA transformer forward pass. Usually the single largest term
Coarse quantizer (HNSW over centroids)0.1 ms0.3 msNegligible, and it is what Chapter 2 worried about
PQ scan of nprobe lists1.5 ms6 msThe part everybody tunes
Fetch 500 full vectors + rerank2 ms9 msDominated by fetch, not arithmetic
Fetch document text3 ms20 msA different datastore, a different tail
Cross-encoder rerank, top 5025 ms60 msIf present, this dwarfs everything else
Total~41 ms~124 ms
The uncomfortable reading. The ANN scan is 1.5 of 41 milliseconds — under 4% of the request. Halving it saves 0.75 ms. Meanwhile the query embedding costs 8 ms and the cross-encoder 25 ms, and neither is usually where the tuning effort goes. This is not an argument that indexes do not matter: the index is what makes the corpus affordable, and Chapter 0's arithmetic is unchanged. It is an argument that once your index is in the right family, further index tuning has a small ceiling, and the next hour is better spent on a smaller embedding model, a batched embedding service, or a cheaper reranker. Profile before optimising, including the parts that are not yours.

Hybrid retrieval, because dense search alone is not the system either

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:

RRF(doc) = ∑systems s 1 / (K + ranks(doc)),   K ≈ 60

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 A: 1/61 + 1/90 = 0.01639 + 0.01111 = 0.02750
doc B: 1/68 + 1/68 = 0.01471 + 0.01471 = 0.02942

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.

What breaks first, as you scale

A rough ladder. Each rung is where a real system typically stops working, and the fix is usually architectural rather than a parameter.

ScaleWhat breaksThe move
104 vectorsNothingA numpy array. Resist everything else
105Still nothing, if you batchStill a numpy array, or a flat index in your database
106p99 latency on an interactive pathHNSW. One machine, one knob
107Rebuild time, and RAM at high dTruncate the dimension; consider IVF for cheaper builds
108RAM, decisivelyOPQ + IVF-PQ with a rerank tier
109One machine cannot hold even the codes at high dDiskANN, or shard — and shard by a filter dimension if you possibly can
Any scale, high write rateBuild time exceeds the refresh intervalWrite buffer plus periodic compaction. The LSM pattern
Any scale, selective filtersRecall, silently, on the filtered path onlyPartition by the filter; brute-force small subsets
Any scale, model upgradesEmbedding throughput, for weeksSize the embedding fleet by the refresh cadence, not the write rate

Operational realities the papers do not cover

ConcernWhat actually happensWhat to do about it
ShardingEvery query fans out to every shard and waits for the slowest; p99 degrades with shard countShard by a filter dimension so a query touches one shard, not by hash
ReplicationStraightforward — indexes are read-only artefactsBuild once, ship the file, hedge slow requests to a replica
Rebuild cadenceCentroids and codebooks drift as content changes; recall decays silentlyTrack recall against a fixed golden query set on every deploy
Model upgradesA new embedding model invalidates every vector, every centroid, every codeBudget a full re-embed and rebuild as a routine event, not an incident
WarmupA cold mmap'd index page-faults on nearly every hopTouch the index before taking traffic; never serve from cold storage
Idsint64 ids are half the memory of an 8-byte PQ indexUse int32 or positional ids; keep the mapping outside the index
EvaluationNobody notices recall regressions because nothing measures themA golden set with brute-force ground truth, recomputed on every rebuild
The most valuable thing to build first is not an index. It is the measurement harness. Ten thousand held-out queries, brute-force ground truth, and a script that prints recall and p99 for a swept knob. It takes an afternoon, it makes every subsequent decision empirical instead of rhetorical, and it is the only thing that will tell you your recall dropped six points after last week's model upgrade.
Decode OPQ64_256,IVF65536_HNSW32,PQ64x4fsr: how many bytes per vector does the compressed payload occupy, and what is the HNSW graph doing?

Chapter 9: Connections and Cheat Sheet

Four papers, fourteen years, one problem. Here is everything on one page, followed by where each thread continues.

The four papers, in one table each

PaperThe one ideaWhat it costsStill used?
PQ — Jégou, Douze & Schmid, TPAMI 2011The representable set is the Cartesian product of m small codebooks, so it multiplies while storage adds. 264 reconstructions from 128 KBCannot represent correlations across chunks; imposes a hard recall ceilingUniversally. Every large index has PQ or a descendant inside it
HNSW — Malkov & Yashunin, 2016 / TPAMI 2018Separate link scales onto layers, as a skip list does; greedy descend, then beam search at the bottomFull vectors must be RAM-resident; slow build; deletes are hardThe default in-memory index across the whole ecosystem
DiskANN — Subramanya et al., NeurIPS 2019PQ codes in RAM for routing, full vectors and adjacency co-located on SSD for the answer; α-pruning for low diameterMulti-day builds; harder to mutate; latency in ms not µsYes — the standard answer for a billion points on one node
ScaNN — Guo et al., ICML 2020Weight the quantization loss by error direction: parallel errors distort scores, perpendicular ones mostly do notThe derivation conditions on a high score threshold; weaker in low d or for deep tailsYes, and the idea travels far beyond ANN

The lesson in ten paragraphs, one per chapter

ChThe one thing
0Exact 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
1Recall, 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
2IVF 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
3Product 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
4HNSW 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
5On 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
6Reconstruction 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
7Compare 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
8Nobody 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
9All four papers do the same thing: find the scarce resource, and restructure the problem so it is spent only where it changes the answer

The symbol table

SymbolMeaningTypical
NCorpus size105–1010
dVector dimension96–3072
kResults requested10–100
rShortlist size before reranking10–50× k
nlistIVF cells (build time)√(nprobe · N)
nprobeIVF cells scanned (query time)1–128
mPQ subquantizers8–64, must divide d
d* = d/mChunk dimension4–32
k*Centroids per sub-codebook256 (8-bit) or 16 (4-bit SIMD)
MHNSW links per node per layer16–48; layer 0 gets 2M
efHNSW query beam widthk to 1000
efConstructionHNSW build beam width100–500
mLHNSW layer scale1/ln(M)
RVamana max out-degree64–128
αVamana prune permissiveness1.2
WDiskANN beam width (I/Os per round)2–8
h, hScaNN parallel / perpendicular loss weightsratio 2–10, grows with d
sFilter selectivitythe number that decides pre- vs post-filter

The equations that carry the weight

(1)  brute-force floor:  t = N · d · 4 / bandwidth
the one division that decides whether you need an index at all
(2)  IVF cost:  C = d · nlist + d · nprobe · N / nlist,  minimised at nlist = √(nprobe · N)
the √N rule, derived rather than recited
(3)  PQ code:  code(x) = (i1, …, im),  ij = argmini ‖x(j) − ci(j)2
(k*)m reconstructions from k* · d floats of codebook
(4)  ADC:  d̂2(q, x) = ∑j Tj[ ij ],  Tj[i] = ‖q(j) − ci(j)2
m lookups and m−1 adds per candidate; zero multiplications
(5)  ADC bias:  E[d̂2(q,x)] ≈ d2(q,x) + E[‖x − x̂‖2]
a constant offset — harmless for ranking; its variance is the recall ceiling
(6)  HNSW layers:  P(level ≥ ℓ) = M−ℓ,  mL = 1/ln M
O(log N) layers; N/(M−1) nodes above layer 0
(7)  HNSW / Vamana prune:  drop v if α · d(p*, v) ≤ d(p, v)
α = 1 is the diversity heuristic; α = 1.2 keeps long edges and lowers the diameter
(8)  anisotropic loss:  L = h ‖r2 + h ‖r2,  h > h
equal weights recovers k-means exactly
(9)  disk throughput:  QPS ≈ drive IOPS / reads per query
600k / 120 ≈ 5,000 — DiskANN's headline, from a division

The numbers worth remembering

NumberWhat it is
3.07 TBA billion 768-dimensional float32 vectors
15.4 sOne exact query over them, bandwidth-bound at 200 GB/s
1/√dStandard deviation of the cosine between random unit vectors — 0.036 at d = 768
√(nprobe · N)Optimal nlist
39–256Training 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 KBThe whole PQ codebook at k* = 256, d = 128 — independent of m
264Reconstructions from that 128 KB
644 B/vectorHNSW at M = 16, d = 128: 512 payload + 132 graph
~36 GBDiskANN's RAM for a billion 128-dimensional points
~100 µsA random 4 KB NVMe read — the unit DiskANN economises
α = 1.2Vamana's pruning permissiveness: keep the direct edge unless the detour saves 20%
0.903 = 0.729Why 90% recall is not 90% in a three-chunk RAG pipeline
N · d · 4 / bandwidthThe calculation that tells you not to build an index

Glossary — every bolded term, in one line each

TermOne lineChapter
Bandwidth-boundLimited by bytes delivered from memory per second, not by arithmetic. Adding cores does not help0
Distance concentrationIn high dimensions almost all pairwise distances are nearly equal; the spread of random cosines is 1/√d0
MIPSMaximum inner product search. Not a metric — no triangle inequality, and longer vectors win0
Locality-sensitive hashingData-independent hashing where collision probability decreases with distance. Provable, and beaten badly on real data0
recall@kFraction of the true top-k that the index actually returned, averaged over queries1
Operating pointOne (recall, QPS) pair. An index is a curve of these, traced by its runtime knob1
Quantization ceilingThe recall no amount of search effort can exceed, set by how short the codes are1, 3
Coarse quantizerThe small set of centroids whose Voronoi cells define an IVF partition2
Inverted list / posting listThe vectors filed under one centroid. Named after the text-search structure it copies2
Voronoi cellThe region of space closer to one centroid than to any other2
nprobeHow many cells a query opens. The query-time knob of IVF2
Residualx minus its cell centroid. What IVF-PQ actually encodes, because it has far less variance2
CodebookThe finite set of centroids a quantizer maps into3
Product quantizerm independent sub-quantizers over disjoint chunks; the representable set is their Cartesian product3
ADCAsymmetric distance computation — full-precision query against quantized database, via lookup tables3
SDCSymmetric variant that quantizes the query too. Strictly worse for ranking3
OPQA learned orthogonal rotation applied before PQ, balancing variance across chunks. Free at query time3
Fast scan / LUT164-bit codes whose lookup tables fit in SIMD registers, scored with shuffle instructions3, 6
Skip listSorted list plus probabilistic express lanes; the structural ancestor of HNSW's hierarchy4
Greedy routingRepeatedly step to the neighbour closest to the query; stop when none improves4
Navigable small worldA graph with short-range links for accuracy and long-range links at every scale for reachability4
Delaunay graphThe graph on which greedy routing is provably correct, and whose degree explodes with dimension4
ef / beam widthHow many candidates stay alive during search. What buys escapes from local minima4
Diversity heuristicAccept a neighbour only if it is closer to me than to anything already accepted — preserves cross-cluster bridges4
VamanaDiskANN's flat graph construction, with the α parameter controlling how permissively edges survive pruning5
MedoidThe point minimising total distance to all others; Vamana's fixed entry point5
Dependent I/OA read whose address is unknown until the previous read completes. The unit DiskANN economises5
Anisotropic quantizationWeighting the quantization loss by error direction, punishing errors parallel to the data vector6
SelectivityFraction of the corpus passing a metadata filter. Decides pre- versus post-filtering7
Reciprocal rank fusionCombining rankings by summing 1/(K + rank), needing no score calibration8

What to memorise, and what to look up

MemoriseLook up
N · d · 4 / bandwidth — the do-I-need-an-index calculationAny specific library's parameter names
Quantization sets a ceiling; effort climbs toward itThe exact recall a given m achieves on a given dataset
nlist ≈ √(nprobe · N)Training-sample-size recommendations
PQ: (k*)m reconstructions from k* · d floatsOPQ's optimisation procedure
ADC is m lookups and no multiplicationsSIMD packing layouts
ef and nprobe are query-time; M, m, nlist are build-timeDefault values in any particular engine
On disk, count dependent round trips, not distance computationsA specific drive's IOPS rating
Errors parallel to x hurt MIPS; perpendicular ones mostly do notThe closed-form weights as a function of T and d
Compare curves, never pointsWhich dataset a published curve used
Recall compounds: 0.93 = 0.729Your 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.

The failure modes, in one place

FailureWhich structureRoot causeChapter
True neighbour sits in an unprobed cellIVFThe query is near a Voronoi boundary2
Two distinct vectors share a code and cannot be orderedPQFinite codebook; the ceiling3
Distances estimated uniformly too highPQ / ADCThe estimator's constant bias; harmless for ranking3
Greedy walk stalls in a local minimumGraph, ef = 1The graph is not Delaunay; some directions are missing4
Whole clusters unreachableGraphNeighbours chosen by distance rather than diversity4
Latency dominated by idle waitingDisk graphDependent I/O with beam width 15
Top-1 wrong far more often than mean error suggestsQuantized MIPSParallel residual inflating a contender's score6
Empty result pagesAny, post-filteredLow selectivity multiplied by a small candidate count7
Recall decays silently over monthsIVF / PQCentroids and codebooks trained on a distribution that moved2, 8
p99 balloons after shardingAny, fanned outThe request waits for the slowest of S shards1, 8

The knob cheat sheet

SymptomMost likely causeFirst thing to try
Recall plateaus and more search effort does nothingQuantization ceilingBigger m, or add exact reranking on a shortlist
Recall is fine but latency is highSearch effort too generousLower ef / nprobe until recall starts to move
Recall collapsed after a data refreshStale centroids or codebooksRetrain the coarse quantizer; check the golden set
Recall is fine offline, bad in productionFiltersMeasure with real predicates; consider pre-filtering
p99 is 10× p50Fan-out across shards, or cold pagesWarm the index; shard by filter, not by hash; hedge requests
Build takes longer than the refresh intervalWrong index family for your churnIVF instead of a graph, or a write buffer merged periodically
Memory is the binding constraintStoring float vectors you never need at full precisionPQ the payload, keep floats on SSD for rerank only
Everything works but it feels over-engineeredIt probably isCompute N · d · 4 / bandwidth and consider deleting the index

Nine things that are widely believed and wrong

BeliefWhat 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

Where each thread continues

If you want…Go to
The systems layer around these indexes — storage, filtering, hybrid searchVector databases
Where the vectors come from in the first placeVector embeddings and embedding layers
The metric choices this lesson assumedSimilarity metrics
The k-means that trains every coarse quantizer and codebookk-means
Truncatable embeddings — the cheapest memory win before any indexMatryoshka representation learning
Running all of this on a phone, where the memory budget is the whole storyOn-device embeddings
The pipeline these indexes feedRAG and multimodal RAG
Retrieval models whose scores you are indexingDense passage retrieval, ColBERT, E5
Learning the quantizer and the retriever togetherJoint search and quantization
How to know your retrieval actually improvedEmbedding benchmarks
The inverted index this all borrows its name fromStorage and retrieval and hash tables

Build it yourself — the weekend recipe

StepDo thisThe decision that matters
1. BaselineWrite the numpy one-liner and time it on your real corpusIf it is fast enough, stop here. Roughly a third of the time it is
2. Ground truthBrute-force the true top-100 for 10,000 held-out queriesUse the metric you will actually deploy, on normalised vectors if that is what you serve
3. First indexfaiss.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 curvesWatch the ceiling appear; note the memory you just saved
5. RerankTake the top 500 by code, rescore with float vectorsThis is where the ceiling disappears. Measure recall at 500, not at 10
6. Hand-encodeImplement PQ encode and the ADC table in twenty lines of numpyReproduce Worked Example 6's 0.06 + 0.07 = 0.13 exactly. Nothing else teaches it
7. FiltersRe-run every measurement with your real predicates appliedCompute the selectivity first; it usually decides the architecture
8. HarnessWire steps 2–3 into CIRecall regressions are silent. This is the only thing that makes them loud

How to read the four papers

All four are short. If you read them in this order, each one answers a question the previous one raised.

OrderPaperRead it forSkim
1Jé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 fieldThe IVFADC experiments, unless you are reproducing them
2Malkov & Yashunin, HNSW (2016)Algorithms 1–5, especially Algorithm 4 (the diversity heuristic) which the abstract does not mention and which is where the quality livesThe complexity analysis on first pass; come back to it
3Subramanya et al., DiskANN (2019)The RobustPrune definition and the disk-layout section. Read them together — neither makes sense aloneThe comparison tables, which have aged
4Guo et al., ScaNN (2020)Sections 3–4: the loss decomposition and the closed-form weights. The idea is portable far outside searchThe 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.

The first three weeks, if you are building this now

WeekDoYou will learn
1Brute force, ground truth for 10,000 held-out queries, and the sweep harness in CIWhether you need an index at all, and what your data actually looks like — duplicates, dimension, clustering
2HNSW and OPQ+IVF-PQ+rerank, swept, plotted on recall/QPS and recall/bytesWhich family, at what cost, at your recall target. Usually the decision is obvious once both plots exist
3Re-run everything with real filters, real concurrency, and a simulated model upgradeThe 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.

References

  1. Jégou, H., Douze, M., Schmid, C. "Product Quantization for Nearest Neighbor Search." IEEE TPAMI 33(1):117–128, 2011. The origin of everything in Chapter 3.
  2. Malkov, Y. A., Yashunin, D. A. "Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs," 2016 — arXiv:1603.09320; IEEE TPAMI 42(4), 2020. Chapter 4.
  3. Subramanya, S. J., Devvrit, Kadekodi, R., Krishnaswamy, R., Simhadri, H. V. "DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node." NeurIPS, 2019. Chapter 5.
  4. Guo, R., Sun, P., Lindgren, E., Geng, Q., Simcha, D., Chern, F., Kumar, S. "Accelerating Large-Scale Inference with Anisotropic Vector Quantization." ICML, 2020 — arXiv:1908.10396. Chapter 6.
  5. Ge, T., He, K., Ke, Q., Sun, J. "Optimized Product Quantization." IEEE TPAMI, 2014. The rotation that fixes PQ's independence assumption.
  6. Babenko, A., Lempitsky, V. "The Inverted Multi-Index." CVPR, 2012. Product quantization applied to the coarse quantizer.
  7. Johnson, J., Douze, M., Jégou, H. "Billion-scale Similarity Search with GPUs," 2017 — arXiv:1702.08734. FAISS, and the reference implementation of most of this lesson.
  8. Malkov, Y., Ponomarenko, A., Logvinov, A., Krylov, V. "Approximate Nearest Neighbor Algorithm Based on Navigable Small World Graphs." Information Systems, 2014. The flat NSW that HNSW improved on.
  9. Fu, C., Xiang, C., Wang, C., Cai, D. "Fast Approximate Nearest Neighbor Search With The Navigating Spreading-out Graph" (NSG). VLDB, 2019. The monotonic-graph line of work that Vamana's pruning descends from.
  10. Indyk, P., Motwani, R. "Approximate Nearest Neighbors: Towards Removing the Curse of Dimensionality." STOC, 1998. LSH, and the (1+ε) framework Chapter 1 argues against using in practice.
  11. Aumüller, M., Bernhardsson, E., Faithfull, A. "ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms." Information Systems, 2020. Chapter 7's plots.
  12. Simhadri, H. V. et al. "Results of the NeurIPS'21 Challenge on Billion-Scale Approximate Nearest Neighbor Search," 2022 — arXiv:2205.03763. The billion-scale tracks.
  13. Ootomo, H. et al. "CAGRA: Highly Parallel Graph Construction and Approximate Nearest Neighbor Search for GPUs," 2023 — arXiv:2308.15136. Chapter 8's GPU branch.
  14. Singh, A., Subramanya, S. J., Krishnaswamy, R., Simhadri, H. V. "FreshDiskANN: A Fast and Accurate Graph-Based ANN Index for Streaming Similarity Search," 2021 — arXiv:2105.09613. Updates, which Chapter 5 flagged as the missing piece.
Cross-domain bridge
Every ANN index is a database query planner in disguise
A relational query planner does exactly this: it uses cheap, biased statistics — histograms, cardinality estimates — to decide which index to use and in what order to join, then executes the plan exactly. It is wrong about the estimates all the time, and that is fine, because the estimates only steer the search; the execution is exact. IVF's centroids are histograms over space. PQ codes are lossy statistics used only to rank candidates. DiskANN's in-memory codes route disk reads, and the disk reads produce the exact answer. Even the failure modes match: a stale histogram makes a planner choose a terrible plan, and stale centroids make an IVF index lose recall — the same bug, in the same place, fixed the same way with periodic retraining. If you have debugged a query plan, you already have the instincts for tuning a vector index; see storage and retrieval for the other half of the analogy.
"What I cannot create, I do not understand."
Twenty lines of numpy gives you a working product quantizer: split, k-means each chunk, argmin to encode, and a table of squared distances to score. Reproduce 0.06 + 0.07 = 0.13 by hand first, then in code, and the 3-terabyte problem stops being intimidating.
Exit gate — teach it back before you leave.

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.

Which single sentence best captures what all four papers in this lesson have in common?