ANN Algorithms
Deep dive into Approximate Nearest Neighbor search methods and their trade-offs between speed and accuracy.
SPACED REPETITION · 12 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 12The index that walked past the answer
You run a duplicate-question finder over 522,931 Quora questions, each embedded with all-MiniLM-L6-v2 (384 dimensions, unit length). Brute-force search is exact, but on one core it takes a median 31.5 ms per query, and the corpus is about to grow tenfold. So you build an HNSW index with hnswlib's default settings, and queries drop to about 0.13 ms. Then a user types this:
How can I remove humidity in laptop?
| Top result | Cosine | |
|---|---|---|
| HNSW index | How can I reduce my weight from 80 kg to 65 kg in 1 month? | 0.257 |
| Brute force | How can I remove humidity in a laptop? | 0.996 |
No error and no warning: ten results, every one of them about losing weight. The near-perfect duplicate is in the index. The search never reached it.
The cause is one number. hnswlib searches with ef = 10 unless you call set_ef, and ef is not saved in the index file, so even a team that tuned it in a notebook can ship 10 by accident. Across all 10,000 test queries of this dataset, 310 queries lost a labeled duplicate that brute force had in its top 10. At ef = 64 it was 30 queries, for about 0.44 ms per query.
That is the deal every approximate nearest neighbor (ANN) index offers. It skips almost all of the corpus, which is why it is fast, and occasionally the part it skipped held the answer. This lesson is about making that deal on purpose: which structure to build, which knob trades what, and how to measure what you gave up.
| # | Symptom | Move | What you pay |
|---|---|---|---|
| 1 | Brute force breaks the latency budget as the corpus grows | Price the exact answer before replacing it | Nothing yet: this is the baseline you measure against |
| 2 | You need sub-millisecond search and the vectors fit in RAM | Walk a graph (HNSW) | Link memory, build time, recall below 1 |
| 3 | Builds must be fast, or you want to pair the index with compression | Search a few clusters (IVF) | A training step, misses at cell borders |
| 4 | The float32 vectors do not fit in RAM | Quantize, then rescore | Recall lost to compression, reads of the originals |
| 5 | Even compressed, the index outgrows one machine's RAM | Put the graph on SSD (DiskANN-style) | SSD reads on every query |
| 6 | "The benchmark said 0.99" | Measure on your own data | A held-out query set and an afternoon |
Everything here runs on one real dataset, so the numbers are comparable from section to section. What a vector database adds around the index (storage, updates, filtering, sharding) is in Vector Database Architecture. Choosing a distance metric is in Cosine Similarity & Distance Metrics.
The test bench
The setup below is used for every table in this lesson. It downloads the Quora set from the BEIR benchmark: 522,931 questions, plus 10,000 test questions with their labeled duplicates. The script embeds everything once and saves the vectors.
# embed_quora.py: run once, as its own script (see the warning below)
import csv, io, json, os, urllib.request, zipfile
import numpy as np
from sentence_transformers import SentenceTransformer
URL = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/quora.zip"
if not os.path.exists("quora"):
zipfile.ZipFile(io.BytesIO(urllib.request.urlopen(URL).read())).extractall(".")
texts = [json.loads(line)["text"] for line in open("quora/corpus.jsonl")]
queries = {q["_id"]: q["text"] for q in map(json.loads, open("quora/queries.jsonl"))}
test_ids = sorted({row["query-id"] for row in
csv.DictReader(open("quora/qrels/test.tsv"), delimiter="\t")})
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
X = model.encode(texts, batch_size=256) # unit-length, 384-d
Q = model.encode([queries[i] for i in test_ids], batch_size=256)
np.save("X.npy", X)
np.save("Q.npy", Q)
print(X.shape, Q.shape, X.dtype, f"{X.nbytes / 1e6:.0f} MB")
# (522931, 384) (10000, 384) float32 803 MB
Encoding took a few minutes on an M2 Max GPU; on a laptop CPU, expect much longer. To start smaller, embed only the first 100,000 questions: the ANN-recall experiments work unchanged, but skip the IR-recall block in Move 2 (it maps labels onto the whole corpus), and expect numbers that differ from the tables here.
⚠️ Keep the embedding step in its own process. On macOS, importing PyTorch (through sentence-transformers) and then running a multi-threaded FAISS search in the same Python process crashed with a segmentation fault in our environment. Both libraries ship their own OpenMP runtime. Embed in one script, search in another.
Measurements in this lesson come from one Apple M2 Max shared with other heavy jobs. Recall numbers are stable. Latencies are not: medians moved by up to about 2× between identical runs (an idle re-run of the exact scan below took 16 ms instead of 31.5 ms), and p95 moved even more. So the latency columns show medians only. Compare rows within a table, and measure p95 on your own hardware.
Two recalls: know which one you are measuring
Two different numbers are both called "recall", and mixing them up causes many of the bad decisions in this area.
- ANN recall@k = |ANN top-k ∩ exact top-k| / k. How faithfully the index reproduces brute force. It needs no human labels, only an exact search to compare with.
- IR (retrieval) recall@k = relevant items in the top k / all relevant items. Whether users find what they need. It needs labeled data. The Retrieval Metrics lesson covers it in depth.
On the test bench, over all 10,000 labeled test queries (the code that produces this table runs in Move 2, once the index exists):
| Search | ANN recall@10 | IR recall@10 |
|---|---|---|
| Brute force | 1.000 | 0.950 |
HNSW, ef = 10 |
0.946 | 0.924 |
HNSW, ef = 64 |
0.995 | 0.948 |
Read it row by row. Brute force misses about 5% of the labeled duplicates, and almost all of that is the embedding model's fault (76 queries have more than ten labeled duplicates, so even a perfect top 10 would score 0.997). No index setting can fix it. At ef = 10, the approximation throws away a further 2.6 points of IR recall. At ef = 64, it keeps almost everything the model could find. So ANN recall tells you how much of the model's quality the index preserves. It never tells you whether the model was any good.
Predict: brute force returns ids [7, 3, 9, 1, 4] and the index returns [3, 7, 4, 8, 2]. What is ANN recall@5? Does the swapped order of 7 and 3 cost anything?
Check your answer
The overlap is 7, 3 and 4, so ANN recall@5 = 3/5 = 0.6. Order inside the top k does not matter to recall. It matters to ranking metrics such as nDCG, which you meet in the Evaluation lessons.
Ground truth and a stopwatch
Every experiment below compares against the exact top-k for 1,000 held-out test queries. Build that once:
import csv, json, time
import numpy as np
import faiss
texts = [json.loads(line)["text"] for line in open("quora/corpus.jsonl")]
row_of = {json.loads(line)["_id"]: i for i, line in enumerate(open("quora/corpus.jsonl"))}
queries = {q["_id"]: q["text"] for q in map(json.loads, open("quora/queries.jsonl"))}
qrels = {} # query id -> rows of its labeled duplicates
for r in csv.DictReader(open("quora/qrels/test.tsv"), delimiter="\t"):
qrels.setdefault(r["query-id"], set()).add(row_of[r["corpus-id"]])
test_ids = sorted(qrels)
X, Q = np.load("X.npy"), np.load("Q.npy")
rng = np.random.default_rng(0)
Qs = Q[rng.choice(len(Q), 1000, replace=False)] # 1,000 held-out test queries
flat = faiss.IndexFlatIP(X.shape[1]) # exact search; inner product = cosine here
flat.add(X)
_, gt = flat.search(Qs, 100) # ground truth: exact top-100 per query
def ann_recall(found, truth, k=10):
"""ANN recall@k: |approximate top-k ∩ exact top-k| / k, averaged over queries."""
return np.mean([len(set(map(int, f[:k])) & set(map(int, t[:k]))) / k
for f, t in zip(found, truth)])
def latency_ms(search_one, n=200):
"""p50 and p95 single-query latency over the first n held-out queries."""
times = []
for q in Qs[:n]:
t = time.perf_counter()
search_one(q)
times.append((time.perf_counter() - t) * 1000)
return np.percentile(times, 50), np.percentile(times, 95)
faiss.omp_set_num_threads(1) # one query, one core
p50, _ = latency_ms(lambda q: flat.search(q[None], 10))
print(f"exact, one query at a time: p50 {p50:.1f} ms")
t = time.perf_counter()
flat.search(Qs, 10)
print(f"exact, 1,000 queries as one batch: {time.perf_counter() - t:.2f} ms per query")
# exact, one query at a time: p50 31.5 ms
# exact, 1,000 queries as one batch: 1.20 ms per query
The queries are held out: none of them is an indexed record (13 of the 10,000 have exact-text twins in the corpus, because Quora contains duplicate questions). Move 6 measures how much holding out changes on this data, and why it matters more in RAG. The vectors are unit length, so inner product, cosine and L2 all rank the same way.
Move 1: Price the exact answer first
Symptom: p95 creeps up with every batch of new documents, and it scales with the corpus.
Exact k-NN computes one similarity per stored vector: n·dim multiply-adds, then a top-k selection. On the test bench that is 522,931 × 384 ≈ 201 million multiply-adds per query, and it streams all 803 MB of float32 vectors through the CPU.
The two measurements above tell different stories:
- Latency, one query at a time: a median of 31.5 ms on one core. This is what a user request sees.
- Throughput, 1,000 queries in one batch: 1.20 s for the batch, so about 830 queries per second on the same single core. A batch turns 1,000 vector-matrix products into one matrix-matrix product, so every vector loaded from memory serves 1,000 queries. That is throughput, not latency: every query in the batch waited the full 1.2 s for its answer.
So brute force is excellent for offline work: computing ground truth, deduplicating a corpus, or nightly jobs. It hurts in the online path, where requests arrive one at a time and the tail counts.
The cost is linear in n·dim. Ten million 768-d chunks is 10,000,000 × 768 / (522,931 × 384) ≈ 38× the work of the test bench: over a second per query on one core at the rate measured here. Whether your own corpus has crossed that line is a question for Vector Database Architecture, which computes the cut-off. This lesson starts where brute force has already lost.
Why not an exact index?
The classic exact structures, such as k-d trees, split space along one coordinate at a time and skip every region that provably cannot contain a closer point. In 2 or 3 dimensions that pruning removes almost everything. In hundreds of dimensions the ball around the query crosses so many splitting planes that the search ends up visiting most of the leaves anyway, and you pay tree overhead for a linear scan.
You can see the underlying problem in the similarity numbers. For 200 queries, compare the best match with the median match:
| Data (522,931 vectors, 384-d) | Best cosine, averaged | Median cosine, averaged |
|---|---|---|
| Quora MiniLM embeddings | 0.91 | 0.04 |
| Random unit vectors | 0.24 | 0.00 |
In random high-dimensional data every point is almost equally far from every other, so the "nearest" neighbor is barely nearer than a typical one. This is the concentration of distances behind the phrase "curse of dimensionality". Real embeddings are different: similar questions really do sit close together, far above the crowd. ANN indexes exploit that structure. They give up the guarantee and bet that neighbors of neighbors are likely to be neighbors. On real embeddings the bet pays off. On random vectors it fails, which is why you never benchmark on them (Move 6).
Your turn: a team has 4 million chunks at 1,024 dimensions, 20 queries per second at peak, and a 50 ms p95 budget for retrieval. Using the single-core scan rate measured above (803 MB in about 31.5 ms), is brute force viable?
Check your answer
Each query streams 4,000,000 × 1,024 × 4 B = 16.4 GB. At the measured rate (about 25.5 GB/s on one core) that is roughly 640 ms. A perfect split across 16 cores would finish that single query in about 40 ms only with about 410 GB/s of memory bandwidth, close to or beyond a whole socket's theoretical peak (below), which sustained bandwidth never reaches. So even one query at a time barely fits the 50 ms budget, if at all. Under load it is clearly out:
- Memory bandwidth. However you split the work, 20 queries per second × 16.4 GB is 328 GB of memory reads every second. That is about the theoretical peak of a whole server socket (eight DDR5-4800 channels give about 307 GB/s, twelve about 461 GB/s), and sustained bandwidth is lower still.
- Queueing. Even with enough bandwidth, 20 queries of 40 ms each keep all 16 cores 80% busy, so requests wait for each other. A simple queueing model (M/D/1) puts the average wait alone at about 80 ms, before the 40 ms of work.
Brute force is out for the online path. It is still the right tool for computing ground truth on a sample of queries.
Move 2: Walk a graph (HNSW)
Symptom: the vectors fit in RAM, but a full scan per query does not fit the latency budget.
A graph index links every vector to a few nearby vectors when it is built. A query then walks the links: start somewhere, move to whichever neighbor is closer to the query, and repeat. Each step computes only a handful of distances, so the walk touches a tiny fraction of the corpus. It works when "close to my neighbor" usually means "close to me", and on real embeddings it usually does.
A walk that gets stuck
Here is the walk on twelve points in 2-D, small enough to follow by hand. Each point is linked to its two nearest neighbors, and links work in both directions. The query sits at (8, 6), and its true nearest neighbor is J, one unit away. The walk starts at D in the bottom-left corner.
y
9 . . . E . . K . . G
8 . . . . . . . . . C
7 . . . . . . . . . .
6 . . . . . . . . Q . Q = query (8, 6)
5 . . . . . . . . J .
4 . . . H . . . . . .
3 L B . . . . . A I .
2 D . . . F . . . . .
0 1 2 3 4 5 6 7 8 9 x
links: D-B D-L B-L B-H H-E H-F F-A A-I A-J I-J J-C C-G G-K K-E
The search keeps two lists: candidates (places it may still expand) and results (the best ef points seen so far). This is the layer search of HNSW, written out in plain Python:
import heapq, math
points = {"A": (7, 3), "B": (1, 3), "C": (9, 8), "D": (0, 2), "E": (3, 9), "F": (4, 2),
"G": (9, 9), "H": (3, 4), "I": (8, 3), "J": (8, 5), "K": (6, 9), "L": (0, 3)}
graph = {"A": "FIJ", "B": "DHL", "C": "GJ", "D": "BL", "E": "HK", "F": "AH",
"G": "CK", "H": "BEF", "I": "AJ", "J": "ACI", "K": "EG", "L": "BD"}
def search_layer(query, entry, ef):
dist = lambda p: math.dist(points[p], query)
visited = {entry}
candidates = [(dist(entry), entry)] # min-heap: nearest unexpanded first
results = [(-dist(entry), entry)] # max-heap: worst kept result on top
while candidates:
d, node = heapq.heappop(candidates)
if d > -results[0][0]: # nearest candidate is worse than the
break # worst result we keep: stop
for nb in graph[node]:
if nb in visited:
continue
visited.add(nb)
d_nb = dist(nb)
if len(results) < ef or d_nb < -results[0][0]:
heapq.heappush(candidates, (d_nb, nb))
heapq.heappush(results, (-d_nb, nb))
if len(results) > ef:
heapq.heappop(results) # forget the worst
print(f" expanded {node} (d={d:.2f}) keep {sorted((round(-x, 2), p) for x, p in results)}")
best = min((-x, p) for x, p in results)
return best[1], round(best[0], 2), len(visited)
query = (8, 6)
for ef in (1, 3):
print(f"ef={ef}")
print(" result:", search_layer(query, entry="D", ef=ef))
Predict first: with ef = 1 the walk is purely greedy. Where does it stop?
Check your answer
It stops at H, 5.39 away, while J is 1.00 away:
ef=1
expanded D (d=8.94) keep [(7.62, 'B')]
expanded B (d=7.62) keep [(5.39, 'H')]
expanded H (d=5.39) keep [(5.39, 'H')]
result: ('H', 5.39, 6)
H's other neighbors are E (5.83) and F (5.66), and B, where the walk came from, is 7.62 away. All of them are farther from the query than H itself, so greedy search declares H the answer. The road to J runs through F, but you have to accept one step that looks worse before things improve. H is a local minimum: better than all of its neighbors, and still not the nearest point.
Now give it a beam of three:
ef=3
expanded D (d=8.94) keep [(7.62, 'B'), (8.54, 'L'), (8.94, 'D')]
expanded B (d=7.62) keep [(5.39, 'H'), (7.62, 'B'), (8.54, 'L')]
expanded H (d=5.39) keep [(5.39, 'H'), (5.66, 'F'), (5.83, 'E')]
expanded F (d=5.66) keep [(3.16, 'A'), (5.39, 'H'), (5.66, 'F')]
expanded A (d=3.16) keep [(1.0, 'J'), (3.0, 'I'), (3.16, 'A')]
expanded J (d=1.00) keep [(1.0, 'J'), (2.24, 'C'), (3.0, 'I')]
expanded C (d=2.24) keep [(1.0, 'J'), (2.24, 'C'), (3.0, 'I')]
expanded I (d=3.00) keep [(1.0, 'J'), (2.24, 'C'), (3.0, 'I')]
result: ('J', 1.0, 11)
Why it works. With three slots, F survives as a runner-up after H is expanded, so the search expands it next and finds the way down to A and J. The walk stops only when the closest unexpanded candidate is farther away than the worst of the ef results it keeps. From then on, nothing left in the queue can improve the answer. A bigger ef keeps more runner-up paths alive. It costs more distance computations: the last number in each result counts the visited points, 6 for ef = 1 and 11 for ef = 3.
On twelve points, visiting 11 is no better than a brute-force scan of 12. The payoff comes at scale: on the 522,931-vector test bench, an HNSW search with a beam of 64 computes about 895 distances per query, 0.17% of the corpus (FAISS counts them; see the M table below).
Layers: express lanes on top
A single-layer graph has a problem at scale: from a random starting point, the walk takes many short hops to cross the space. HNSW (Hierarchical Navigable Small World, Malkov & Yashunin, IEEE TPAMI 2020) stacks sparser graphs on top of the full one.
When a vector is inserted, it draws a random top layer. It reaches layer 1 with probability 1/M, layer 2 with probability 1/M², and so on. Here is what that produced in a FAISS HNSW index with M = 16 over the test bench:
layer 5 1 vector <- the entry point
layer 4 10 vectors
layer 3 109
layer 2 2,020
layer 1 32,787 (about n / 16)
layer 0 522,931 every vector, up to 2·M = 32 links each
A query enters at the top and walks greedily (a beam of 1) on each sparse layer. Because those layers hold few vectors, each hop covers a long distance. The best point found on one layer becomes the entry to the layer below. On layer 0, which holds everything, the search runs the beam search above with a beam of efSearch. The upper layers get the walk to the right neighborhood; layer 0 does the careful local work.
Insertion uses the same search. The new vector looks for its neighbors with a beam of efConstruction, keeps a few of them (at most M on each upper layer), and the neighbors link back to it. Layer 0 allows up to 2·M links per node: FAISS picks up to 2·M at insertion, while hnswlib picks M and lets later nodes' back-links fill the rest. Both reserve memory for all 2·M slots. The chosen neighbors are not simply the closest ones. A selection heuristic skips a candidate that is closer to an already-chosen neighbor than to the new vector. That spreads a vector's links in different directions instead of spending them all inside one tight cluster. The paper reports that the heuristic matters most at high recall and on clustered data.
Back to the failed query. At ef = 10, all ten results for "How can I remove humidity in laptop?" were weight-loss questions ("How can I reduce 3 kgs in 7 days?"), with cosines between 0.224 and 0.257. Quora holds a big, tight family of them: 1,227 questions match a simple weight-loss pattern, and their average pairwise cosine is 0.43, against 0.04 for random pairs. The walk ended inside that family. With only ten slots, every candidate it held was a family member whose links led mostly to other family members, none closer to the query, so the stop rule fired: the same local minimum as H, in 384 dimensions. At ef = 16, the saved index already found the 0.996 duplicate.
The three knobs
| Knob | When it is set | What it controls | Library names |
|---|---|---|---|
M |
Build | Links per vector per layer (up to 2·M on layer 0) | hnswlib M, FAISS M, Qdrant m, pgvector m |
efConstruction |
Build | Beam width while inserting, so the quality of the links | hnswlib ef_construction, FAISS efConstruction, Qdrant ef_construct, pgvector ef_construction |
efSearch |
Every query | Beam width on layer 0 while searching | hnswlib ef (via set_ef), FAISS efSearch, Qdrant hnsw_ef, pgvector hnsw.ef_search |
Here is the query knob on the test bench, with hnswlib's default build (M = 16, ef_construction = 200):
import hnswlib
hnsw = hnswlib.Index(space="cosine", dim=X.shape[1])
hnsw.init_index(max_elements=len(X), M=16, ef_construction=200) # hnswlib's defaults
t = time.perf_counter()
hnsw.add_items(X, np.arange(len(X))) # uses all cores
print(f"build {time.perf_counter() - t:.0f} s; default ef = {hnsw.ef}")
for ef in (10, 16, 32, 64, 128, 256):
hnsw.set_ef(ef)
found, _ = hnsw.knn_query(Qs, k=10, num_threads=1)
p50, _ = latency_ms(lambda q: hnsw.knn_query(q, k=10, num_threads=1))
print(f"ef={ef:3d} recall@10={ann_recall(found, gt):.3f} p50={p50:.2f} ms")
hnsw.save_index("quora_hnsw.bin")
reloaded = hnswlib.Index(space="cosine", dim=X.shape[1])
reloaded.load_index("quora_hnsw.bin")
print(f"ef before save: {hnsw.ef}, after load: {reloaded.ef}")
# build 201 s; default ef = 10
# ef= 10 recall@10=0.944 p50=0.13 ms
# ef= 16 recall@10=0.968 p50=0.17 ms
# ef= 32 recall@10=0.988 p50=0.27 ms
# ef= 64 recall@10=0.994 p50=0.44 ms
# ef=128 recall@10=0.997 p50=0.78 ms
# ef=256 recall@10=0.998 p50=1.46 ms
# ef before save: 256, after load: 10
Every row is the same index. Going from ef = 10 to ef = 64 bought 5.0 points of recall for about 3.4× the latency, and it still ran about 70× faster than the exact scan. Past ef = 128, the extra time buys almost nothing.
The last line is the trap from the opening: the value you set is gone after a reload.
Reproduce the opening failure
Now run the same index over all 10,000 labeled test queries, and print the query whose ANN #1 fell furthest below the exact #1:
relevant = [qrels[q] for q in test_ids]
def hits(found, k=10):
"""How many labeled duplicates each query has in its top k."""
return np.array([len(rel & set(map(int, f[:k]))) for rel, f in zip(relevant, found)])
def ir_recall(found, k=10):
"""IR recall@k: share of each query's labeled duplicates in its top k, averaged."""
return np.mean(hits(found, k) / np.array([len(rel) for rel in relevant]))
faiss.omp_set_num_threads(12)
exact_scores, exact_top = flat.search(Q, 10) # all 10,000 test queries
print(f"exact: IR recall@10 = {ir_recall(exact_top):.3f}")
for ef in (10, 64):
hnsw.set_ef(ef)
found, dist = hnsw.knn_query(Q, k=10)
lost = (hits(found) < hits(exact_top)).sum() # queries that lost a duplicate
print(f"ef={ef:2d}: IR recall@10 = {ir_recall(found):.3f} "
f"ANN recall@10 = {ann_recall(found, exact_top):.3f} lost a duplicate: {lost}")
if ef == 10:
i = np.argmax(exact_scores[:, 0] - (1 - dist[:, 0])) # biggest shortfall
print(" query:", queries[test_ids[i]])
print(f" ANN #1 ({1 - dist[i, 0]:.3f}):", texts[found[i, 0]])
print(f" exact #1 ({exact_scores[i, 0]:.3f}):", texts[exact_top[i, 0]])
# exact: IR recall@10 = 0.950
# ef=10: IR recall@10 = 0.924 ANN recall@10 = 0.946 lost a duplicate: 310
# query: How can I remove humidity in laptop?
# ANN #1 (0.257): How can I reduce my weight from 80 kg to 65 kg in 1 month?
# exact #1 (0.996): How can I remove humidity in a laptop?
# ef=64: IR recall@10 = 0.948 ANN recall@10 = 0.995 lost a duplicate: 30
These are the numbers in the "Two recalls" table, and the printed query is the one from the opening. Your rebuilt graph will differ, because multi-threaded builds are not deterministic, so a different handful of queries will fail for you. At ef = 10, 43 of the 10,000 queries had a best ANN result below cosine 0.5 while brute force had one above 0.8.
Now the build knobs. This table uses FAISS's IndexHNSWFlat with efConstruction = 100, because FAISS counts the distance computations each query performs. That count is the same on any machine, unlike milliseconds:
| M | Build, 12 threads | Bytes per vector | Recall@10, efSearch 16 | efSearch 64 | efSearch 256 | efSearch to reach 0.99 (distances computed) |
|---|---|---|---|---|---|---|
| 8 | 67 s | 1,617 | 0.920 | 0.976 | 0.991 | 256 (1,864) |
| 16 | 107 s | 1,680 | 0.970 | 0.990 | 0.997 | 64 (895) |
| 32 | 110 s | 1,808 | 0.982 | 0.994 | 0.997 | 32 (704) |
Three things to read off it:
- A small M is not a recall ceiling. The
M = 8graph still reaches 0.991 atefSearch = 256. It just needs a wider beam, and so more distance computations, to get there. RaiseefSearchbefore you schedule a rebuild. - A bigger M buys cheaper queries at high recall. It costs link memory, and every insert does more work. Build times on our busy machine were too noisy to separate
M = 16fromM = 32. - The build is the slow part. It runs once per index, but it runs again after every model change, and the graph needs RAM while it grows.
The hnswlib authors' parameter guide suggests M between 12 and 48 for most data, and gives a check for efConstruction: measure recall for the M nearest neighbors (k = M) with ef = ef_construction. If it is below 0.9, a better build is possible.
Four traps with the query knob
efis not stored in the index. The hnswlib README says so, and the reload above shows it. Callset_efafter everyload_index, with the value read from configuration.- Defaults differ by library. hnswlib searches with
ef = 10. FAISSIndexHNSWFlatusesefSearch = 16andefConstruction = 40. pgvector's HNSW usesm = 16,ef_construction = 64andhnsw.ef_search = 40(README). Qdrant builds withm = 16,ef_construct = 100. None of these defaults was tuned on your data. efbelowk. hnswlib quietly searches with the larger ofefandk: on the test bench,ef = 5andef = 50gave identical results fork = 50. FAISS usesefSearchas given. WithefSearch = 5andk = 50, recall@50 fell to 0.541, and 3 of the 1,000 queries came back with fewer than 50 real ids (FAISS pads the rest with −1).set_efchanges the whole index. In hnswlib it is shared state, so two request tiers cannot use different values at the same time. FAISS takes a per-callfaiss.SearchParametersHNSW(efSearch=...), and Qdrant takeshnsw_efper request.
Memory: next to float32 vectors, the links are the small part
The FAISS index above used 1,680 bytes per vector at M = 16: 1,536 for the float32 vector (384 × 4), 128 for the layer-0 links (2·M = 32 ids of 4 bytes), and the rest for upper layers and bookkeeping. A working estimate is n × (dim × 4 + 2·M × 4) bytes. For 10 million 768-d vectors at M = 16, that is 30.72 GB of vectors plus 1.28 GB of links, about 32 GB. The links only become a large share when vectors are short: at 128 dimensions they add 25%.
So while the vectors are float32, lowering M barely shrinks an index of modern embeddings. Compressing the vectors (Move 4) is what moves the total, and once they are compressed, the balance flips: the links become the big part.
Deletes: tombstones, not holes
Deleting a node from a graph would cut the paths that run through it. So hnswlib's mark_deleted keeps the node in the graph as a stepping stone and only hides it from results. Here is what that did on the test bench (hnswlib, ef = 32, recall measured against an exact search over the surviving vectors):
| State | Recall@10 | p50 latency | Vectors held in memory |
|---|---|---|---|
| No deletes | 0.987 | 0.28 ms | 522,931 |
| 30% marked deleted | 0.990 | 0.36 ms | 522,931 |
| 60% marked deleted | 0.992 | 0.54 ms | 522,931 |
| Rebuilt from the surviving 40% | 0.982 | 0.27 ms | 209,173 |
Recall held up, because the search simply walks further to fill its ten slots. The costs are memory that never comes back and latency that grows with the dead weight. hnswlib can reuse deleted slots (allow_replace_deleted=True at init, then add_items(..., replace_deleted=True)). FAISS's HNSW index has no remove_ids at all (guidelines); you rebuild. Vector databases rebuild in the background by rewriting segments, as Vector Database Architecture explains, and some graph designs target streaming updates directly, such as FreshDiskANN (Singh et al., 2021).
Your turn: a reranker now needs the top 100 candidates instead of the top 10, from the same hnswlib index running with ef = 64. What happens to the effective beam, what should you measure, and what will probably happen to latency?
Check your answer
hnswlib searches with max(ef, k) = 100, so the effective beam grows on its own, and latency grows with it (compare the ef = 64 and ef = 128 rows above). Measure recall@100 against an exact top-100, because the reranker can only reorder what it receives. Then decide whether 100 is enough or whether you need a larger beam. On FAISS, nothing grows automatically: set efSearch to at least 100 yourself.
Move 3: Search only a few neighborhoods (IVF)
Symptom: the graph takes too long to build or rebuild, or you want an index that pairs naturally with compression.
An inverted file index (IVF) sorts the corpus into buckets before any query arrives:
- Train: run k-means on a sample of the vectors to find
nlistcentroids. - Add: file every vector in the list of its nearest centroid.
- Search: compare the query with all
nlistcentroids, pick thenprobenearest, and scan only those lists exhaustively.
cell 1 cell 2 nprobe = 2: scan cells 2 and 4 only
. . . . | . . . . .
. c1 . | . c2 . . c = centroid, q = query, * = true neighbor
. . . | . . q . .
------------+------------------ the true neighbor sits just across the
. . . | . . * . border, in cell 4, so nprobe = 1 misses it
. c3 . | . c4 . .
. . . . | . . . . .
cell 3 cell 4
The boundary is why nprobe = 1 is rarely enough: a query near the edge of its cell has neighbors in the next cell. Here is the sweep on the test bench, with FAISS and nlist = 1024:
dim = X.shape[1]
ivf = faiss.IndexIVFFlat(faiss.IndexFlatIP(dim), dim, 1024, faiss.METRIC_INNER_PRODUCT)
faiss.omp_set_num_threads(12)
t = time.perf_counter()
ivf.train(X) # k-means: learn 1,024 centroids
ivf.add(X) # file every vector under its nearest centroid
print(f"train + add {time.perf_counter() - t:.0f} s")
faiss.omp_set_num_threads(1)
for nprobe in (1, 4, 16, 64, 128):
ivf.nprobe = nprobe
faiss.cvar.indexIVF_stats.reset()
_, found = ivf.search(Qs, 10)
scanned = faiss.cvar.indexIVF_stats.ndis / len(Qs)
p50, _ = latency_ms(lambda q: ivf.search(q[None], 10))
print(f"nprobe={nprobe:3d} recall@10={ann_recall(found, gt):.3f} "
f"scanned {scanned:6,.0f} vectors ({scanned / len(X):.2%}) p50={p50:.2f} ms")
# train + add 7 s
# nprobe= 1 recall@10=0.641 scanned 621 vectors (0.12%) p50=0.12 ms
# nprobe= 4 recall@10=0.854 scanned 2,324 vectors (0.44%) p50=0.13 ms
# nprobe= 16 recall@10=0.946 scanned 8,771 vectors (1.68%) p50=0.40 ms
# nprobe= 64 recall@10=0.982 scanned 34,051 vectors (6.51%) p50=1.41 ms
# nprobe=128 recall@10=0.990 scanned 67,501 vectors (12.91%) p50=2.88 ms
On top of the vectors in the lists, every query also compares itself with the 1,024 centroids. Now compare with the graph. To reach recall@10 ≈ 0.98, IVF scanned about 34,000 vectors per query, while HNSW at M = 16 computed 557 distances (recall 0.986 at efSearch = 32). IVF's scans are sequential reads, which CPUs handle very efficiently, so the latency gap (1.41 ms against 0.27 ms for hnswlib at ef = 32) is much smaller than the 60× count gap. On this data the graph still wins per query. IVF's advantages lie elsewhere:
- Fast builds. Training and adding took 7 s, against 201 s for the hnswlib graph.
- Small overhead: one 8-byte id per vector in FAISS, plus the centroids.
- Easy updates: new vectors go into the list of their nearest centroid, and FAISS IVF indexes support
remove_ids. - A natural partner for compression. Each list can store compressed codes instead of vectors (Move 4).
Choosing nlist. FAISS's guidelines suggest 4·√n to 16·√n centroids below a million vectors, trained on 30 to 256 vectors per centroid; pgvector's README suggests rows / 1000 lists up to a million rows and √rows above. For the test bench that is about 2,900 to 11,600 centroids by FAISS's rule and 523 by pgvector's; the 1,024 used here sits between them. Treat both rules as starting points for a sweep. FAISS warns when the sample is too small: 4,096 centroids trained on 50,000 vectors printed please provide at least 159744 training points (39 per centroid).
When the centroids go stale
The centroids describe the data they were trained on. Add a new kind of content later and it all lands in whichever old cells happen to be nearest. To test that, the same index was trained twice, on 60,000 vectors each time, and then filled with the whole corpus:
| Trained on | Largest list | Imbalance | Recall@10, nprobe = 16 | Vectors scanned per query |
|---|---|---|---|---|
| A random 60,000 | 1,483 | 1.22 | 0.941 | 8,943 |
| The 60,000 nearest to "What is the meaning or purpose of life?" | 5,021 | 2.19 | 0.926 | 11,622 |
FAISS's imbalance factor is 1.0 when all lists are equal. With skewed training, the centroids crowd into one topic while everything else shares a few oversized cells. Each probe then scans more vectors and still finds fewer of the true neighbors. nprobe can buy some of that back, but only retraining on a representative sample fixes it. Retraining means rebuilding the index.
Predict: what does IVF-Flat return with nprobe = nlist?
Check your answer
The brute-force scores, at the cost of a full scan plus the centroid comparisons: a slower IndexFlat. That makes it a sanity check for your recall code. On the test bench it gave recall@10 of 0.9998 or 0.9999, depending on the run. Every score matched, but tenth place was sometimes a tie between two questions with identical scores, and the two indexes broke the tie differently. Quora contains exact duplicate questions. Anything clearly below 1 means a bug in your measurement.
Move 4: Shrink the vectors, then rescore
Symptom: the index does not fit in RAM, or RAM is most of the bill.
In both structures above, the float32 vectors dominate memory. Here is the arithmetic for 10 million 768-d chunks:
n, dim, M = 10_000_000, 768, 16
sizes = {
"float32 vectors": n * dim * 4,
"HNSW links (2*M ids on layer 0)": n * 2 * M * 4,
"int8 scalar codes": n * dim * 1,
"binary codes (1 bit per dim)": n * dim // 8,
"PQ codes, m=96 x 8 bits": n * 96,
}
for name, b in sizes.items():
print(f"{name:33s} {b / 1e9:6.2f} GB")
# float32 vectors 30.72 GB
# HNSW links (2*M ids on layer 0) 1.28 GB
# int8 scalar codes 7.68 GB
# binary codes (1 bit per dim) 0.96 GB
# PQ codes, m=96 x 8 bits 0.96 GB
That is 30.72 GB (about 28.6 GiB) of float32 against under a gigabyte of binary or PQ codes. Now look at the links row again: a rounding error next to float32, but next to 0.96 GB of codes, the 1.28 GB of links are the largest part of the index. On the test bench it is 128 B of links against 48 B of codes per vector, 73% of the total. After compression, lowering M starts to matter, and the graph is what eventually outgrows RAM. Move 5 deals with that.
Three ways to compress:
- Scalar quantization (int8). Map each dimension's range onto 256 levels: one byte per dimension, 4× smaller.
- Binary quantization. Keep only the sign of each dimension: one bit, 32× smaller. Distances become Hamming distances (XOR and popcount), which are very fast.
- Product quantization (PQ). Cut each vector into
msub-vectors and replace each with the id of its nearest centroid from a 256-entry codebook for that slice:mbytes per vector (Jégou, Douze & Schmid, IEEE TPAMI 2011).
PQ from scratch, and the asymmetric trick
PQ is less mysterious once you build it yourself. This version splits the 384 dimensions into 48 slices of 8 and learns 256 centroids per slice:
m, dsub = 48, X.shape[1] // 48 # 48 sub-vectors of 8 dimensions
train = X[np.random.default_rng(1).choice(len(X), 50_000, replace=False)]
codebooks = []
codes = np.empty((len(X), m), dtype=np.uint8)
for j in range(m):
part = slice(j * dsub, (j + 1) * dsub)
km = faiss.Kmeans(dsub, 256, niter=20, seed=j) # 256 centroids -> 1 byte
km.train(np.ascontiguousarray(train[:, part]))
codebooks.append(km.centroids) # shape (256, 8)
codes[:, j] = km.index.search(np.ascontiguousarray(X[:, part]), 1)[1][:, 0]
print(f"{codes.nbytes / len(X):.0f} bytes per vector instead of {X[0].nbytes}")
def pq_search(q, k=10):
# Asymmetric distance: the query stays float32. One (48 x 256) table of
# partial inner products per query, then 48 lookups + adds per vector.
table = np.stack([codebooks[j] @ q[j * dsub:(j + 1) * dsub] for j in range(m)])
scores = table[np.arange(m), codes].sum(axis=1)
return np.argpartition(-scores, k)[:k]
def pq_then_rescore(q, k=10, candidates=100):
cand = pq_search(q, candidates)
return cand[np.argsort(-(X[cand] @ q))[:k]] # exact scores for the shortlist
sub = slice(0, 200) # 200 queries keep this quick
print(f"PQ alone: recall@10 = {ann_recall([pq_search(q) for q in Qs[sub]], gt[sub]):.3f}")
for c in (40, 100):
found = [pq_then_rescore(q, candidates=c) for q in Qs[sub]]
print(f"PQ top-{c:3d} + rescoring: recall@10 = {ann_recall(found, gt[sub]):.3f}")
# 48 bytes per vector instead of 1536
# PQ alone: recall@10 = 0.783
# PQ top- 40 + rescoring: recall@10 = 0.989
# PQ top-100 + rescoring: recall@10 = 0.999
The query is never quantized, and that is the asymmetric part. For each query you compute 48 × 256 partial scores once, one per slice and centroid. After that, scoring a stored vector means looking up its 48 codes in the table and adding the results: no multiplications, and 48 bytes read instead of 1,536. Jégou et al. showed that keeping the query exact gives better estimates than quantizing both sides.
Look at the numbers. PQ alone lost a lot of recall. Rescoring a shortlist with the original vectors brought almost all of it back. That pattern, cheap codes to shortlist, exact vectors to decide, is how quantization is used in practice.
Binary: 32× smaller, and rescoring does the rest
faiss.omp_set_num_threads(12)
bits = np.packbits(X > 0, axis=1) # sign of each dimension: 384 dims -> 48 bytes
binary = faiss.IndexBinaryFlat(X.shape[1])
binary.add(bits)
_, cand = binary.search(np.packbits(Qs > 0, axis=1), 100) # Hamming distance
print(f"binary alone: recall@10 = {ann_recall(cand, gt):.3f}")
for c in (40, 100):
found = [ids[:c][np.argsort(-(X[ids[:c]] @ q))[:10]] for ids, q in zip(cand, Qs)]
print(f"binary top-{c:3d} + rescoring: recall@10 = {ann_recall(found, gt):.3f}")
# binary alone: recall@10 = 0.717
# binary top- 40 + rescoring: recall@10 = 0.954
# binary top-100 + rescoring: recall@10 = 0.986
The full comparison on the test bench. These are flat scans over the codes, and rescoring uses the float32 originals. The PQ rows come from FAISS's IndexPQ on all 1,000 queries, so they differ slightly from the 200-query run above:
| Encoding | Bytes per vector | Smaller by | Recall@10 alone | Rescore top 40 | Rescore top 100 |
|---|---|---|---|---|---|
| float32 | 1,536 | 1× | 1.000 | ||
| int8 scalar | 384 | 4× | 0.995 | 0.999 | 1.000 |
| PQ, m = 96 | 96 | 16× | 0.866 | 0.999 | 1.000 |
| PQ, m = 48 | 48 | 32× | 0.766 | 0.987 | 0.998 |
| binary (sign bits) | 48 | 32× | 0.717 | 0.954 | 0.986 |
| PQ, m = 24 | 24 | 64× | 0.619 | 0.899 | 0.964 |
Binary codes alone do poorly here, and at 384 dimensions that is expected: Qdrant's quantization guide says one-bit codes lost significant precision on vectors smaller than about a thousand dimensions. You have two levers. The first is the one above: shortlist more, then rescore. The second is a better code:
- More bits per dimension. Qdrant (v1.15+) offers 1.5-bit and 2-bit binary encodings.
- Asymmetric encoding. The stored vectors stay binary while the query is encoded with more bits (Qdrant v1.15+, 8 or 4 bits), the same idea as PQ's asymmetric distance above.
- Rotation first. RaBitQ (Gao & Long, SIGMOD 2024) rotates the vectors randomly before quantizing them. It is available in FAISS as
IndexRaBitQ, and it scored 0.798 with 56-byte codes on the test bench, and 0.992 after rescoring the top 40. Qdrant v1.18 added TurboQuant, another rotation-based scheme, with 1 to 4 bits per dimension.
Configuring Qdrant's options is part of Advanced Qdrant. Shorter vectors from a Matryoshka-trained model are another lever, covered in Vector Embeddings.
IVF-PQ: the recall that nprobe cannot buy
IVF and PQ combine naturally: the lists hold PQ codes of each vector's offset from its centroid. Watch what nprobe can and cannot do:
dim = X.shape[1] # 384 again (the memory example set it to 768)
ivfpq = faiss.IndexIVFPQ(faiss.IndexFlatIP(dim), dim, 1024, 48, 8, faiss.METRIC_INNER_PRODUCT)
ivfpq.train(X)
refined = faiss.IndexRefineFlat(ivfpq) # wrap BEFORE adding: it keeps a float32 copy
refined.add(X)
refined.k_factor = 10 # rescore 10 x k candidates with exact scores
faiss.omp_set_num_threads(1)
for nprobe in (4, 16, 64, 256):
ivfpq.nprobe = nprobe
_, a = ivfpq.search(Qs, 10)
_, b = refined.search(Qs, 10)
print(f"nprobe={nprobe:3d} IVF-PQ recall@10={ann_recall(a, gt):.3f} with rescoring={ann_recall(b, gt):.3f}")
print(f"bytes per vector: IVF-PQ {faiss.serialize_index(ivfpq).nbytes / len(X):.0f}, "
f"IVF-PQ + float32 copy {faiss.serialize_index(refined).nbytes / len(X):.0f}")
# nprobe= 4 IVF-PQ recall@10=0.696 with rescoring=0.853
# nprobe= 16 IVF-PQ recall@10=0.730 with rescoring=0.942
# nprobe= 64 IVF-PQ recall@10=0.735 with rescoring=0.973
# nprobe=256 IVF-PQ recall@10=0.737 with rescoring=0.982
# bytes per vector: IVF-PQ 60, IVF-PQ + float32 copy 1596
Two lessons in five lines of output:
- IVF-PQ alone plateaus. Probing more cells cannot fix an error that lives in the codes. If recall stalls while you raise
nprobe, you have hit the quantization floor. Add rescoring or use bigger codes. - Rescoring from RAM gives up the memory savings.
IndexRefineFlatkeeps every float32 vector in memory, so the index grows from 60 to 1,596 bytes per vector. That setup still saves compute, but it does not save RAM. To save RAM, the originals must live on disk and be read only for the shortlist.
⚠️ Build order trap: creating IndexRefineFlat around an index that already holds vectors raises 'base_index->ntotal == refine_index->ntotal' failed. Wrap the trained, empty index first, then add through the wrapper.
Where do the originals live?
query ──► quantized index in RAM ──► top k_candidates ids ──► read those originals ──► exact scores ──► top k
(int8 / binary / PQ codes, (e.g. 100) (RAM, or SSD: 100 reads (100 dot products)
plus graph or IVF lists) of 1.5 to 4 KB each)
With originals on SSD, rescoring 100 candidates of 768-d float32 reads 100 × 3,072 B ≈ 307 KB per query, as 100 small random reads. That is cheap on local NVMe and slow on network-attached disks. Measure it on your hardware. Vector databases expose this pattern directly. Qdrant, for example, has quantization with oversampling and rescore settings, and its production tuning is covered in Advanced Qdrant.
Your turn: 50 million 1,024-d vectors on a machine with 64 GB of RAM and a local NVMe drive. You need recall@10 ≥ 0.95 against exact search. Which configuration fits?
Check your answer
float32 is 50,000,000 × 1,024 × 4 = 204.8 GB, so it cannot sit in RAM. int8 codes are 51.2 GB, plus about 6.4 GB of HNSW links at M = 16 (50M × 128 B): 57.6 GB. That is too tight for 64 GB once the OS, the process and the page cache need room. Binary codes (6.4 GB) or PQ codes with m = 128 (6.4 GB), plus the index structure, fit easily. Note that the HNSW links (6.4 GB) now weigh as much as the codes. Neither reaches 0.95 on its own, so shortlist 100 to 200 candidates and rescore with float32 originals read from the NVMe drive. Then verify: sweep the shortlist size on held-out queries and check that recall@10 reaches 0.95 at your p95 budget, with the SSD reads included. A disk-resident graph (next move) is the other valid design.
Move 5: Past RAM, and the family tree
Symptom: even with compressed codes, a billion-vector index outgrows one machine's memory, or the RAM bill dominates.
Graphs on SSD: DiskANN
After Move 4, the graph is the biggest thing left in RAM. At a billion vectors, 128 B of links per vector (M = 16) is 128 GB before a single code is stored. DiskANN (Subramanya et al., NeurIPS 2019) puts the graph itself on disk:
RAM: PQ codes for every vector ──► guide the walk (approximate distances)
│ which nodes to expand next?
SSD: for each node, one block: [ full float32 vector | its neighbor ids ]
│ one read returns both
▼
beam search reads a few blocks per round trip, then ranks the
visited nodes by their full vectors (the rescoring is "free")
Its graph, Vamana, is a single layer built with a pruning rule that deliberately keeps some long-range edges, so a search needs fewer hops, and every hop costs an SSD read. Because each node's full vector sits in the same disk block as its neighbor list, the rescoring step reads nothing extra. The paper reports a billion-point index served from a workstation with 64 GB of RAM and an SSD, at 95%+ recall@1, more than 5,000 queries per second and under 3 ms mean latency on the SIFT1B benchmark, using a 16-core machine. That is one dataset on the authors' hardware; your numbers come from your own test.
As of 2026 you meet the idea as a DISKANN index type in Milvus (PQ codes in memory, graph on NVMe) and as StreamingDiskANN in the pgvectorscale extension for Postgres. Which product to choose is a question for Vector DB Selection.
ScaNN, from Google (Guo et al., ICML 2020), is the other name you will see. Its search runs in three phases: pick a few partitions, as IVF does, score their points with quantized codes, then recompute the scores of the best candidates more accurately. Its twist is anisotropic quantization: for inner-product search, it penalizes the part of a vector's quantization error that is parallel to the vector more than the part orthogonal to it. For the queries that match a vector best, error along the vector's own direction moves the score the most.
The family tree: trees and hashing
Two older families still show up in blog posts and older code:
- Trees. k-d trees are exact but degrade in high dimensions, as Move 1 showed. Spotify's Annoy builds a forest of random-projection trees instead: each split is the hyperplane halfway between two sampled points. Its index is a read-only file that many processes can memory-map and share, and you cannot add items once it is built. In October 2023 Spotify released Voyager, based on hnswlib, "intended to succeed Annoy" for production use. Spotify reports more than 10× Annoy's speed at the same recall.
- Locality-sensitive hashing (LSH). Hash functions chosen so that close vectors collide more often than distant ones (Indyk & Motwani, STOC 1998). For cosine similarity, one hash bit is the side of a random hyperplane a vector falls on (Charikar, STOC 2002). Binary quantization's sign bits are the same trick, with fixed hyperplanes (one perpendicular to each coordinate axis) instead of random ones. MinHash for set similarity (Broder, 1997) belongs to the same family. LSH comes with provable guarantees, and inserts are easy, but high recall needs many hash tables and so a lot of memory. On dense text embeddings, graph and quantization indexes have largely replaced it.
Filters change the picture
Real queries usually carry filters: tenant, language, date. A filter that removes 98% of the corpus interacts badly with an index that returns a fixed top k before filtering, and graph walks can lose connectivity when most nodes are off limits. Pre-, post- and in-graph filtering are the subject of Metadata & Filtering, including how Qdrant's planner chooses between an exact scan of the matching points and a filter-aware graph walk (it does not post-filter), and why its payload index must exist before the graph is built. Benchmark with the filters you will actually use.
Move 6: Benchmark it on your own data
Symptom: a benchmark chart promised 0.99 recall, and your system does not feel like 0.99.
What ann-benchmarks is, and is not
ann-benchmarks (Aumüller, Bernhardsson & Faithfull, Information Systems) runs many libraries on public datasets such as SIFT, GloVe and GIST. It plots recall against queries per second, one query at a time on one CPU. The current published results date from April 2025. Read it like this: pick your recall target on the x-axis and compare the throughput of the curves there. Use it to shortlist two or three candidates. It cannot tell you how those candidates behave on your embeddings, with your filters, your update rate and your hardware.
The same index, four data sets
Identical FAISS HNSW settings (M = 16, efConstruction = 100), 1,000 queries each:
| Data | n | Recall@10, efSearch 16 | 64 | 256 |
|---|---|---|---|---|
| Quora, held-out test queries | 522,931 | 0.970 | 0.990 | 0.997 |
| Quora, queries copied from the index | 522,931 | 0.972 | 0.992 | 0.996 |
| 20 Newsgroups paragraphs (MiniLM), held-out | 73,053 | 0.886 | 0.972 | 0.990 |
| Random unit vectors, same n and dim | 522,931 | 0.007 | 0.019 | 0.067 |
The newsgroup rows come from all 18,846 posts in scikit-learn's fetch_20newsgroups, with headers, footers and quotes removed, split at blank lines, keeping paragraphs of 40 to 1,000 characters (74,053, not deduplicated), of which 1,000 random ones were held out as queries.
Four lessons:
- Real data differs. At the same low
efSearch, the longer, more varied newsgroup paragraphs were harder than short Quora questions, even with seven times fewer vectors. ByefSearch = 256the gap nearly closes, and how big it looks depends on how you cut the paragraphs. A setting tuned on one dataset is a guess on another. - Random vectors are useless for recall. With no neighbor structure, the same index scores close to zero. They are fine for timing memory bandwidth and useless for choosing
efSearch. - Queries copied from the index look a little better than real ones, but on this data only a little. Each copy's exact #1 is itself. On 5,000 queries of each kind at
efSearch = 10, copies scored recall@1 0.983 and recall@10 0.954, against 0.970 and 0.944 for held-out queries; byefSearch = 32the gap was gone. But Quora's documents are questions, just like its queries. In RAG, chunks are passages and queries are short questions, so recall measured with chunks as queries need not carry over to real questions. That, more than self-matching, is why you test with real queries. - Averages hide tails. Report the share of queries below some recall (say 0.8), and read a sample of the worst ones. That is how you would find the trapped queries from the opening.
A measurement plan you can reuse
- Queries: 500 to 1,000 real queries from logs, held out from the index. If you have no logs, write a few hundred questions of the kind your users will ask about your content, or have an LLM draft them and a person spot-check them. Chunks that were never indexed avoid self-matching, but they are still passages, not questions.
- Ground truth: exact top-k by brute force, computed offline in batches. Inside a vector database, use its exact mode (Qdrant:
SearchParams(exact=True)). - Sweep the query knob (
efSearch,nprobe, the rescoring shortlist). For each setting, record ANN recall@k, p50/p95/p99 latency of single queries at your production concurrency, and memory. - Pick the cheapest setting that meets the recall target inside the latency budget. Write it into configuration, including after reloads.
- Re-measure after anything changes: data volume, embedding model, filters, delete rate, library version. Track IR recall on a labeled set separately, because that one measures the model, not the index.
Final round: no label on the symptom
Real incidents do not say which move they need. Start from the measurement, name the symptom, then pick the move.
Challenge 1: the nightly purge
A news-search service uses hnswlib and removes expired articles every night with mark_deleted. After three months, 70% of the vectors in the index are expired. Recall against an exact search over the live articles is still fine, but p50 latency has roughly doubled, and the process has not given back any memory. Diagnose it, and fix it.
Check your answer
Tombstones. Deleted nodes stay in the graph and in memory, so the walk passes more and more dead nodes before it has ten live results. On the test bench, 60% deletions took p50 from 0.28 to 0.54 ms with no recall loss. Rebuild from the live vectors once the deleted share passes a threshold, or reuse dead slots with replace_deleted=True. Track the deleted share, p50 and memory over time.
Challenge 2: recall that will not move
An IVF-PQ index (m = 32) gives recall@10 of 0.71 at nprobe = 16, 0.73 at 64 and 0.73 at 256. The team proposes nprobe = 1024. What do you tell them?
Check your answer
The curve has flattened: the missing recall is quantization error in the codes, not unprobed cells. More probes cost latency and buy nothing. Rescore a shortlist (say 10 × k) with the original vectors, or use larger codes, and check where the originals live, because rescoring from RAM gives up the memory savings.
Challenge 3: two results instead of ten
One HNSW index holds 2 million chunks for many tenants. A small tenant owns 1% of them. The service asks the index for the top 100, drops chunks that belong to other tenants, and returns the best 10 that remain. Some of that tenant's queries return only one or two results, and not its best ones. What is going on, and where do you look next?
Check your answer
Post-filtering. About 1% of 100 candidates, one chunk, belongs to the tenant, and the tenant's best chunks may not be in the global top 100 at all. Asking for more candidates helps only in proportion and costs latency. Filter during the search instead (the engine's filtered search on an indexed tenant field; in Qdrant, create that payload index before the graph is built), or partition by tenant; Metadata & Filtering covers both. Measure results per query and recall against an exact search restricted to the tenant.
Challenge 4: minus ones in the candidate list
A reranker receives the top 100 candidates from a FAISS IndexHNSWFlat left at its default settings. The reranked results look worse than the team expected, and a few requests log candidate ids of −1. What is wrong?
Check your answer
FAISS searches with efSearch = 16 by default and, unlike hnswlib, does not raise the beam to k. A beam of 16 cannot deliver 100 good candidates. On the test bench, asking for the top 100 at efSearch = 16 gave recall@100 of 0.656 (0.980 at efSearch = 128), and 1 query in 5,000 came back with slots it could not fill, padded with −1. Set efSearch to at least k (for example 128 to 200, globally or per call with SearchParametersHNSW), measure recall@100 against an exact top-100, and drop −1 ids defensively.
Cheat sheet: symptom → move → cost
| Symptom | Move | You pay | Measure |
|---|---|---|---|
| Exact scan too slow per request | HNSW (graph) | About 2·M·4 B of links per vector, a slow build, recall below 1 | ANN recall@k vs efSearch, p95 |
| Recall too low on HNSW | Raise efSearch before rebuilding |
Latency per query | Recall and p95 at each step of a sweep |
ef "changed" after a restart |
set_ef after every load |
Nothing | A startup exact-vs-ANN check |
| Builds too slow, or need compression | IVF (nlist, nprobe) |
Training step, border misses | Recall vs nprobe, list sizes |
| New content, IVF slower or worse | Retrain centroids, rebuild | A rebuild | Imbalance factor, recall on new-content queries |
| Vectors do not fit in RAM | int8, binary or PQ codes + rescoring | Recall before rescoring, reads of the originals | Recall vs shortlist size, bytes per vector |
Recall flat as nprobe rises |
Rescore, or larger codes | Memory or SSD reads | The recall curve's plateau |
| Too big for RAM even compressed | Disk graph (DiskANN-style) | SSD reads per query | p95 with the disk included |
| Many deletes | Rebuild or replace deleted slots | Rebuild time | Memory, p50 over time |
| ANN recall high, users unhappy | Fix the model or add lexical retrieval | Not an index problem | IR recall on labeled queries |
Before moving on, take one index you run or plan to run and answer out loud: which structure it uses and why, which knob you would turn first if recall dropped, what that knob costs, where the original vectors live if you quantize, and the exact query set and ground truth you would use to prove the new setting is better. If you can do that, you can read any vector database's index settings and know what they trade.
Next: Vector DB Selection turns these trade-offs into a product choice, Qdrant shows the HNSW and quantization settings in one real engine, and Metadata & Filtering handles the filters every real query carries.