Problem
Similarity search turns retrieval into "find the k stored vectors closest to this query vector". Done exactly, that is a matrix-vector product over the whole collection: for 100,000,000 vectors of 768 float32 values it reads 307.2 GB of memory per query. A retrieval system behind a chat assistant or a recommender has to answer in milliseconds, for many queries at once, often on machines that cannot hold that much RAM.
Approximate nearest-neighbour (ANN) search gives up the guarantee of the exact top k. Its quality is measured by recall@k, the fraction of the true k nearest neighbours that the index returns. Every index design is a point in a three-way trade between recall, latency (how many vectors a query touches) and memory (how many bytes each vector costs), plus how long the index takes to build and whether it can accept new vectors.
Intuition
There are two ways to do less work per query, and production indexes combine them.
- Look at fewer vectors. Partition the space so a query only searches the regions near it (IVF: "check the post office districts nearest your address"), or link every vector to its neighbours so a query can walk toward its answer (HNSW: "ask for directions, hopping from person to person, with a few long-distance express routes on top").
- Make each vector cheaper. Replace a vector of floats with a short code that still lets you estimate distances (PQ: describe a face as "nose type 13, eyes type 5, mouth type 1" from small catalogues of each part).
Both introduce misses: the true neighbour may sit just across a partition boundary, the walk may stop at a local minimum, the codes may blur two vectors together. Each method has a knob that buys recall back with more work: nprobe, ef, or the number of candidates re-ranked with exact distances.
The query is closer to one centroid but its nearest neighbour lives in the next cell. IVF's misses are always of this kind, and nprobe is how many cells you search to catch them.
Mechanics
IVF: inverted lists over k-means cells
An inverted file index (IVF) trains k-means with nlist centroids on a sample of the data, the coarse quantizer, and stores each vector in the list of its nearest centroid. The cells are the Voronoi regions of the centroids.
A query computes its distance to all nlist centroids, picks the nprobe nearest, and scans only those lists exactly. With balanced lists the work per query is about nlist + nprobe · n / nlist distances, minimized near nlist ≈ √n; production systems with a billion vectors use tens of thousands of lists or more.
Recall rises with nprobe and reaches the exact answer when nprobe = nlist. Lists are never perfectly balanced, since dense regions get bigger cells, so latency varies by query. New vectors can be added by assigning them to a cell, but if the data drifts the centroids go stale and the index needs retraining.
Product quantization
Jégou, Douze and Schmid split each d-dimensional vector into m sub-vectors of d/m dimensions and train a separate k-means codebook of ks centroids for each slot. A vector is stored as m centroid ids, m · log₂ ks bits: with the usual ks = 256, one byte per slot. The m codebooks together describe ksm possible reconstructions (256⁸ ≈ 1.8 × 10¹⁹ with 8 bytes) while storing only m · ks centroids.
Search uses asymmetric distance computation (ADC): the query stays exact. For each slot j, precompute a table of the squared distance from the query's j-th sub-vector to all ks centroids. The estimated distance to any stored code is then m table lookups and additions:
d̂(q, x)² = Σj=1..m Tj[codej(x)] , Tj[c] = ‖q(j) − μj,c‖²
That is exactly the squared distance from q to x's reconstruction, so the error comes only from quantizing x. Because the estimates are biased and noisy, systems fetch the top R candidates by d̂ and re-rank them with exact distances, when the full vectors are available somewhere (on disk, say). IVF-PQ combines both ideas: PQ-encode the residual x − centroid within each cell, which has smaller spread than x itself, so the same bytes buy more accuracy. Optimized PQ rotates the space first to balance the variance across slots, and scalar quantization (int8 or 4-bit per value) is the simpler alternative when memory pressure is milder.
HNSW graphs
Malkov and Yashunin's hierarchical navigable small world graph links every vector to a few near neighbours and searches by greedy walking: from an entry point, repeatedly move to whichever neighbour is closest to the query. A flat graph needs many hops to cross the dataset, so HNSW stacks layers like a skip list. Each inserted vector draws a top level ℓ = ⌊−ln(U) · mL⌋ with U uniform in (0, 1] and mL = 1/ln M, so each layer has about 1/M as many nodes as the one below; it appears in every layer from ℓ down to 0.
-
Search: start at the single entry point on the top layer, walk greedily to the closest node, drop to the next layer and continue from there. On layer 0 run a best-first search that keeps the
efclosest nodes seen so far and stops when the nearest unexpanded candidate is farther than the worst of them; return the best k. -
Insert: search for the new vector itself with
ef_constructioncandidates on each of its layers, connect it to up to M of them, and add the reverse links, pruning any neighbour list that grows past M (2M on layer 0). - Neighbour selection heuristic: take candidates nearest first, and keep one only if it is closer to the new node than to every neighbour already kept. That skips redundant links into a cluster you already reach and keeps links that point in new directions, which is what lets greedy walks escape between clusters.
Search cost grows roughly logarithmically with n. HNSW gives the best recall for a given latency on most in-memory benchmarks, at the price of storing full vectors plus about 2M links per node, a build that does an ef_construction search per insert, and deletes that need tombstones or repair.
The upper layers are sparse, so a few long hops cover most of the distance; layer 0 is dense and only has to polish the answer. Greedy routing here reached the true neighbour, but on harder data it can stall, which is what a larger ef protects against.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
| Coarse quantizer (IVF) | nlist centroids (nlist, d); lists of ids | k-means on a training sample; each vector goes to its nearest centroid's list. scikit-learn's KMeans is the same algorithm a vector library runs to train it. |
_kmeans.py 1191-1561 |
| Assigning to a cell or a code | X (n, d), centroids (k, d) → labels (n,) | Nearest-centroid assignment, used for adding vectors to IVF lists, for choosing which lists to probe, and for PQ encoding of each slot. | KMeans.predict 1071-1104, _labels_inertia 761-832 |
| Seeding | k-means++, 2 + ⌊ln k⌋ local trials | Spreads the starting centroids so cells and codebooks cover the data. | _kmeans.py 180-283 |
| Training on large samples | batch_size 1024; partial_fit |
Mini-batch k-means updates centroids from small batches, for training sets too big for full Lloyd iterations. | MiniBatchKMeans 1680-2320 |
| PQ codebooks | m codebooks (ks, d/m); codes (n, m) of log₂ ks bits | One k-means per slot of the split vector; ADC tables (m, ks) per query. | Jégou et al. 2011; trained with KMeans |
| HNSW graph | M links per node (2M on layer 0), mL = 1/ln M, ef_construction, ef | Layered proximity graph searched greedily from the top. | Malkov and Yashunin 2018 (no pinned library implements it) |
| Exact baseline | algorithm "auto", "ball_tree", "kd_tree" or "brute" | Ground truth for recall, and the right answer for small collections. Trees prune well only in low dimensions. | NearestNeighbors 10-179, kneighbors 756-959 |
Why does PQ split the vector into slots instead of running one k-means with a huge number of centroids?
To get the same number of distinct reconstructions from one codebook you would need ksm centroids, about 1.8 × 10¹⁹ for 8 one-byte slots: impossible to train, store or search. Splitting makes the codebook a Cartesian product of m small ones, so training is m small k-means runs, storage is m · ks centroids, and a query's distance tables cost m · ks sub-distances.
Worked example
Data. 800 vectors in 16 dimensions, drawn around 16 random centres with standard deviation 0.6 per coordinate (real embeddings are clustered too), plus 40 queries from the same distribution. Ground truth is the exact top 10 by Euclidean distance, which costs 800 distances per query. Recall below is recall@10 averaged over the queries, and cost is distance computations per query, the main term in latency for these indexes.
IVF with nlist = 28. The lists hold between 1 and 56 vectors: cells over dense regions are big.
| nprobe | Recall@10 | Distances per query | Fraction of brute force |
|---|---|---|---|
| 1 | 0.688 | 66.4 | 0.08 |
| 2 | 0.805 | 90.7 | 0.11 |
| 4 | 0.915 | 148.7 | 0.19 |
| 8 | 0.975 | 273.0 | 0.34 |
| 28 | 1.000 | 828.0 | 1.03 |
One cell finds 68.8% of the neighbours with 8% of the work; 8 cells find 97.5%. Probing all 28 is exact but costs more than brute force, since it adds the centroid distances.
Product quantization with ks = 16 centroids per slot (4 bits each). The float32 vectors take 64 bytes each.
| m slots | Dims per slot | Bytes per vector | Compression | Mean squared reconstruction error | Recall, PQ only | Re-rank top 30 | Re-rank top 100 |
|---|---|---|---|---|---|---|---|
| 2 | 8 | 1 | 64× | 5.068 | 0.240 | 0.562 | 0.887 |
| 4 | 4 | 2 | 32× | 3.207 | 0.417 | 0.755 | 0.955 |
| 8 | 2 | 4 | 16× | 1.017 | 0.675 | 0.965 | 1.000 |
More slots means smaller sub-vectors, lower reconstruction error and better ranking, for more bytes. PQ alone ranks badly at 32× compression (0.417), but its top 100 candidates contain 95.5% of the true neighbours, so re-ranking those 100 with exact distances recovers them.
One ADC estimate with m = 4: the query's nearest stored vector, (−0.82, −0.57, …, 1.17), is stored as the code 13 · 5 · 1 · 1, two bytes. The query's four tables give T1[13] = 1.274, T2[5] = 2.341, T3[1] = 0.745, T4[1] = 1.465, which sum to d̂² = 5.825. The exact squared distance is 5.333: the estimate is off by 0.492 because the code's reconstruction is not the vector.
HNSW with M = 8 and ef_construction = 40. The layers hold 800, 111, 10, 1 nodes from the bottom up, close to the expected ratio of 1/M = 1/8 per layer; layer-0 nodes keep 9.4 links on average. Building it took 150,216 distance computations.
| ef | Recall@10 | Distances per query | Fraction of brute force |
|---|---|---|---|
| 10 | 0.927 | 108.8 | 0.14 |
| 20 | 0.980 | 152.3 | 0.19 |
| 40 | 0.997 | 230.2 | 0.29 |
| 80 | 1.000 | 362.6 | 0.45 |
At ef = 20 HNSW touches 152 vectors per query, about the same as IVF's nprobe = 4 (149), and finds 98.0% of the neighbours against IVF's 91.5%; ef = 80 is exact on these queries at 45% of the brute-force work. The gap grows with n, because HNSW's cost grows roughly with log n and IVF's with √n.
At scale. For 100,000,000 vectors of 768 dimensions: flat float32 storage needs 307.2 GB; IVF-Flat adds 8-byte ids for 308.0 GB; HNSW with M = 32 adds about 65.0 four-byte links per vector for 333.2 GB; IVF-PQ with m = 96 one-byte slots needs 10.4 GB, small enough for one machine, or a GPU.
Benchmarks of real indexes draw exactly this plot, usually with queries per second instead of distance counts, and compare curves rather than single points: a method is better only if it is higher at the same cost.
Implementation
import numpy as np
from sklearn.cluster import KMeans
def build_ivf_pq(X, nlist=1024, m=16, ks=256, seed=0):
coarse = KMeans(nlist, n_init=1, random_state=seed).fit(X) # coarse quantizer
cell = coarse.labels_
R = X - coarse.cluster_centers_[cell] # residuals
d = X.shape[1] // m
books = [KMeans(ks, n_init=1, random_state=seed).fit(R[:, j*d:(j+1)*d]) for j in range(m)]
codes = np.stack([b.labels_ for b in books], 1).astype(np.uint8) # (n, m), 1 byte per slot
lists = [np.flatnonzero(cell == c) for c in range(nlist)]
return coarse, books, codes, lists
def search(q, coarse, books, codes, lists, k=10, nprobe=16):
d = len(q) // len(books)
near = np.argsort(((coarse.cluster_centers_ - q) ** 2).sum(1))[:nprobe]
ids, est = [], []
for c in near:
r = q - coarse.cluster_centers_[c] # query residual for this cell
T = np.stack([((b.cluster_centers_ - r[j*d:(j+1)*d]) ** 2).sum(1)
for j, b in enumerate(books)]) # ADC tables (m, ks)
cand = lists[c]
est.append(T[np.arange(len(books)), codes[cand]].sum(1)) # m lookups per vector
ids.append(cand)
ids, est = np.concatenate(ids), np.concatenate(est)
top = np.argsort(est)[:k] # re-rank here if raw vectors are kept
return ids[top], est[top]
In practice you use a library: FAISS (IndexIVFFlat, IndexIVFPQ, IndexHNSWFlat, with nprobe and efSearch as the search knobs), hnswlib, ScaNN, or a vector database built on one of them. The sketch above is what they do, minus the SIMD, batching and memory layout that make it fast.
Tune with a held-out query set and exact ground truth: fix the recall you need (0.9 or 0.95 at k = 10 is common), then pick the cheapest setting that reaches it.
- IVF: nlist around √n to 16√n, trained on 30 to 256 points per centroid; then raise nprobe until recall is met.
- PQ: m sets bytes per vector; d/m between 2 and 8 dimensions per slot works well; ks = 256 keeps codes byte-aligned. Re-rank if you can afford to keep or fetch raw vectors.
- HNSW: M = 16 to 64 (higher for high-dimensional data and high recall), ef_construction 100 to 500 (build time for graph quality), then ef ≥ k raised until recall is met. ef can change per query; M and ef_construction cannot without a rebuild.
Tradeoffs
| Index | Memory per vector | Query work | Build | Updates | Best for |
|---|---|---|---|---|---|
| Flat (brute force) | 4d bytes | all n, but perfectly regular and GPU-friendly | none | trivial | up to about a million vectors, or exact ground truth |
| IVF-Flat | 4d + id | nlist + nprobe · n/nlist | k-means | add easily; retrain on drift | large sets with a recall/latency dial |
| IVF-PQ | m bytes + id | same, with table lookups | k-means + m codebooks | add easily; retrain on drift | billions of vectors in RAM or on a GPU |
| HNSW | 4d + about 8M bytes of links | about log n hops × M | an ef_construction search per insert | inserts fine; deletes awkward | low-latency, high-recall search that fits in RAM |
- Recall is not the end goal. Retrieval feeds a re-ranker or a language model; a recall drop from 0.99 to 0.95 often changes end-task quality very little, while halving latency or memory changes the bill a lot. Measure the end task.
- Filters break assumptions. "Nearest neighbours where language = fr" can leave a graph walk stranded among filtered-out nodes or empty most probed cells; systems pre-filter, post-filter with a larger k, or build filter-aware indexes.
- Distance choice matters. For cosine similarity, normalize vectors and use inner product or Euclidean distance, which then rank identically (see similarity search).
- Disk-based graphs (DiskANN and relatives) keep compressed vectors in RAM and the graph plus full vectors on SSD, for collections too big for HNSW in memory.
Your HNSW index meets its recall target in testing, but recall drops in production after a month of inserts and deletes. What are likely causes?
Deletes are usually tombstones: deleted nodes still sit in the graph and take up ef slots and hops, and if many neighbours of a region are deleted, live nodes can become hard to reach. The query distribution may also have drifted toward regions where the graph was built with fewer or worse links. Fixes: raise ef, periodically rebuild or repair the graph, and keep monitoring recall against exact search on a sample of live queries.
Connections
Read first
- Similarity search intro
- Clustering intro
Read next
No page builds on this one yet.
Clustering supplies IVF's cells and PQ's codebooks; similarity search defines the metrics and the exact baseline; nearest neighbours covers k-NN as a model and the tree indexes that work in low dimensions.
References
- Malkov and Yashunin. Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs. IEEE TPAMI 2018. arXiv:1603.09320
- Jégou, Douze and Schmid. Product Quantization for Nearest Neighbor Search. IEEE TPAMI 2011. doi:10.1109/TPAMI.2010.57
- Sivic and Zisserman. Video Google: A Text Retrieval Approach to Object Matching in Videos. ICCV 2003 (inverted files over k-means visual words).
- Ge, He, Ke and Sun. Optimized Product Quantization. IEEE TPAMI 2014.
- Johnson, Douze and Jégou. Billion-Scale Similarity Search with GPUs. 2017. arXiv:1702.08734
- Subramanya, Devvrit, Kadekodi, Krishnaswamy and Simhadri. DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node. NeurIPS 2019.
- Aumüller, Bernhardsson and Faithfull. ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms. Information Systems 2020. arXiv:1807.05614
- scikit-learn 1.9.1:
KMeans,MiniBatchKMeans,NearestNeighbors.