Vector search at scale
You've embedded a hundred million passages with a 768-dimensional model. A query arrives, and the product team wants the top 10 in 20 milliseconds. How hard can that be? Let's count. Scoring one query against every vector is multiply-adds. Worse, it means reading all the vectors, which in float32 is 307 GB. A server CPU socket streams memory at very roughly 100 to 200 GB per second, so the scan alone takes a couple of seconds, a hundred times over budget, before we've even sorted anything.
This section is about doing much less work and still finding (almost) the right answers. That's approximate nearest neighbor (ANN) search, and four ideas cover most of what's deployed. Inverted files skip most of the data. Product quantization shrinks every vector to a few bytes. HNSW graphs walk toward the answer in a few hundred steps. Locality-sensitive hashing buckets similar vectors together. We'll build each from scratch, derive what it costs, and finish with how to choose.
Exact search, and when it's enough
Set the problem up precisely. We have a database (row ), a query , and we want the rows with the smallest squared distance , or equivalently for unit vectors the largest inner product (§1.3 showed the rankings agree). Brute force computes all scores in time and selects the top in with a partial sort (Exercise 1.3b).
The expansion turns a batch of queries into one matrix multiply, which is why exact search on a GPU is fast. With a batch of queries, each byte of the database read from memory is reused times, so the work goes from memory-bound to compute-bound, the roofline story of §11.2. For a single query it stays memory-bound, and the bytes are what you pay for.
| database | float32 size | one query, exact, memory-bound | verdict |
|---|---|---|---|
| 100K vectors, 768-d | 0.3 GB | about 2 ms on a CPU | exact is fine |
| 10M vectors | 31 GB | about 0.2 s on a CPU, about 10 ms on a GPU with enough memory | borderline |
| 1B vectors | 3.1 TB | seconds, and it doesn't fit in memory | needs ANN and compression |
So exact search is not a strawman. Below a million or so vectors it's often the right choice, and it's always the reference you measure ANN against. The standard measure is recall@, the fraction of the true top- that the approximate method returns, . Notice what it compares against. This recall treats exact search as the truth and needs no relevance labels, so it measures the index alone. The retrieval recall of §7.5 compares against human relevance labels and measures the whole system, embedding model included. A perfect index over a poor embedding model scores 1.0 on the first and badly on the second.
One practical detail even exact search needs is blocking. Scoring 10 million vectors for a batch of 1,000 queries would need a score matrix, 40 GB. Instead you scan the database in blocks and keep a running top- per query, merging each block's best candidates into it.
Exact top-k by blocks, with a running merge
Implement blocked_topk(Qs, X, k, block) returning (dist, idx), both (nq, k), the k smallest squared Euclidean distances from each query to rows of X and their row indices, sorted ascending. Process X in blocks of block rows so that you never build more than an (nq, block) score matrix, and keep a running top-k that you merge with each block's results. Use np.argpartition inside each block.
Inverted files, search only where the answer probably is
The first idea is the one a librarian would have. Don't scan every book, go to the right shelf. Cluster the database with k-means into cells, with centroids . Store each vector in the list of its nearest centroid. That's an inverted file index (IVF), named after the inverted indexes of text search (§7.5), with cells playing the role of words.
At query time, compare the query with all centroids, pick the nearest cells, and scan only the vectors in those lists. Why more than one cell? Because the true nearest neighbor of a query near a cell boundary often lives in the cell next door. is the knob that trades speed for recall.
The cost is easy to write down. If the lists are balanced, each holds about vectors, and a query computes
multiply-adds. In plain words, more cells make the lists shorter but the centroid scan longer. Setting the derivative with respect to to zero, , gives
So IVF turns into per query, for a fixed . That fine print matters. As grows with , every cell holds more vectors and the space is cut into more cells, so in practice has to creep up to keep the same recall, and the real scaling is somewhat worse than . For and , that's about 40,000 cells and roughly 80,000 distance computations (40,000 centroids plus 40,000 vectors in the probed lists) instead of 100 million. FAISS's guidelines suggest between and , which is exactly our optimum for between 16 and 256, and in practice the centroid scan itself is often accelerated with a small HNSW graph (more on HNSW below) once reaches the tens of thousands.
An IVF index with nprobe
Implement ivf_build(X, C), which assigns every row of X to its nearest centroid in C and returns a list of len(C) integer arrays (the inverted lists, each holding row indices in increasing order), and ivf_search(y, X, C, lists, nprobe, k), which probes the nprobe nearest cells to query y, scans only their vectors, and returns (ids, n_scanned), the k nearest row indices by squared Euclidean distance (closest first) and the number of database vectors whose distance you computed.
Here's the speed and recall trade-off on 10,000 vectors in 32 dimensions with only mild cluster structure, which is closer to real embeddings than neat, well-separated blobs.
Probing one cell finds about half the true neighbors. Recall climbs steadily with , and the last few percent are expensive, which is the shape you'll see on every ANN benchmark. The widget below lets you see why in two dimensions. The neighbors you miss are the ones across a cell boundary.
Product quantization, a few bytes per vector
IVF reduces how many vectors we touch. It doesn't reduce how big they are, and at a billion vectors the index must also fit in memory. Product quantization (PQ, Jégou, Douze & Schmid, 2011) compresses each vector to a handful of bytes while still allowing fast approximate distances. It's the most important compression idea in vector search, so let's derive it carefully.
Start with ordinary vector quantization. Run k-means with centroids on the database and store each vector as the id of its nearest centroid, bits. Distances to the query are approximated by distances to the centroid. To make the approximation good you need a huge , and a codebook of centroids can be neither trained nor stored.
The product trick. Split each vector into contiguous subvectors of dimensions, with . Learn a separate small codebook for each subspace, (row ), by k-means on that subspace alone. A vector is encoded as ids,
and reconstructed by concatenation, .
In plain words, PQ quantizes each slice of the vector separately with its own small k-means, and the code is the list of slice ids.
The implied codebook is the Cartesian product of the small ones, centroids, but we only store numbers. With (one byte per id) and , that's effective centroids in 8 bytes per vector.
Why is training each subspace independently the right thing to do? Because squared distance splits over the subvectors,
Each term involves only one codebook, so the total distortion is minimized by minimizing each term separately, which is exactly what k-means on each subspace does. The only approximation is the structural one, assuming the product of small codebooks is a good codebook. It works best when the subspaces carry similar amounts of variance, which is why optimized PQ (OPQ, Ge et al., 2013) first applies a learned rotation to balance them.
Asymmetric distances, computing with codes
Now the clever part. To rank the database for a query , we never decompress anything. The same split gives
For each query, precompute a lookup table with . That costs operations, once per query. Then the distance to any database vector is table lookups and additions. In plain words, the query stays exact, the database is compressed, and distances become sums of precomputed numbers. This is called asymmetric distance computation (ADC).
The alternative, symmetric distance computation (SDC), quantizes the query too and looks up centroid-to-centroid distances. It costs the same, but it's less accurate, and the triangle inequality tells you exactly how much.
∂ Error bounds for ADC and SDC
Write . For ADC, by the triangle inequality,
so the error is bounded by the quantization error of the database vector alone. For SDC, apply the triangle inequality twice, once for each side,
The query's quantization error adds on top. Since the query is available exactly at search time, there's no reason to throw that precision away, and Jégou et al. found ADC consistently more accurate.
One more subtlety they point out is that the squared ADC distance is biased, and the direction is easy to get backwards. Write with the quantization error. Then
The middle term splits over subspaces like everything else, and each subspace centroid is the mean of the subvectors assigned to it, so averaged over the vectors that share a centroid, that piece of is zero and the middle term vanishes on average. What remains says that the true squared distance is, on average, the ADC distance plus the mean squared quantization error. So ADC underestimates squared distances. Jégou et al. discuss a correction that adds each centroid's mean squared error back. Since every database distance is shifted by a similar amount, the ranking barely moves, and in practice the correction is left out (FAISS does not apply it).
For inner-product search the same trick works, since also splits into per-subspace lookups.
Product quantization, train, encode, and asymmetric distance
Implement three functions. A deterministic kmeans(X, K, iters, seed) returning a (K, dim) centroid array is provided.
pq_train(X, m, K)splits the columns ofX(n, d)intomcontiguous blocks ofd // mcolumns and runskmeans(block, K, 10, j)on blockj. Return codebooks of shape(m, K, d // m).pq_encode(X, books)returns(n, m)codes of dtypeuint8, the nearest centroid id in each subspace.pq_adc(y, codes, books)returns(n,)asymmetric squared distances from a float queryyto every encoded vector, using an(m, K)lookup table. No Python loop over database vectors.
Let's put numbers on it with the 32-dimensional data from before, cut into subspaces of 4 dimensions with centroids each, and compare ADC with SDC.
The vectors shrank 16 times. On its own, PQ ranking gets about half of the exact top 10. But more than 90 percent of the true top 10 sit within PQ's top 100, so the standard recipe is to shortlist with PQ and rerank the shortlist with exact distances, from full vectors kept on SSD or from a finer code. ADC beats SDC on both counts, as the bound predicted.
In production, PQ is almost always combined with IVF, a design Jégou et al. called IVFADC. The coarse quantizer picks cells, and PQ encodes each vector's residual , which is smaller and more uniform than itself, so the same bytes buy more accuracy. FAISS writes this as IVF65536,PQ64. A billion 768-dimensional vectors (3 TB in float32) then fit in about 64 GB of codes plus 8 GB of ids.
HNSW, navigating a graph of neighbors
The third idea comes from small-world networks. Connect each vector to some of its near neighbors, forming a proximity graph. To search, start anywhere, look at your current node's neighbors, move to whichever is closest to the query, and repeat until no neighbor is closer. That's greedy routing, and on a good graph it reaches the neighborhood of the answer in surprisingly few hops.
Plain proximity graphs have a problem. If every edge is short, getting across the space takes many hops. Hierarchical Navigable Small World graphs (HNSW, Malkov & Yashunin, 2018) fix it with layers, in the spirit of a skip list.
- Every vector lives in layer 0, a graph where each node links to up to neighbors.
- Each vector is also placed in layers , where its top layer is random, with uniform on . Upper layers hold exponentially fewer nodes with links of up to neighbors, so their edges are long.
- Search starts at a fixed entry point in the top layer, greedily descends to the closest node there, uses it as the entry to the layer below, and repeats. In layer 0 it switches from greedy to a beam search that keeps the best candidates found so far, and returns the best of them.
The level distribution is where the logarithm comes from. For any level , , so choosing , the paper's recommended setting, gives . Each layer has times fewer nodes than the one below, so there are about layers, and with a bounded number of hops per layer the search visits nodes. The paper reports logarithmic complexity scaling empirically. It's not a worst-case guarantee.
Building the graph uses search. To insert a vector, draw its level, search down to that level, and in each layer from there to 0 run a beam search with width to find candidate neighbors, then connect it to of them. Which ? Not the closest. HNSW's neighbor-selection heuristic keeps a candidate only if it's closer to the new node than to every neighbor already selected. That prefers neighbors in different directions, which keeps long bridges between clusters, and the paper found that it helps most at high recall and on highly clustered data, where plain nearest neighbors would leave each cluster connected only to itself.
The layer-0 beam search is the inner loop of every graph index, so it's worth implementing once.
Beam search on a proximity graph
Implement beam_search(X, graph, y, entry, ef) for one layer of a graph index. X is (n, d), graph[i] is an array of neighbor ids of node i, y is the query, entry is the start node, and ef is the beam width. Return (ids, n_dist), the up to ef closest nodes found, sorted by increasing squared distance, and the number of distance computations made. Compute each node's distance at most once.
The algorithm keeps a min-heap of candidates to expand and a max-heap of the best ef results. Pop the closest candidate. If it's farther than the worst result while the results are full, stop. Otherwise, for each unvisited neighbor, compute its distance and, if the results aren't full or it beats the worst result, push it into both heaps (dropping the worst result if there are more than ef).
On a simple nearest-neighbor graph (no layers), here is how the beam width trades recall for work. The work is counted in distance computations against a database of 2,000 vectors.
Even without layers, a beam of 10 finds 97 percent of the true top 10, and a beam of 20 finds all of them while computing distances to about a fifth of the database. Wider beams buy nothing more here and keep costing distance computations. The layers of HNSW mostly speed up the approach from a far-away entry point, which matters more as grows.
HNSW is the default in most vector databases because its recall-versus-latency curve is among the best on public benchmarks (ann-benchmarks, Aumüller et al., 2020) and because it supports inserting vectors one at a time. Its costs are memory, since each vector carries about links in layer 0 (with and 4-byte ids, 256 bytes per vector on top of the vector itself), slow builds, and awkward deletions (usually handled by marking nodes deleted and rebuilding periodically). The graph must also live in RAM. DiskANN (Subramanya et al., 2019) builds a single-layer graph designed for SSDs, keeping compressed vectors in memory and full vectors on disk, to serve a billion vectors from one machine.
Locality-sensitive hashing
The oldest idea gives the cleanest theory. You built a random-hyperplane index in §1.3; here we derive its collision probability and the amplification that makes it useful. Design a random hash function so that similar vectors are likely to collide and dissimilar ones aren't, then look only at vectors in the query's buckets. For cosine similarity the classic choice is the random hyperplane (Charikar, 2002). Draw and set . Then
where is the angle between and .
∂ Why a random hyperplane separates two vectors with probability theta over pi
Only the component of in the plane spanned by and affects the signs of and . Because the Gaussian is rotationally symmetric, the direction of that projected component is uniform on the circle. The two signs differ exactly when the line perpendicular to the projected falls between and . That line has two ends, each uniform on the circle, and the probability that one of them lands in the arc of angle between the vectors is .
One bit is a weak filter. LSH amplifies it in two ways. Concatenate bits into one bucket key, so two vectors share a bucket with probability where (an AND, which sharpens). Then build independent tables and take the union of the query's buckets, so a pair collides in at least one table with probability
In plain words, makes buckets strict and gives each near neighbor many chances to be found, and together they turn the gentle line into a steep S-curve that's near 1 for close pairs and near 0 for far ones. You'll meet exactly this banding construction, with MinHash in place of hyperplanes, for near-duplicate detection in §8.3.
With 16 bits and 40 tables, pairs 20 degrees apart are found 99.9 percent of the time and pairs 60 degrees apart only 6 percent. LSH has provable sublinear query time for the approximate problem, returning a point within a factor of the nearest distance with high probability (Indyk & Motwani, 1998), and it is simple to shard and update. For dense learned embeddings, though, graph and quantization methods usually reach the same recall with far less memory and time, because LSH ignores the data's actual distribution and needs many tables. You'll still meet it in streaming systems, deduplication, and behind the sign-bit codes of §7.3, which are hyperplane hashes whose hyperplanes are the coordinate hyperplanes (the sign of coordinate is the sign of ). Because the axes of a trained model are not random directions, the formula holds only approximately for them, and a random rotation before binarizing restores it bit by bit.
Choosing an index
There's no best index, only a best index for a budget. Here's a map, with FAISS's factory names in parentheses.
| index | memory per 768-d vector | query cost | recall | build | updates | use it when |
|---|---|---|---|---|---|---|
exact (Flat) | 3,072 B | 1.0 | none | trivial | under about a million vectors, or as ground truth | |
IVF (IVF4096,Flat) | 3,072 B + 8 B id | tunable with | k-means | easy | millions of vectors with RAM to spare | |
IVF-PQ (IVF65536,PQ64) | 64 B + 8 B id | lookups, plus rerank | moderate, high with rerank | k-means per subspace | easy | hundreds of millions to billions, memory-bound |
HNSW (HNSW32,Flat) | 3,072 B + about 256 B links | about hops | very high | slow | inserts easy, deletes hard | low latency at high recall, fits in RAM |
HNSW over compressed vectors (HNSW32,SQ8) | 768 B + links | as HNSW | high | slow | as HNSW | HNSW quality with 4 times less RAM |
| DiskANN-style graph on SSD | compressed in RAM, full on disk | SSD reads along the search path | high | slow | moderate | a billion vectors on one machine |
| LSH | depends on tables | sublinear, many buckets | tunable | instant | trivial | streaming, dedup, theory |
A few rules of thumb that hold up in practice.
- Measure recall against exact search on a sample of real queries before tuning anything. A latency number without a recall number is meaningless.
- The three-way trade-off is recall, latency, and memory. You can usually buy any two. Compression buys memory with recall, which reranking buys back with latency.
- Filters are hard. "Top 10 documents from this customer, newer than last week" breaks naive ANN. Filtering after search can return almost nothing, and filtering before search defeats the index. Vector databases handle this with filter-aware graph traversal or partitioning by the filter key.
- Normalize consistently. If the model is trained for cosine, normalize at index and query time and use inner product. If lengths carry meaning (raw dot products, as in many two-tower recommenders), you need maximum inner-product search, so use an index built for inner product or the norm-augmentation reduction of §1.3, which turns it into nearest-neighbor search. ScaNN (Guo et al., 2020) goes further and trains its quantizer to preserve inner products rather than distances.
In PyTorch
On a GPU, exact search is a matrix multiply plus topk, done in chunks to bound memory, and it's often fast enough to skip ANN entirely below tens of millions of vectors.
With FAISS, the same ideas are a few lines each. This needs the faiss package, so it's shown and not run.
Read more
- Jégou, Douze & Schmid (2011). Product Quantization for Nearest Neighbor Search. PQ, ADC versus SDC, and IVFADC. The derivations in this section follow it.
- Malkov & Yashunin (2018). Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. HNSW, the level distribution, and the neighbor heuristic.
- Douze et al. (2024), The Faiss library, and Johnson, Douze & Jégou (2019), Billion-scale similarity search with GPUs. A modern overview of the design space with the trade-offs measured, and how exact and IVF-PQ search map onto GPUs.
- Charikar (2002). Similarity Estimation Techniques from Rounding Algorithms. Random hyperplane hashing.
- Subramanya et al. (2019). DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node. Billion-scale graph search from SSD.
- Guo et al. (2020). Accelerating Large-Scale Inference with Anisotropic Vector Quantization (ScaNN). Quantization that preserves inner products, behind one of the fastest MIPS libraries.
- Aumüller, Bernhardsson & Faithfull (2020). ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms. How to compare ANN methods fairly, and how they compare.
- The FAISS wiki. Guidelines to choose an index. Practical rules of thumb, including the cell-count guideline.