Chapter 7 · Section 7.4 · 60 min

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 108×768≈7.7×101010^8 \times 768 \approx 7.7\times 10^{10} 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 X∈Rn×d\mathbf{X} \in \mathbb{R}^{n \times d} (row xi\mathbf{x}_i), a query y∈Rd\mathbf{y} \in \mathbb{R}^d, and we want the kk rows with the smallest squared distance ∥y−xi∥2\lVert \mathbf{y} - \mathbf{x}_i\rVert^2, or equivalently for unit vectors the largest inner product (§1.3 showed the rankings agree). Brute force computes all nn scores in O(nd)O(nd) time and selects the top kk in O(n)O(n) with a partial sort (Exercise 1.3b).

The expansion ∥y−x∥2=∥y∥2−2 y⊤x+∥x∥2\lVert \mathbf{y} - \mathbf{x}\rVert^2 = \lVert\mathbf{y}\rVert^2 - 2\,\mathbf{y}^\top\mathbf{x} + \lVert\mathbf{x}\rVert^2 turns a batch of queries into one matrix multiply, which is why exact search on a GPU is fast. With a batch of bb queries, each byte of the database read from memory is reused bb 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.

databasefloat32 sizeone query, exact, memory-boundverdict
100K vectors, 768-d0.3 GBabout 2 ms on a CPUexact is fine
10M vectors31 GBabout 0.2 s on a CPU, about 10 ms on a GPU with enough memoryborderline
1B vectors3.1 TBseconds, and it doesn't fit in memoryneeds 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@kk, the fraction of the true top-kk that the approximate method returns, ∣ANNk∩exactk∣/k|\text{ANN}_k \cap \text{exact}_k|/k. 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 1000×1071000 \times 10^7 score matrix, 40 GB. Instead you scan the database in blocks and keep a running top-kk per query, merging each block's best candidates into it.

Core

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.

Hidden tests, hints, and AI help, from $1.99 a month.

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 nlistn_{\text{list}} cells, with centroids c1,…,cnlist\mathbf{c}_1, \dots, \mathbf{c}_{n_{\text{list}}}. 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 nproben_{\text{probe}} 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. nproben_{\text{probe}} is the knob that trades speed for recall.

The cost is easy to write down. If the lists are balanced, each holds about n/nlistn/n_{\text{list}} vectors, and a query computes

work(nlist)≈nlist d⏟find the cells+nprobenlist n d⏟scan the lists\text{work}(n_{\text{list}}) \approx \underbrace{n_{\text{list}}\, d}_{\text{find the cells}} + \underbrace{\frac{n_{\text{probe}}}{n_{\text{list}}}\, n\, d}_{\text{scan the lists}}

multiply-adds. In plain words, more cells make the lists shorter but the centroid scan longer. Setting the derivative with respect to nlistn_{\text{list}} to zero, d−nprobe n d/nlist2=0d - n_{\text{probe}}\,n\,d/n_{\text{list}}^2 = 0, gives

nlist⋆=nprobe  n,work⋆≈2 d nprobe n.n_{\text{list}}^\star = \sqrt{n_{\text{probe}}\; n}, \qquad \text{work}^\star \approx 2\,d\,\sqrt{n_{\text{probe}}\, n}.

So IVF turns O(n)O(n) into O(n)O(\sqrt{n}) per query, for a fixed nproben_{\text{probe}}. That fine print matters. As nn grows with nlist∝nn_{\text{list}} \propto \sqrt{n}, every cell holds more vectors and the space is cut into more cells, so in practice nproben_{\text{probe}} has to creep up to keep the same recall, and the real scaling is somewhat worse than n\sqrt{n}. For n=108n = 10^8 and nprobe=16n_{\text{probe}} = 16, 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 nlistn_{\text{list}} between 4n4\sqrt{n} and 16n16\sqrt{n}, which is exactly our optimum nprobe n\sqrt{n_{\text{probe}}}\,\sqrt{n} for nproben_{\text{probe}} between 16 and 256, and in practice the centroid scan itself is often accelerated with a small HNSW graph (more on HNSW below) once nlistn_{\text{list}} reaches the tens of thousands.

Interview

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.

Hidden tests, hints, and AI help, from $1.99 a month.

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.

