ml-models / Retrieval and recommendation / Vector indexes advanced

Vector indexes

Exact nearest-neighbour search compares the query with every stored vector, which stops being affordable somewhere around millions of vectors per query per second. Vector indexes trade a little recall for orders of magnitude less work. This page builds the three ideas behind almost every production index, inverted files (IVF) over k-means cells, product quantization (PQ) and HNSW graphs, runs them on the same 800 vectors, and measures recall against cost and memory.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. IVF: inverted lists over k-means cells
  5. Product quantization
  6. HNSW graphs
  7. Worked example
  8. Implementation
  9. Tradeoffs
  10. Connections
  11. Read first
  12. Read next
  13. References

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.

An IVF index with five k-means cells and a query near a cell boundary 45 points in a 10 by 10 square, coloured by the k-means cell they belong to, with crosses at the 5 centroids. The query at (1.25, 1.00) is marked with a square. Its nearest centroid is (3.45, 1.25), but its true nearest neighbour (1.70, 2.50), inside the dashed circle, belongs to the neighbouring cell with centroid (2.27, 3.10). Searching one cell returns (3.10, 1.10) instead; searching two cells finds the true neighbour. query (1.25, 1.00) nprobe = 1: search the nearest cell only → returns (3.10, 1.10), a miss (9 distances: 5 centroids + 4 points) nprobe = 2: add the second-nearest cell → returns (1.70, 2.50), the true neighbour (12 distances, against 45 for brute force) × centroid · ■ query dashed circle: distance to the true neighbour

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 ef closest 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_construction candidates 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.

An HNSW graph on the same points: upper layer and layer 0, with a greedy search route Left: the 16 points that reached layer 1 or higher, with their layer-1 links; larger dots reached layer 2 or higher. The search for the query at (1.25, 1.00) enters at (6.50, 1.10), the only point on the top layer, and hops greedily through the upper layers to (1.70, 2.50). Right: layer 0, where all 45 points have up to 6 links each; the search finishes here and confirms (1.70, 2.50) is the nearest neighbour, after 15 distance computations in total. layers ≥ 1 (16 nodes) with the route layer 0 (all 45 nodes)

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.

ComponentShape/ParamsMeaningSource
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.

nprobeRecall@10Distances per queryFraction of brute force
10.68866.40.08
20.80590.70.11
40.915148.70.19
80.975273.00.34
281.000828.01.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 slotsDims per slotBytes per vectorCompressionMean squared reconstruction errorRecall, PQ onlyRe-rank top 30Re-rank top 100
28164×5.0680.2400.5620.887
44232×3.2070.4170.7550.955
82416×1.0170.6750.9651.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.

efRecall@10Distances per queryFraction of brute force
100.927108.80.14
200.980152.30.19
400.997230.20.29
801.000362.60.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.

Recall against distance computations per query for IVF and HNSW A line chart with distance computations per query on the horizontal axis, from 0 to 800, and recall@10 on the vertical axis, from 0.6 to 1.0. The IVF curve rises from 0.688 at 66 distances to 0.975 at 273. The HNSW curve lies above and to the left, from 0.927 at 109 distances to 1.000 at 363. A vertical dashed line marks brute force at 800 distances. 0.6 0.7 0.8 0.9 1.0 0 200 400 600 800 brute force IVF, nprobe 1–8 HNSW, ef 10–80 distance computations per query recall@10

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

IndexMemory per vectorQuery workBuildUpdatesBest for
Flat (brute force)4d bytesall n, but perfectly regular and GPU-friendlynonetrivialup to about a million vectors, or exact ground truth
IVF-Flat4d + idnlist + nprobe · n/nlistk-meansadd easily; retrain on driftlarge sets with a recall/latency dial
IVF-PQm bytes + idsame, with table lookupsk-means + m codebooksadd easily; retrain on driftbillions of vectors in RAM or on a GPU
HNSW4d + about 8M bytes of linksabout log n hops × Man ef_construction search per insertinserts fine; deletes awkwardlow-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

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.