The lesson in one minute
What you'll be able to explain
- Flat compares the query with everything: exact, O(N). It's the ground truth, and fine for small collections.
- IVF clusters ahead of time and searches the
nprobenearest clusters. - PQ compresses each vector to a few bytes and scores by table lookup; re-score a shortlist with exact vectors.
- HNSW is a layered graph: long jumps on top, a careful beam search at the bottom.
efSearchtrades recall for latency;MandefConstructionset graph quality at build time. It's fast and accurate, but memory-hungry. - Always measure recall@k against a flat index on your own data while you turn the dial.
Level 1
The practitioner's guide
In one sentence
An approximate nearest neighbor (ANN) index finds the stored vectors most similar to a query without comparing it against every one, trading a small, measurable loss of accuracy for searches that stay fast and affordable as a collection grows to millions of vectors.
When you need it
Every search over embeddings ends the same way: the
question becomes a vector, and the answers are the stored vectors nearest to
it. The honest way to find them is a flat search, one comparison per
stored vector, and it is exact. It is also the ground truth every index is
judged against, and below about a million vectors it is often the right
choice: simple, exact, fast enough. You need an index when the collection
outgrows it. At 10 million vectors of 1,536 dimensions a flat search costs
about 15 billion multiply-adds per query, and the raw vectors alone take
about 61 GB of memory (this lesson's storage_estimate does the sum). The
tell: query latency grows in step with the number of documents, or the
vectors no longer fit in one machine's memory.
Your options
Four ideas cover almost every vector database in use, and a fifth takes them past the size of one machine's memory. From the simplest to the most scalable:
| Option | What it does | What it gives you | What it costs | Where it lives |
|---|---|---|---|---|
| Flat search | Compares the query with every stored vector | Exact results: recall 1.0, by definition | One comparison per stored vector per query; every vector in memory | NumPy, FAISS Flat, a pgvector column with no index |
| IVF (inverted file) | Sorts the vectors into clusters ahead of time; a query opens only the nprobe nearest clusters |
High recall at a fraction of the work, rising with nprobe until it equals a flat scan |
A k-means training step before the first insert, and retraining when the data drifts | FAISS IVF, pgvector ivfflat |
| PQ (product quantization) | Compresses each vector to a few bytes and scores by table lookup | A collection 8 to 64 times smaller in memory, and a good shortlist | Lossy scores: re-score the shortlist with exact vectors or recall suffers | FAISS IVF...,PQ, ScaNN |
| HNSW (layered graph) | Links each vector to a few neighbors on stacked layers; a query hops from coarse to fine | High recall at low latency, tuned per query with efSearch, with no training step |
Memory for every vector plus its links; slow builds at scale | hnswlib, FAISS HNSW, pgvector hnsw, Elasticsearch, Qdrant |
| Disk-resident graph | Keeps the graph and full vectors on an SSD and a compressed copy in memory | A billion vectors on one machine | SSD reads per query and a long build | DiskANN |
How to choose
Start from the size of the collection and the memory you have.
- Under about a million vectors: flat search. Measure it before you build anything; it may already be fast enough, and it is what you will compare every index against.
- Millions of vectors, memory to spare: HNSW. It is the default in most
vector databases because it gives high recall at low latency with no
training step. Set
Maround 16 (32 to 64 for high-dimensional data), build withefConstructionof 100 to 400, then tuneefSearchat query time. - Millions of vectors, memory tight: IVF with PQ codes, re-scoring a
shortlist with the exact vectors. Choose
nlistnear the square root of N (FAISS's guidelines say 4√N to 16√N below a million vectors, with 30 to 256 training vectors per cluster), then tunenprobe. - Billions of vectors: IVF-PQ, HNSW sharded across machines, or a disk-based graph. DiskANN indexes a billion points on one workstation with 64 GB of memory and an SSD.
- Whatever you pick, measure recall@k against a flat index on your own vectors while you turn the dial, then check the 95th-percentile latency. A benchmark on other data predicts little, because the shape of the data decides where the curve flattens.
What it costs
Three currencies: memory, build time and recall.
- Memory. Raw float32 vectors cost 4 bytes per dimension: about 3 GB for a
million 768-dimensional vectors, 3 TB for a billion. HNSW adds its links,
roughly
Mtimes 8 to 10 bytes per vector by hnswlib's estimate (1.69 MB against the flat index's 1.28 MB in this lesson's run on 5,000 vectors of 64 dimensions). PQ goes the other way: 96 one-byte codes for a 3,072-byte vector is a 32× saving, so a billion vectors fit in about 100 GB instead of 3 TB. - Build time. Flat builds instantly and IVF needs one k-means pass. HNSW inserts each vector by first searching for its neighbors, so a build is one search per vector: the slowest to build in this lesson's run, and pgvector documents the same trade (HNSW: a better speed-recall trade-off, slower builds, more memory; IVFFlat: the reverse).
- Recall. Every index has one dial, and the last few points of recall are
the expensive ones. In this lesson's run, HNSW at
efSearch10 reaches recall@10 of 0.49 while comparing 5.5% of the collection, and 0.99 at 160 while comparing 36%. IVF atnprobe1 gives 0.33, at 16 gives 0.96, and at 70 (every list) gives 1.0. PQ scores alone at 8 bytes per vector give 0.35; re-scoring the top 100 with exact vectors lifts that to 0.88. - Latency. This lesson's pure-Python timings are only relative; FAISS or hnswlib run the same searches around 100× faster. DiskANN reports more than 5,000 queries per second at under 3 ms mean latency on a billion points.
What breaks
- Recall you never measured. An index that scored 0.99 on a benchmark can do worse on your vectors, because ANN accuracy depends on the data's structure. Keep a flat index of a sample and measure recall@k against it.
- A neighbor across a cluster boundary. IVF's blind spot: the true
nearest vector sits in a cell the query didn't open. Raise
nprobe, and retrain the centroids when the data changes a lot. - Ranking by compressed scores. PQ is excellent at shortlisting and poor at final ranking: at 8 bytes per vector it finds under half of the true top 10 on its own. Always re-score the shortlist with the exact vectors.
- Running out of memory. HNSW must hold every vector and every link in memory. When it no longer fits, move to IVF-PQ, shard across machines, or use a disk-based graph.
- A greedy walk stuck in a corner. A search that only hops to closer
neighbors stops at a point with none closer even when a closer one
exists; the beam (
efSearch) protects against that, and can never be set below k. - A filter applied after the search. Keep only the results a user may see after asking for the top 10, and you can be left with none. Qdrant's documentation describes extra graph edges from indexed metadata so filters apply during the search; where your database filters afterwards, ask for more candidates than you need.
In the wild
FAISS, Meta's library, ships every index in this lesson
and publishes guidelines that pick one by collection size. hnswlib is the
reference HNSW implementation from the paper's authors, and its parameter
guide is where this lesson's knob table comes from. Databases have absorbed
the same indexes: pgvector adds hnsw (defaults m 16, ef_construction
64, ef_search 40) and ivfflat indexes to PostgreSQL; Elasticsearch's
dense_vector fields index with HNSW (m 16, ef_construction 100) and
offer quantized variants; Qdrant builds every collection on a filterable
HNSW (m 16, ef_construct 100). Google's ScaNN pairs partitioning with
anisotropic quantization and re-scoring. DiskANN (Subramanya et al.,
NeurIPS 2019) put a billion points on one workstation by keeping the graph
on an SSD. ANN-Benchmarks publishes recall-versus-throughput curves across
these libraries. The papers behind this lesson (HNSW, product quantization
and the Faiss library) are listed at the end with their companions.
Go deeper
Level 2 builds each index by hand on eight points you can check with a pencil: the flat scan, IVF's centroids and cells, PQ's codebooks and lookup tables, and HNSW's layered graph with its greedy descent, beam search and the random draw that decides which vectors become highways. Then it measures all four on 5,000 vectors and draws the recall-versus-work curves. If you only needed to choose an index and set its dial, you are done.
Level 2
How it works, from scratch
Level 2 builds every index above from nothing, starting with a picture and eight points on a map.
The everyday picture. You've just moved to a new country and want the house nearest to a landmark. You could walk up to every house in the country and measure the distance. That's always right, but it takes forever. Or you could do what everyone actually does: take the highway to the right region, switch to main roads to the right town, then walk the side streets to the house. You check only a handful of places and still end up at (almost always) the right door.
That is the whole problem of this lesson. A search engine that works on
meaning stores every document as a vector (a list of numbers, see
primer.ml.embeddings.similarity) and turns the question into a vector too.
The best documents are the vectors nearest the question. With ten million
documents, "walk to every house" is too slow, so we build a map with
highways. Structures like that are called approximate nearest neighbor
(ANN) indexes. Approximate because, like the traveller, they very
occasionally stop at the second-nearest house instead of the nearest.
Four ideas cover almost every vector database in use:
| Index | Everyday picture | What you give up |
|---|---|---|
| Flat | Visit every house | Nothing; it's exact. Just slow at scale |
| IVF | A library sorts books into sections; you search only the few nearest sections | Books shelved in the section next door |
| PQ | Describe a face by picking the closest nose, eyes and mouth from a small catalogue | Fine detail: the description is lossy |
| HNSW | Highway, then main roads, then side streets | Memory for all the roads, and the occasional near-miss |
A few words used throughout, in plain terms:
- Similarity score. How alike two vectors are. Here we use the dot product (multiply the two lists position by position and add up). All vectors are rescaled to length 1 first (normalized), so the dot product equals the cosine similarity, which runs from −1 (opposite) to 1 (identical). Higher means closer.
- Top k. The k best matches, e.g. the top 10.
- Recall@k. Of the true top k (found by exhaustive search), the fraction the index actually returned. Recall@10 = 0.9 means it found 9 of the true 10. It's the standard measure of an ANN index's accuracy.
- Big-O, e.g. O(N). How the work grows with the data. O(N) means doubling the number of vectors N doubles the work; O(log N) means doubling N adds only one more step.
Opening
A tiny worked example: eight houses on a map
Every index in this lesson is shown first on the same eight points, drawn on a flat map so you can check each step with a pencil. The question (the query) sits at (5, 4). On a flat map "near" is ordinary distance; we keep it squared (dx² + dy²) so every number stays whole. Squaring doesn't change which point is nearest.
| Point | Position | Squared distance to the query (5, 4) |
|---|---|---|
| A | (0, 0) | 5² + 4² = 41 |
| B | (2, 1) | 3² + 3² = 18 |
| C | (4, 0) | 1² + 4² = 17 |
| D | (1, 3) | 4² + 1² = 17 |
| E | (3, 3) | 2² + 1² = 5 |
| F | (5, 2) | 0² + 2² = 4 |
| G | (2, 5) | 3² + 1² = 10 |
| H | (5, 5) | 0² + 1² = 1 ← the true nearest |
Checking all eight rows is flat search. It's exact, and it costs one distance per stored point.
Figure 1 · Drawn from the lesson's code
On the eight-point map, HNSW reaches the query's nearest point in two hops, one along the highway from A to E and one along a street from E to H
Chapter 1
Flat search: the exact baseline
Figure 2 · Diagram
flowchart LR Q[Query vector] --> D["Dot product with<br/>EVERY stored vector<br/>(N comparisons)"] D --> T[Keep the k highest] T --> R[Exact top k]
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| q | the query vector | d numbers, length 1 |
| x | one stored vector | d numbers, length 1 |
| d | number of dimensions (numbers per vector) | 2 in the toy; 384 to 3072 in practice |
| i | position in the vector, counted from 1 | 1 … d |
| q_i, x_i | the i-th number of q and of x | −1 … 1 |
| Σ | "add up the following for every i from 1 to d" | |
| s(q, x) | similarity score, the dot product | −1 … 1 for unit vectors |
In words: the score of a stored vector is what you get by multiplying it with the query number by number and adding the results.
On the example: with unit vectors q = (0.8, 0.6) and x = (0.6, 0.8): s = 0.8·0.6 + 0.6·0.8 = 0.48 + 0.48 = 0.96, very similar.
In Python:
q = [0.8, 0.6]
x = [0.6, 0.8]
# s(q, x) = Σ q_i x_i
round(sum(q_i * x_i for q_i, x_i in zip(q, x)), 2) # → 0.96
points = {"A": (0, 0), "B": (2, 1), "C": (4, 0), "D": (1, 3),
"E": (3, 3), "F": (5, 2), "G": (2, 5), "H": (5, 5)}
query = (5, 4)
def sq_dist(p):
return (p[0] - query[0]) ** 2 + (p[1] - query[1]) ** 2
# flat search on the map: check all eight
min(points, key=lambda name: sq_dist(points[name])) # → 'H'
In code: FlatIndex.search scores the query against every stored vector
and keeps the best k with top_k; normalize rescales vectors to length 1
first, so the dot product is the cosine.
Why it matters flat search costs N·d multiply-adds per query. At 10 million vectors of 1,536 dimensions that's about 15 billion, far too many for an interactive search, and the raw vectors alone take 10,000,000 × 1,536 × 4 bytes ≈ 61 GB of memory. Below about a million vectors, flat search is often the right answer: simple, exact, and fast enough.
In code: storage_estimate does that memory sum for any n and d.
Chapter 2
IVF: search only the nearest sections of the library
The everyday picture. A library doesn't search every shelf for a book about sailing. It sorts books into sections ahead of time, and you go to "Sports" and maybe "Travel", the one or two most promising sections, and search only there. A sailing book shelved in the wrong section is missed unless you check that section too.
On the eight points. Split them into two groups (clusters) ahead of time: left = {A, B, C, D} and right = {E, F, G, H}. Each cluster is summarized by its centroid, the average position of its members: left = (1.75, 1), right = (3.75, 3.75). For the query (5, 4):
| Cluster | Centroid | Squared distance to (5, 4) |
|---|---|---|
| left | (1.75, 1) | 3.25² + 3² = 19.5625 |
| right | (3.75, 3.75) | 1.25² + 0.25² = 1.625 ← probe this one |
Scan only E, F, G, H: the nearest is H (distance 1). That's 2 centroid checks + 4 point checks = 6 instead of 8. With a million points in 1,000 clusters the saving is enormous.
Figure 4 · Diagram
flowchart TB
subgraph BUILD["Ahead of time"]
V[All vectors] --> KM["k-means: find nlist centroids"]
KM --> L["Put each vector on the list<br/>of its nearest centroid"]
end
subgraph QUERY["At query time"]
Q[Query] --> C["Compare with the nlist centroids"]
C --> P["Pick the nprobe nearest"]
P --> S["Scan only those lists"]
S --> R[Top k]
end
L -.-> S
nlist lists. The bottom box runs per query and never touches
lists it didn't pick. The only dial is nprobe: how many lists to open.The set of all points closer to one centroid than to any other is called that centroid's Voronoi cell: the "section" of the library. IVF's blind spot is a true neighbor sitting just across a cell boundary.
Figure 3 · Drawn from the lesson's code
IVF compares the query with 12 centroids and scans only the two nearest cells, so every point in the other ten cells is never looked at
nprobe raises recall.Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Typical range |
|---|---|---|
| N | number of stored vectors | thousands to billions |
| n_list | number of clusters (lists) | ≈ √N, e.g. 1,000 for 1M vectors |
| n_probe | clusters scanned per query, the recall dial | 1 … n_list |
| N · n_probe / n_list | vectors in the scanned lists, assuming equal-size clusters |
In words: you pay once to compare with every centroid, plus the share of the collection that lives in the clusters you open.
On the example: 2 + 8 · 1/2 = 6 comparisons (versus 8). At N = 1,000,000, n_list = 1,000, n_probe = 10: 1,000 + 10,000 = 11,000, about 1% of a flat scan.
In Python:
left = [(0, 0), (2, 1), (4, 0), (1, 3)]
right = [(3, 3), (5, 2), (2, 5), (5, 5)]
def centroid(members):
return tuple(sum(p[i] for p in members) / len(members) for i in range(2))
centroid(left), centroid(right) # → ((1.75, 1.0), (3.75, 3.75))
# probe the nearer
[(c[0] - 5) ** 2 + (c[1] - 4) ** 2 for c in (centroid(left), centroid(right))] # → [19.5625, 1.625]
def comparisons(N, n_list, n_probe):
# n_list centroids + the vectors in the opened lists
return n_list + N * n_probe // n_list
comparisons(8, 2, 1) # → 6
comparisons(1_000_000, 1_000, 10) # → 11000
In code: IVFIndex.train finds the centroids with kmeans,
IVFIndex.add files each vector on its nearest centroid's list, and
IVFIndex.search scans only the nprobe nearest lists. tiny_ivf_search
replays the eight-point example.
Why it matters IVF is cheap to build and light on memory, and
nprobe = nlist gives you back an exact flat search, which is a handy
sanity check. Its accuracy depends on how well the clusters fit the data, so
retrain the centroids when the data changes a lot.
Chapter 3
PQ: store each vector as a few catalogue numbers
The everyday picture. A police sketch artist doesn't record every pixel of a face. They pick the closest nose from a catalogue of 256 noses, the closest eyes from 256 eyes, the closest mouth, and so on. The whole face becomes a handful of catalogue numbers: tiny to store, close enough to recognize someone. That's product quantization (PQ). To quantize means to round a value to the nearest entry of a fixed set.
On a 4-number vector. Split x = (0.9, 0.1, −0.2, 0.8) into two halves. Each half is matched against a four-entry catalogue (a codebook), here the four compass directions: 0 = (1, 0), 1 = (0, 1), 2 = (−1, 0), 3 = (0, −1).
| Half | Values | Nearest catalogue entry | Code |
|---|---|---|---|
| 1 | (0.9, 0.1) | (1, 0) | 0 |
| 2 | (−0.2, 0.8) | (0, 1) | 1 |
The vector is now stored as two codes, [0, 1]. To score the query q = (1, 0, 0, 1) against any stored vector, first build one small table per half: the query's half dotted with each catalogue entry.
| entry 0 | entry 1 | entry 2 | entry 3 | |
|---|---|---|---|---|
| T₁ (q's half 1 = (1, 0)) | 1 | 0 | −1 | 0 |
| T₂ (q's half 2 = (0, 1)) | 0 | 1 | 0 | −1 |
The approximate score of codes [0, 1] is T₁[0] + T₂[1] = 1 + 1 = 2. The exact score is 0.9 + 0.8 = 1.7: close, not identical. That's the price of compression.
Figure 6 · Diagram
flowchart LR
subgraph ENC["Encode (once per vector)"]
X["x: d numbers"] --> SPLIT["split into m pieces"]
SPLIT --> NN["each piece → nearest of<br/>256 codebook entries"]
NN --> CODE["m one-byte codes"]
end
subgraph ADC["Score (per query)"]
Q["query q"] --> TAB["m small tables:<br/>q's piece · every entry"]
TAB --> SUM["score = sum of m<br/>table lookups"]
end
CODE --> SUM
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| m | number of pieces each vector is split into | 2 in the toy; 8 to 96 in practice |
| j | which piece, counted from 1 | 1 … m |
| q^(j) | the j-th piece of the query (d/m numbers) | |
| C_j | the codebook for piece j: its catalogue of entries | 2^nbits entries, usually 256 |
| C_j[c] | entry number c of that catalogue | |
| c_j(x) | the code stored for piece j of x: which entry was nearest | 0 … 255 (one byte) |
| T_j[c] | precomputed table: q's piece j dotted with entry c | |
| ŝ(q, x) | approximate score ("s-hat": the hat means estimate) |
In words: the estimated score is the sum, over the pieces, of the table value for whichever catalogue entry that piece of the stored vector was rounded to.
On the example: ŝ = T₁[c₁] + T₂[c₂] = T₁[0] + T₂[1] = 1 + 1 = 2 (exact: 1.7).
In Python:
# the compass codebook, used for both pieces
C = [(1, 0), (0, 1), (-1, 0), (0, -1)]
def dot(a, b):
return sum(a_i * b_i for a_i, b_i in zip(a, b))
def sq_dist(a, b):
return sum((a_i - b_i) ** 2 for a_i, b_i in zip(a, b))
x = [0.9, 0.1, -0.2, 0.8]
pieces = [x[0:2], x[2:4]]
# c_j(x)
codes = [min(range(4), key=lambda c: sq_dist(piece, C[c])) for piece in pieces]
codes # → [0, 1]
q = [1, 0, 0, 1]
# T_j[c] = q^(j) · C_j[c]
T = [[dot(q_j, C[c]) for c in range(4)] for q_j in (q[0:2], q[2:4])]
T # → [[1, 0, -1, 0], [0, 1, 0, -1]]
# ŝ = Σ_j T_j[c_j(x)]
sum(T[j][codes[j]] for j in range(2)) # → 2
# the exact score, for comparison
round(dot(q, x), 2) # → 1.7
In code: ProductQuantizer learns the codebooks (ProductQuantizer.train),
rounds vectors to codes (ProductQuantizer.encode), builds the Tⱼ tables
(ProductQuantizer.lookup_table) and sums the lookups
(ProductQuantizer.adc_scores). tiny_pq_example replays the 4-number
example by hand-setting the compass codebooks.
Why it matters a 768-dimension float32 vector is 3,072 bytes; with m = 96 it's 96 bytes, 32× smaller, so a billion vectors fit in about 100 GB instead of 3 TB. The lost accuracy is recovered by re-scoring: take the top few hundred by PQ score and recompute their exact scores from the full vectors kept on disk. Real systems combine PQ with IVF (IVF-PQ) and encode each vector's residual (its offset from its cluster centroid), which is smaller and so rounds more precisely.
Figure 5 · Drawn from the lesson's code
PQ scores alone find under half of the true top 10 at 8 bytes per vector, while re-scoring PQ's shortlist with exact vectors is near perfect from 8 bytes up
In code: PQIndex scans every PQ code and can re-score its top
candidates with the exact vectors; IVFPQIndex combines IVF lists with
PQ-encoded residuals.
Chapter 4
HNSW: highway, main roads, side streets
The everyday picture. Back to the traveller: highways to get close fast, main roads to get closer, side streets to find the door. HNSW (Hierarchical Navigable Small World) builds exactly that as a graph: a set of points (nodes) joined by links (edges). Every vector is a node on the bottom layer (the side streets). A random few are also placed on layer 1 (main roads), fewer still on layer 2 (highways), and so on.
On the eight points. Layer 1 holds only A, E and G. Layer 0 holds all eight, linked to nearby points (the thin lines in the figure above). A greedy search means "always move to whichever neighbor is closest to the target; stop when none is closer". Start at A:
| Step | Layer | At | Neighbors checked (squared distance) | Move? |
|---|---|---|---|---|
| 1 | 1 | A (41) | E (5), G (10) | → E |
| 2 | 1 | E (5) | A (41), G (10) | no closer neighbor: drop a layer, staying at E |
| 3 | 0 | E (5) | B (18), D (17), F (4), G (10), H (1) | → H |
| 4 | 0 | H (1) | E (5), F (4), G (10) | no closer neighbor: done. Answer: H |
Figure 8 · Diagram
flowchart TD
subgraph L2["Top layer: few nodes, long jumps"]
A2[A] --- D2[D]
end
subgraph L1["Middle layer: more nodes"]
A1[A] --- B1[B] --- D1[D] --- E1[E]
end
subgraph L0["Bottom layer: every vector"]
A0[A] --- B0[B] --- C0[C] --- D0[D] --- E0[E] --- F0[F]
end
D2 -.-> D1
E1 -.-> E0
In code: tiny_hnsw_search replays the greedy walk from the table above
on the eight-point map and returns every stop; HNSWIndex is the full index
used on real vectors.
The search, step by step
Figure 9 · Diagram
flowchart TD
S[Start at the entry point<br/>on the top layer] --> G{Is any neighbor<br/>closer to the query?}
G -->|yes| H[Hop to the closest neighbor] --> G
G -->|no| B{On the bottom layer?}
B -->|no| DOWN[Drop one layer,<br/>same node] --> G
B -->|yes| BEAM["Beam search: keep the best efSearch<br/>candidates, expand the most promising<br/>until nothing better turns up"]
BEAM --> K[Return the top k]
efSearch nodes found so far, not just one, and keeps
exploring from the most promising. That wider net is what protects against
getting stuck at a point that is only locally the best. efSearch is the
dial you tune at query time.Figure 7 · Drawn from the lesson's code
One HNSW query takes a couple of long hops on the 14-node layer, a few on the 64-node layer, then a small local search among all 300 points, comparing only 51 vectors in all
Figure 11 · Interactive · computed from the lesson's code
HNSW search
Try it: the map below has 60 points on four layers. Pick a query and drag Step: watch the walk cross the map in long hops on the sparse upper layers, drop a layer whenever no neighbor is closer, and finish with a short local search on the bottom layer. Then choose query D with a beam of 1 (pure greedy): it stops at a point with no closer neighbor, yet brute force finds a closer one. Widen the beam to 4 and step through again.
In code: HNSWIndex.search runs the greedy descent and the bottom-layer
beam search; HNSWIndex.search_trace does the same and returns every node it
expanded, which is what this figure draws. small_hnsw_map builds the
60-point graph the widget searches, and viz_data hands it to the page.
How the layers are built
Figure 10 · Diagram
flowchart TD
N[New vector] --> LV["Draw its top layer ℓ at random<br/>(most get 0, a few get more)"]
LV --> DESC[Greedy descent from the entry point<br/>down to layer ℓ]
DESC --> FIND["On each layer ≤ ℓ: beam search with<br/>efConstruction to find candidates"]
FIND --> SEL["Keep up to M diverse neighbors<br/>(the heuristic below)"]
SEL --> LINK[Link both ways]
LINK --> PRUNE{Neighbor now has too many links?<br/>more than M, or 2·M on layer 0}
PRUNE -->|yes| TRIM[Re-select its best-spread links]
PRUNE -->|no| DONE[Next layer down]
TRIM --> DONE
efConstruction) for a
better-quality result. The wiring part keeps each node's links to a fixed
budget, so the graph never gets too dense to walk quickly.The diversity heuristic. When choosing a new node's M neighbors, go through the candidates from nearest to farthest and keep one only if it is closer to the new node than to every neighbor already kept. Otherwise a kept neighbor already "covers" that direction. Without this rule, in clustered data all M links would point into the same clump, and the graph could split into islands a greedy walk can't cross. With it, links fan out in different directions and long "bridges" between clusters survive.
The random layer draw is where the math comes in:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | Range |
|---|---|---|
| ℓ | the top layer the new node will live on | 0, 1, 2, … |
| U | a random number drawn uniformly | 0 < U ≤ 1 |
| ln | natural logarithm: the power you raise e ≈ 2.718 to in order to get the number. ln(1) = 0; ln of a number below 1 is negative, so −ln(U) is positive | |
| ⌊ ⌋ | "floor": round down to a whole number | |
| M | the link budget per node (also sets how fast layers thin out) | 4 to 64; 16 is common |
| m_L | the level multiplier, 1/ln M | ≈ 0.36 for M = 16 |
| P(ℓ ≥ l) | the probability a node reaches layer l or higher | |
| efConstruction | beam width while building | 100 to 400 |
| efSearch | beam width while searching, the recall dial | ≥ k; 50 to 500 |
In words: draw a random number, take minus its logarithm, scale it by one over the log of M, and round down. That gives a node layer 1 or higher with probability 1/M, layer 2 or higher with probability 1/M², and so on.
On the example: M = 16, so m_L = 1/ln 16 = 1/2.773 = 0.361. A draw of U = 0.5 gives −ln 0.5 · 0.361 = 0.693 · 0.361 = 0.25 → floor → layer 0. A draw of U = 0.05 gives 2.996 · 0.361 = 1.08 → layer 1. Only 1/16 = 6.25% of nodes reach layer 1, and 1/256 ≈ 0.4% reach layer 2: each layer has about 1/M as many nodes as the one below, which is what makes the upper layers "highways".
In Python:
import math
M = 16
# m_L = 1 / ln M
m_L = 1 / math.log(M)
round(m_L, 3) # → 0.361
for U in (0.5, 0.05):
# -ln(U) · m_L
scaled = -math.log(U) * m_L
# ... then round down to get ℓ
print(U, round(scaled, 2), math.floor(scaled)) # → 0.5 0.25 0 0.05 1.08 1
# P(ℓ ≥ l) = M^(-l): 6.25% and about 0.4%
[M ** -l for l in (1, 2)] # → [0.0625, 0.00390625]
| Knob | Set when | Higher means |
|---|---|---|
M |
build | more links per node: better recall, more memory, slower build. 16 is a common default; 32 to 64 for high-dimensional data |
efConstruction |
build | a better-quality graph, a slower build |
efSearch |
query time | a wider beam: better recall, slower queries |
In code: HNSWIndex.add inserts each vector this way: draw its layer,
beam-search each layer with efConstruction, keep up to M diverse neighbors
and link both ways. HNSWIndex.layer_sizes counts the nodes on each layer,
and HNSWIndex.memory_bytes adds up the vectors plus their links.
Why it matters HNSW is the default index in most vector databases because it gives high recall at low latency with no training step. Its costs are memory (every vector plus its links must sit in RAM) and slow builds for very large collections. At billions of vectors, teams switch to IVF-PQ, shard HNSW across machines, or use disk-based graphs (DiskANN).
Opening
Turning the dial: recall vs. work
Figure 12 · Drawn from the lesson's code
Both HNSW and IVF rise steeply and then flatten as their dial widens, and HNSW reaches about 0.95 recall while comparing around a fifth of the 2,000 vectors
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here |
|---|---|
| k | how many results we ask for |
| true top k | the answer from an exact flat search |
| ∩ | "intersection": the items in both lists |
| | | | "the number of items in" |
In words: recall@k is the share of the true top k that the index actually returned.
On the example: if the true top 10 is documents 1 to 10 and the index returns 1 to 9 plus document 42, the overlap is 9, so recall@10 = 9/10 = 0.9.
In Python:
# documents 1 to 10
true_top = set(range(1, 11))
# 1 to 9, plus document 42
returned = set(range(1, 10)) | {42}
k = 10
# |returned ∩ true| / k
len(returned & true_top) / k # → 0.9
In code: recall_at_k computes this share; ground_truth runs the exact
flat search that supplies the true top k, and evaluate reports recall,
latency and distance computations per query for any index.
Why it matters recall and latency are traded against each other on every index. The only reliable way to pick a setting is to measure recall@k against a flat index on your own vectors while you turn the dial, then check the 95th-percentile latency.
Test yourself
9 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1On the eight-point map, query at (5, 4): walk the HNSW search.Think it through, then reveal
Start at A on layer 1 (squared distance 41). Its layer-1 neighbors are E (5) and G (10), so hop to E. No layer-1 neighbor of E is closer, so drop to layer 0 at E. There, H (1) is closer, so hop to H. None of H's neighbors beats 1, so the answer is H.
Question 2PQ stores x = (0.9, 0.1, −0.2, 0.8) with the four compass directions as each half's codebook. What are the codes, and what score does the query (1, 0, 0, 1) get?Think it through, then reveal
Codes [0, 1]. The lookup tables are [1, 0, −1, 0] and [0, 1, 0, −1], so the approximate score is 1 + 1 = 2, against an exact score of 0.9 + 0.8 = 1.7.
Question 3How does an HNSW search proceed, and which knobs change its speed and recall?Think it through, then reveal
Enter at the top layer's entry point; greedily hop to whichever neighbor is
closest to the query until none is closer, then drop a layer at the same
node; at layer 0 run a beam search keeping efSearch candidates; return the
top k. Build-time knobs: M (links per node; memory and recall) and
efConstruction (graph quality; build time). Query-time knob: efSearch. Raise
it until recall@k against a flat index meets your target, then check p95
latency.
Question 4Why does HNSW need a "diversity" rule when choosing neighbors?Think it through, then reveal
If a node linked to its M nearest points, in clustered data all M would sit in one clump and the graph could split into islands that greedy search can't cross. Keeping a candidate only if it's closer to the new node than to any already-kept neighbor spreads links in different directions and keeps bridges between clusters.
Question 5Why do the upper layers of HNSW have so few nodes?Think it through, then reveal
Each node's top layer is drawn so that P(layer ≥ l) = M^−l. Each layer holds about 1/M of the one below, like express stops on a subway line, so a few long hops at the top cover the whole space.
Question 6IVF: what happens as nprobe goes from 1 to nlist?Think it through, then reveal
Recall rises and the work grows roughly in proportion. At nprobe = nlist
it's an exact scan (plus the centroid comparisons).
Question 7How does PQ score a vector without decompressing it?Think it through, then reveal
It precomputes, for each of the m pieces, the query piece's dot product with all 256 codebook entries. A stored vector's score is then the sum of m table lookups, one per code. The query stays exact; only the stored side is approximate (asymmetric distance computation).
Question 8You have 1 billion 768-dimension vectors. Why not HNSW over raw float32?Think it through, then reveal
The raw vectors alone are 10⁹ × 768 × 4 bytes ≈ 3 TB of RAM, before the graph links. Use IVF-PQ (e.g. 64-byte codes ≈ 64 GB) with exact re-scoring of a shortlist, shard the index across machines, or use a disk-based graph index such as DiskANN.
Question 9Why can an index that scores 0.99 recall on a benchmark do worse on your data?Think it through, then reveal
ANN performance depends on the data's structure. Uniformly random high-dimensional vectors are the hardest case (all distances look alike), while real embeddings are clustered. A benchmark with different structure, dimension or size predicts little. Always measure on your own vectors.
Primary sources
The papers behind this lesson
Introduced HNSW: the layered graph, the exponential layer draw with m_L = 1/ln M, and the diversity heuristic for choosing neighbors, which together gave logarithmic-feeling search with state-of-the-art recall.
Read the annotated companion →The paper ↗Introduced product quantization, asymmetric distance computation and the IVF-ADC index (IVF with PQ-encoded residuals), the basis of billion-scale vector search.
The paper ↗Describes the most widely used vector-search library and the design space of indexes (flat, IVF, PQ, HNSW and their combinations) this lesson walks through.
The paper ↗Researcher's shelf
Further reading
- FAISS wiki (index types, guidelines for choosing an index): https://github.com/facebookresearch/faiss/wiki
- Douze et al., The Faiss library (2024): https://arxiv.org/abs/2401.08281
- hnswlib, the reference HNSW implementation, and its parameter guide: https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
- ANN-Benchmarks (recall vs. queries per second across libraries): https://ann-benchmarks.com/
- Pinecone's illustrated guides to HNSW, IVF and PQ: https://www.pinecone.io/learn/series/faiss/
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit 048aeaa, so the two always agree: the explanation, the code that builds it and the tests that prove it.