Python · cell 1

Probing one cell finds about half the true neighbors. Recall climbs steadily with nproben_{\text{probe}}, 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.

Why does probing more cells find more of the true neighbors?
1,200 points are clustered by k-means into cells. A query scans only the points in its nprobe nearest cells. Click anywhere to move the query. Rings mark the true 10 nearest points, filled when the index found them and hollow red when it missed them.
Loading interactive figure…

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 KK centroids on the database and store each vector as the id of its nearest centroid, log⁡2K\log_2 K bits. Distances to the query are approximated by distances to the centroid. To make the approximation good you need a huge KK, and a codebook of 2642^{64} centroids can be neither trained nor stored.

The product trick. Split each vector into mm contiguous subvectors of ds=d/md_s = d/m dimensions, x=[u1(x),…,um(x)]\mathbf{x} = [\mathbf{u}_1(\mathbf{x}), \dots, \mathbf{u}_m(\mathbf{x})] with uj(x)∈Rds\mathbf{u}_j(\mathbf{x}) \in \mathbb{R}^{d_s}. Learn a separate small codebook for each subspace, C(j)∈RK×ds\mathbf{C}^{(j)} \in \mathbb{R}^{K \times d_s} (row Cc(j)\mathbf{C}^{(j)}_c), by k-means on that subspace alone. A vector is encoded as mm ids,

code⁡(x)=(c1,…,cm),cj=arg⁡min⁡c∈[K]∥uj(x)−Cc(j)∥2,\operatorname{code}(\mathbf{x}) = (c_1, \dots, c_m), \qquad c_j = \arg\min_{c \in [K]} \big\lVert \mathbf{u}_j(\mathbf{x}) - \mathbf{C}^{(j)}_c\big\rVert^2 ,

and reconstructed by concatenation, q(x)=[Cc1(1),…,Ccm(m)]q(\mathbf{x}) = [\mathbf{C}^{(1)}_{c_1}, \dots, \mathbf{C}^{(m)}_{c_m}].

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, KmK^m centroids, but we only store m⋅K⋅ds=Kdm \cdot K \cdot d_s = K d numbers. With K=256K = 256 (one byte per id) and m=8m = 8, that's 2642^{64} 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,

∥x−q(x)∥2=∑j=1m∥uj(x)−Ccj(j)∥2.\lVert \mathbf{x} - q(\mathbf{x}) \rVert^2 = \sum_{j=1}^{m} \big\lVert \mathbf{u}_j(\mathbf{x}) - \mathbf{C}^{(j)}_{c_j} \big\rVert^2 .

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 y\mathbf{y}, we never decompress anything. The same split gives

d~ADC(y,x)2=∥y−q(x)∥2=∑j=1m∥uj(y)−Ccj(x)(j)∥2⏟Δj, cj(x).\tilde d_{\text{ADC}}(\mathbf{y}, \mathbf{x})^2 = \lVert \mathbf{y} - q(\mathbf{x})\rVert^2 = \sum_{j=1}^{m} \underbrace{\big\lVert \mathbf{u}_j(\mathbf{y}) - \mathbf{C}^{(j)}_{c_j(\mathbf{x})} \big\rVert^2}_{\Delta_{j,\, c_j(\mathbf{x})}} .

For each query, precompute a lookup table Δ∈Rm×K\boldsymbol{\Delta} \in \mathbb{R}^{m \times K} with Δj,c=∥uj(y)−Cc(j)∥2\Delta_{j,c} = \lVert \mathbf{u}_j(\mathbf{y}) - \mathbf{C}^{(j)}_c\rVert^2. That costs KdK d operations, once per query. Then the distance to any database vector is mm table lookups and m−1m - 1 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 d(y,x)=∥y−x∥d(\mathbf{y}, \mathbf{x}) = \lVert \mathbf{y} - \mathbf{x}\rVert. For ADC, by the triangle inequality,

∣d(y,x)−d(y,q(x))∣≤d(x,q(x)),\big| d(\mathbf{y}, \mathbf{x}) - d(\mathbf{y}, q(\mathbf{x})) \big| \le d(\mathbf{x}, q(\mathbf{x})),

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,

