rumblr Work in progressWIP

● The AI Primer · Lesson 30 · Embeddings, the centerpiece

Vector search

finding the nearest neighbors without checking everyone

This lesson covers Flat, IVF, PQ and HNSW from scratch, recall vs. latency

Members · open during launch 37 min11 figures and diagrams1 interactive
How it works builds the idea from scratch. Math & code adds the formulas and the Python.

At a glance

Key takeaways

  1. Flat compares the query with everything: exact, O(N). It's the ground truth, and fine for small collections.
  2. IVF clusters ahead of time and searches the nprobe nearest clusters.
  3. PQ compresses each vector to a few bytes and scores by table lookup; re-score a shortlist with exact vectors.
  4. HNSW is a layered graph: long jumps on top, a careful beam search at the bottom. efSearch trades recall for latency; M and efConstruction set graph quality at build time. It's fast and accurate, but memory-hungry.
  5. Always measure recall@k against a flat index on your own data while you turn the dial.

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 · Chart

0 1 2 3 4 5 0 1 2 3 4 5 Eight points: highway hop A→E, street hop E→H A B C D E F G H layer 0 links (every point) layer 1 links (A, E, G) query (5, 4)

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

Reading it: the eight dots are the stored points and the yellow star is the query at (5, 4). Thin grey lines are the "streets" (layer 0: every point, linked to its neighbors); the thick blue lines are the "highway" (layer 1: only A, E and G). The red arrows are the HNSW search worked through below: one highway hop from A to E, then one street hop from E to H. It checked a few points near its route instead of reading the whole table.

Chapter 1

Flat search: the exact baseline

Figure 2 · Diagram

Reading it: there is only one path and no shortcuts. The middle box touches every stored vector, which is why the cost grows in step with N. It is always exactly right, so every other index is measured against it: its answer is the "truth" in recall@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 3 · Diagram

Reading it: the top box runs once, when the index is built: k-means (a simple clustering method: guess centroids, assign every vector to its nearest, move each centroid to the average of its members, repeat) sorts the vectors into 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 4 · Chart

IVF: 12 clusters, the query scans only the 2 nearest centroids probed (nprobe = 2) query

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

Reading it: 600 points, colored by which of 12 centroids (black X) they belong to; each color patch is a Voronoi cell. The query (yellow star) compares itself with the 12 centroids, then scans only the two nearest cells (red X, bright points). The faded points are never looked at. A true neighbor sitting just across a boundary, in a faded cell, would be missed, which is exactly why raising 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 5 · Diagram

Reading it: the left box shrinks every stored vector to m bytes, once. The right box is the trick: the query is not compressed. We precompute m tables of 256 numbers, and then scoring any stored vector is just m lookups and additions, with no multiplication at all. Keeping the query exact while the stored side is compressed is called asymmetric distance computation (ADC).
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 6 · Chart

2 B (128× smaller) 4 B (64× smaller) 8 B (32× smaller) 16 B (16× smaller) 32 B (8× smaller) bytes per vector (raw float32 = 256 B) 0.0 0.2 0.4 0.6 0.8 1.0 recall@10 Product quantization: memory vs. accuracy PQ codes only PQ shortlist of 100, re-scored exactly

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

Reading it: left to right, each vector gets more bytes (less compression). The orange line uses PQ scores alone: at 8 bytes per vector (32× smaller than the raw 256 bytes) it finds under half of the true top 10. The blue line re-scores PQ's top 100 with the exact vectors and is near perfect from 8 bytes up. The lesson: PQ is excellent at shortlisting and poor at final ranking, so production systems always re-score.

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 7 · Diagram

Reading it: three layers of the same kind of map, fewer nodes the higher you go. Solid lines are links within a layer; dotted arrows are "the same node, one layer down". A search enters at the top, crosses a lot of ground in a few long jumps (A to D), then follows a dotted arrow down and continues from the same node with finer steps, until the bottom layer, where every vector lives.

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 8 · Diagram

Reading it: the loop at the top is greedy descent: hop while something closer exists, else drop a layer. On the upper layers it keeps a single best node. On the bottom layer it switches to a beam search, which keeps a shortlist of the best 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 9 · Chart

layer 2: 14 nodes layer 1: 64 nodes layer 0: 300 nodes (every vector) One HNSW query: long hops up top, a careful local search at the bottom (★ = query)

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

Reading it: this graph has four layers: 300 nodes at the bottom, then 64, then 14, and a single node on top, which is the entry point. The top layer has nothing to hop to, so the figure leaves it out and draws the other three, left to right: 14 nodes, 64, and the bottom with all 300. Grey lines are the graph's links. The red path is one real query (yellow star) run by the code below; the red circle marks where the search entered each layer. On the left it covers most of the map in a couple of long hops. By the bottom layer it is already next to the star, and it only explores a small neighborhood. Out of 300 points, it compared the query with 51, a few dozen.

Figure 10 · 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 11 · Diagram

Reading it: inserting a vector is a search followed by wiring. The random draw at the top decides how many layers the node lives on. The search part is the same as a query, just with a wider beam (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 · Chart

1 0 1 1 0 2 % of the corpus compared per query (log scale) 0.0 0.2 0.4 0.6 0.8 1.0 recall@10 vs. exact search Turning the dial: recall vs. work (2,000 vectors, 32-d) 10 15 20 30 50 80 120 200 1 2 3 5 8 12 20 45 flat scan: every vector, recall 1.0 HNSW (labels: ef) IVF (labels: nprobe)

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

Reading it: each point is one setting of the dial (the small labels are efSearch for HNSW and nprobe for IVF). The x-axis is the share of the whole collection compared per query, on a log scale (each tick is 10× more work), and the dashed line at 100% is a flat scan. The y-axis is recall@10. Read it as a menu: the further up and to the left, the better. HNSW reaches about 0.95 recall while comparing around a fifth of these 2,000 vectors. On real collections of millions the share is far smaller, because the number of hops grows only slowly with N. Both curves rise steeply and then flatten, which is why the last few points of recall are the expensive ones.
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

Malkov & Yashunin, Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs (2016).

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 ↗
Jégou, Douze & Schmid, Product Quantization for Nearest Neighbor Search (IEEE TPAMI, 2011).

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 ↗
Douze et al., The Faiss library (2024).

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 c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.