Lesson 10 / 28
Approximate Search: IVF and HNSW
Understand the two most common index families and their recall knobs.
Skip most of the data on purpose
For millions of vectors, scanning everything is too slow, so approximate nearest neighbour (ANN) indexes avoid looking at most of the data. IVF (inverted file) clusters the vectors into nlist cells with k-means; a query looks only in the nprobe closest cells. HNSW (hierarchical navigable small world) builds a layered graph in which each vector links to near neighbours; a query walks the graph greedily toward the target, and efSearch controls how many candidates it keeps while walking. Both give recall below 100%: the true nearest neighbour is sometimes missed. Increasing nprobe or efSearch raises recall and latency together, so you tune on your data to the recall you need. IVF needs a training step and uses less memory; HNSW has no training, is very fast and accurate, but uses more memory and supports updates less cheaply.
Recall versus search effort, run
I ran this in a Python virtual environment with numpy 2.5.3, scikit-learn 1.9.1 and faiss-cpu 1.15.1, with fixed random seeds so the numbers repeat. On 20,000 clustered 64-dimension vectors, IVF with 100 cells reaches 0.743 recall@10 when probing 1 cell, 0.998 with 4 and 1.000 with 16. HNSW goes from 0.643 at efSearch=4 to 0.932 at 16 and 1.000 at 64. Recall is measured against the exact flat-index answers. Latency is not shown because timings vary by machine.
import numpy as np, faiss
rng = np.random.default_rng(0)
d, n, nq = 64, 20000, 200
centers = rng.normal(size=(50, d)) * 3
xb = (centers[rng.integers(0, 50, n)] + rng.normal(size=(n, d))).astype("float32")
xq = (centers[rng.integers(0, 50, nq)] + rng.normal(size=(nq, d))).astype("float32")
flat = faiss.IndexFlatL2(d); flat.add(xb)
_, truth = flat.search(xq, 10) # exact answers
def recall(found): return float(np.mean([len(set(f) & set(t)) / 10 for f, t in zip(found, truth)]))
ivf = faiss.IndexIVFFlat(faiss.IndexFlatL2(d), d, 100); ivf.train(xb); ivf.add(xb)
for nprobe in (1, 4, 16):
ivf.nprobe = nprobe
print(f"IVF nprobe={nprobe:2d} recall@10 = {recall(ivf.search(xq, 10)[1]):.3f}")
hnsw = faiss.IndexHNSWFlat(d, 16); hnsw.hnsw.efConstruction = 100; hnsw.add(xb)
for ef in (4, 16, 64):
hnsw.hnsw.efSearch = ef
print(f"HNSW efSearch={ef:2d} recall@10 = {recall(hnsw.search(xq, 10)[1]):.3f}")
Output:
IVF nprobe= 1 recall@10 = 0.743 IVF nprobe= 4 recall@10 = 0.998 IVF nprobe=16 recall@10 = 1.000 HNSW efSearch= 4 recall@10 = 0.643 HNSW efSearch=16 recall@10 = 0.932 HNSW efSearch=64 recall@10 = 1.000
Tune against exact answers
Compute recall@k of your ANN index against a flat index on a sample of real queries. Pick the cheapest setting that meets your target.
Quick check: What does raising nprobe (IVF) or efSearch (HNSW) do?
- Raises recall and also latency
- Lowers both recall and latency
- Changes the embedding model
- Deletes vectors
Answer
Raises recall and also latency — More search effort finds more true neighbours but takes longer.