∣d(y,x)−d(q(y),q(x))∣≤d(x,q(x))+d(y,q(y)).\big| d(\mathbf{y}, \mathbf{x}) - d(q(\mathbf{y}), q(\mathbf{x})) \big| \le d(\mathbf{x}, q(\mathbf{x})) + d(\mathbf{y}, q(\mathbf{y})).

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 x=q(x)+ϵ\mathbf{x} = q(\mathbf{x}) + \boldsymbol{\epsilon} with ϵ\boldsymbol{\epsilon} the quantization error. Then

∥y−x∥2=∥y−q(x)∥2−2 (y−q(x))⊤ϵ+∥ϵ∥2.\lVert \mathbf{y} - \mathbf{x}\rVert^2 = \lVert \mathbf{y} - q(\mathbf{x})\rVert^2 - 2\,(\mathbf{y} - q(\mathbf{x}))^\top\boldsymbol{\epsilon} + \lVert\boldsymbol{\epsilon}\rVert^2 .

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 ϵ\boldsymbol{\epsilon} 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 y⊤q(x)=∑juj(y)⊤Ccj(j)\mathbf{y}^\top q(\mathbf{x}) = \sum_j \mathbf{u}_j(\mathbf{y})^\top \mathbf{C}^{(j)}_{c_j} also splits into per-subspace lookups.

Interview

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 of X (n, d) into m contiguous blocks of d // m columns and runs kmeans(block, K, 10, j) on block j. Return codebooks of shape (m, K, d // m).
  • pq_encode(X, books) returns (n, m) codes of dtype uint8, the nearest centroid id in each subspace.
  • pq_adc(y, codes, books) returns (n,) asymmetric squared distances from a float query y to every encoded vector, using an (m, K) lookup table. No Python loop over database vectors.
Hidden tests, hints, and AI help, from $1.99 a month.
How can a vector be stored in a few bits and still be compared with a query?
Each 4-dimensional point is split into two 2-dimensional halves, and each half is quantized by its own k-means codebook. The ringed point is stored as two centroid ids. The square is the query, kept exact. Click a panel to pick a different point.
Loading interactive figure…

Let's put numbers on it with the 32-dimensional data from before, cut into m=8m = 8 subspaces of 4 dimensions with K=64K = 64 centroids each, and compare ADC with SDC.

Python · cell 2

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 x−ccell\mathbf{x} - \mathbf{c}_{\text{cell}}, which is smaller and more uniform than x\mathbf{x} 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 M0=2MM_0 = 2M neighbors.
  • Each vector is also placed in layers 1,2,…,ℓ1, 2, \dots, \ell, where its top layer ℓ\ell is random, ℓ=⌊−ln⁡(U) mL⌋\ell = \lfloor -\ln(U)\, m_L \rfloor with UU uniform on (0,1](0, 1]. Upper layers hold exponentially fewer nodes with links of up to MM 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 efef best candidates found so far, and returns the best kk of them.

The level distribution is where the logarithm comes from. For any level λ≥0\lambda \ge 0, P(ℓ≥λ)=P(−ln⁡U≥λ/mL)=e−λ/mL\mathbb{P}(\ell \ge \lambda) = \mathbb{P}(-\ln U \ge \lambda/m_L) = e^{-\lambda/m_L}, so choosing mL=1/ln⁡Mm_L = 1/\ln M, the paper's recommended setting, gives P(ℓ≥λ)=M−λ\mathbb{P}(\ell \ge \lambda) = M^{-\lambda}. Each layer has MM times fewer nodes than the one below, so there are about log⁡Mn\log_M n layers, and with a bounded number of hops per layer the search visits O(log⁡n)O(\log n) 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 efconstructionef_{\text{construction}} to find candidate neighbors, then connect it to MM of them. Which MM? 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.

How does HNSW find a nearest neighbor without looking at most points?
An HNSW graph built live over 300 points (M = 5). Search starts at the entry node in the top layer, moves greedily to closer neighbors, drops a layer, and finishes with a beam search of width ef in layer 0. Click any panel to move the query square.
Loading interactive figure…

The layer-0 beam search is the inner loop of every graph index, so it's worth implementing once.

Interview

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

Hidden tests, hints, and AI help, from $1.99 a month.

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.

Python · cell 3

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 nn 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 2M2M links in layer 0 (with M=32M = 32 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 r∼N(0,I)\mathbf{r} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) and set hr(x)=sign⁡(r⊤x)h_{\mathbf{r}}(\mathbf{x}) = \operatorname{sign}(\mathbf{r}^\top\mathbf{x}). Then

P[hr(x)=hr(y)]=1−θπ,\mathbb{P}\big[h_{\mathbf{r}}(\mathbf{x}) = h_{\mathbf{r}}(\mathbf{y})\big] = 1 - \frac{\theta}{\pi},

where θ\theta is the angle between x\mathbf{x} and y\mathbf{y}.

∂ Why a random hyperplane separates two vectors with probability theta over pi

Only the component of r\mathbf{r} in the plane spanned by x\mathbf{x} and y\mathbf{y} affects the signs of r⊤x\mathbf{r}^\top\mathbf{x} and r⊤y\mathbf{r}^\top\mathbf{y}. 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 r\mathbf{r} falls between x\mathbf{x} and y\mathbf{y}. That line has two ends, each uniform on the circle, and the probability that one of them lands in the arc of angle θ\theta between the vectors is 2θ/2π=θ/π2\theta / 2\pi = \theta/\pi.

One bit is a weak filter. LSH amplifies it in two ways. Concatenate rr bits into one bucket key, so two vectors share a bucket with probability prp^r where p=1−θ/πp = 1 - \theta/\pi (an AND, which sharpens). Then build LL independent tables and take the union of the query's buckets, so a pair collides in at least one table with probability

Pcollide(θ)=1−(1−pr)L.P_{\text{collide}}(\theta) = 1 - \big(1 - p^r\big)^L .

In plain words, rr makes buckets strict and LL gives each near neighbor many chances to be found, and together they turn the gentle line 1−θ/π1 - \theta/\pi 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.

Python · cell 4

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 c>1c \gt 1 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 jj is the sign of ej⊤x\mathbf{e}_j^\top\mathbf{x}). Because the axes of a trained model are not random directions, the 1−θ/π1 - \theta/\pi 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.

indexmemory per 768-d vectorquery costrecallbuildupdatesuse it when
exact (Flat)3,072 BO(nd)O(nd)1.0nonetrivialunder about a million vectors, or as ground truth
IVF (IVF4096,Flat)3,072 B + 8 B idO(n d)O(\sqrt{n}\,d)tunable with nproben_{\text{probe}}k-meanseasymillions of vectors with RAM to spare
IVF-PQ (IVF65536,PQ64)64 B + 8 B idlookups, plus rerankmoderate, high with rerankk-means per subspaceeasyhundreds of millions to billions, memory-bound
HNSW (HNSW32,Flat)3,072 B + about 256 B linksabout O(log⁡n)O(\log n) hopsvery highslowinserts easy, deletes hardlow latency at high recall, fits in RAM
HNSW over compressed vectors (HNSW32,SQ8)768 B + linksas HNSWhighslowas HNSWHNSW quality with 4 times less RAM
DiskANN-style graph on SSDcompressed in RAM, full on diskSSD reads along the search pathhighslowmoderatea billion vectors on one machine
LSHdepends on LL tablessublinear, many bucketstunableinstanttrivialstreaming, 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.

🔥 In PyTorchfor a machine with PyTorch

With FAISS, the same ideas are a few lines each. This needs the faiss package, so it's shown and not run.

Read more

Watch next

🎬 Faiss - Introduction to Similarity Search · James BriggsBuilds a flat index and then an IVF index in FAISS, showing the speed and recall trade-off of probing only the nearest cells.
🎬 Product Quantization for Vector Similarity Search (+ Python) · James BriggsImplements product quantization from scratch with subvectors, codebooks, and lookup tables, then measures the memory saved.
🎬 HNSW for Vector Search Explained and Implemented with Faiss (Python) · James BriggsA clear visual explanation of the layered small-world graph and of how M and ef trade speed against recall.
🎬 Graph-Based Approximate Nearest Neighbors (ANN) and HNSW · PineconeA workshop with Yury Malkov, the author of HNSW, on why graph indexes work and how to tune them.