Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A support assistant has to find one rollback runbook in a corpus of 100 million trace summaries. Checking every stored representation can blow tail latency; skipping most candidates can miss the runbook an operator needs. The production question isn't "which index is fastest?" It's "how much work can we skip while still recovering the right neighbors?"
Use that query to make the trade-off visible. An embedding turns the query and each runbook into a vector, so related meaning appears as nearby geometry. Exact search checks every vector; Hierarchical Navigable Small World (HNSW) walks graph links; inverted file (IVF) opens selected partitions; and product quantization (PQ) compresses stored vectors. The release decision comes from recovered neighbors, latency, memory, and update behavior measured together.
The brute-force problem
Start with exact search, also called flat search. Given a query vector , it computes a distance to every stored vector and returns the nearest neighbors; k-NN is shorthand for -nearest-neighbor search.
Mathematically, given a query and a database of vectors each with dimensions, exact search has complexity. That notation means the work grows in direct proportion to both the number of vectors and the number of dimensions.
Use the corpus as a concrete baseline: 100 million trace-summary embeddings, each with 1,536 dimensions, stored as 4-byte floats. A flat query scores all 100 million vectors, or 153.6 billion scalar components before considering vectorization, batching, memory bandwidth, or accelerator hardware. Whether that fits a service-level agreement (SLA) is a benchmark question, not something dimensionality alone can answer.
The memory picture is just as stark:
That's raw vector storage alone, before any index overhead (the lab below reports the same quantity in GiB, the binary units RAM is usually measured in). That budget leaves little room for index and application state on many single-node deployments. Approximate nearest-neighbor (ANN) search reduces scored candidates by accepting that some true neighbors may be missed.
1def gib_for_float32_vectors(count: int, dimensions: int) -> float:
2 return count * dimensions * 4 / (1024 ** 3)
3
4def scalar_components_scored(count: int, dimensions: int) -> int:
5 return count * dimensions
6
7n_vectors = 100_000_000
8dimensions = 1_536
9
10print(f"raw float32 storage: {gib_for_float32_vectors(n_vectors, dimensions):.1f} GiB")
11print(f"scalar components scored per flat query: {scalar_components_scored(n_vectors, dimensions):,}")1raw float32 storage: 572.2 GiB
2scalar components scored per flat query: 153,600,000,000For large, weakly filtered collections, ANN is usually the practical serving path. Exact flat search still matters: it's the ground truth for measuring recall, and it can be the right production execution path when a metadata filter leaves only a small candidate set. For example, pgvector performs exact nearest-neighbor search by default and documents exact search as useful for selective filter conditions.[1]
Why is unfiltered exact vector search difficult at 100 million vectors?
Answer
The system stores hundreds of GB of raw vectors and scores every vector for each unfiltered query. Both storage and query work grow with corpus size, so teams commonly evaluate ANN indexes while retaining exact search as their recall baseline.
Recall@k answers one question: of the exact top-k nearest neighbors, how many did the ANN index recover? Latency numbers without recall are misleading because an index can look fast by missing good candidates. A system that returns random vectors in 1 ms has terrible recall. A system that takes 20 ms and finds 99 of the true top 100 has excellent recall.
1def recall_at_k(exact_ids: list[int], approximate_ids: list[int]) -> float:
2 expected = set(exact_ids)
3 return len(expected.intersection(approximate_ids)) / len(expected)
4
5exact_top_5 = [10, 21, 34, 55, 89]
6fast_but_weak = [10, 21, 8, 13, 5]
7slower_but_better = [10, 21, 34, 55, 3]
8
9print(f"fast candidate recall@5: {recall_at_k(exact_top_5, fast_but_weak):.0%}")
10print(f"better candidate recall@5: {recall_at_k(exact_top_5, slower_but_better):.0%}")1fast candidate recall@5: 40%
2better candidate recall@5: 80%Recall gate: Latency without recall is incomplete. Keep an exact-search evaluation set, measure ANN recall against it, and choose a latency-recall operating point that satisfies the product requirement.

HNSW (Hierarchical Navigable Small World)
With exact search held as ground truth, start with HNSW when the working set can stay in memory. It can provide a strong recall-latency trade-off without a separate centroid-training phase.[2] Whether it beats another index on your data still needs measurement under the same memory and recall constraints.
Layers skip, then search locally
Suppose the rollback query enters at a node that is far from its best match. A useful next hop should move closer to the query, but scanning every neighbor at every node would erase the speedup. HNSW makes that local decision on a sparse upper graph first, then repeats it on a denser neighborhood.
That structure is a multi-layer proximity graph. Upper layers hold long-range links; layer 0 contains every vector and does the local search.[2] M sets how many neighbors a new node keeps, ef_search sets the query beam, and the layer-0 adjacency cap (often 2M) keeps the base layer better connected. The mechanism is graph hops, not a partition map: an upper-layer hop gets search near the right region, and layer 0 spends the larger candidate budget there.

A worked example: tracing one insertion
To see the layers work, insert one new runbook vector into a graph that already has a few nodes.
First, a random process samples the new node's top layer. Most nodes live only on layer 0; a few appear higher, which keeps upper layers sparse.
Next, start at the current entry point and greedily walk each layer above the new node's top layer. Stop when no neighbor is closer, then drop down. Those upper layers locate a useful neighborhood without wiring the new node into distant parts of the graph.
On each layer the new node actually occupies, run a beam search with ef_construction candidates. Keep up to M neighbors, add bidirectional edges, and prune an existing neighbor if its degree is now too large. The layer-0 cap is commonly 2M, so the base layer can offer more local routes than upper layers.
Finally, update the entry point only when the sampled level is above the graph's current maximum. Landing on an existing top layer changes local wiring, not the global starting point.
This incremental construction is why HNSW doesn't need a training phase. Every new vector is wired into the existing graph using local search.
Build a tiny graph, then search it
Insert follows the paper's two phases: greedy descent through layers above the new node's top layer, then a wider ef_construction search while wiring bidirectional edges on the layers the node actually occupies.[2] Neighbor selection here is "keep the nearest M candidates." Production HNSW usually adds a diversity heuristic so the graph doesn't collapse into a clique of near-duplicates.
Level sampling is exponential. sample_level is the random process a real builder uses. The lab then inserts with explicit levels so the wiring stays reproducible. Before running it, predict the distinction: the returned neighbors should match the exact top-2 in this tiny graph, but that result doesn't establish large-scale recall.
1import heapq
2import math
3import random
4from dataclasses import dataclass, field
5
6def squared_l2(left: tuple[float, ...], right: tuple[float, ...]) -> float:
7 return sum((a - b) ** 2 for a, b in zip(left, right))
8
9def sample_level(mL: float, rng: random.Random) -> int:
10 u = max(rng.random(), 1e-12)
11 return math.floor(-math.log(u) * mL)
12
13@dataclass
14class ToyHNSW:
15 M: int = 2
16 ef_construction: int = 4
17 vectors: dict[int, tuple[float, ...]] = field(default_factory=dict)
18 edges: dict[tuple[int, int], list[int]] = field(default_factory=dict)
19 entry_point: int | None = None
20 max_level: int = -1
21
22 def neighbors(self, node_id: int, level: int) -> list[int]:
23 return list(self.edges.get((node_id, level), []))
24
25def greedy_search(graph: ToyHNSW, query: tuple[float, ...], entry_id: int, level: int) -> int:
26 current = entry_id
27 improved = True
28 while improved:
29 improved = False
30 for neighbor in graph.neighbors(current, level):
31 if squared_l2(query, graph.vectors[neighbor]) < squared_l2(query, graph.vectors[current]):
32 current = neighbor
33 improved = True
34 return current
35
36def search_layer(
37 graph: ToyHNSW, query: tuple[float, ...], entry_id: int, level: int, ef: int
38) -> list[int]:
39 visited = {entry_id}
40 entry_dist = squared_l2(query, graph.vectors[entry_id])
41 candidates = [(entry_dist, entry_id)]
42 results = [(-entry_dist, entry_id)]
43 while candidates:
44 dist, current = heapq.heappop(candidates)
45 if len(results) >= ef and dist > -results[0][0]:
46 break
47 for neighbor in graph.neighbors(current, level):
48 if neighbor in visited:
49 continue
50 visited.add(neighbor)
51 neighbor_dist = squared_l2(query, graph.vectors[neighbor])
52 if len(results) < ef or neighbor_dist < -results[0][0]:
53 heapq.heappush(candidates, (neighbor_dist, neighbor))
54 heapq.heappush(results, (-neighbor_dist, neighbor))
55 if len(results) > ef:
56 heapq.heappop(results)
57 return [node for _, node in sorted((-neg, node) for neg, node in results)]
58
59def prune(graph: ToyHNSW, node_id: int, level: int, cap: int) -> None:
60 linked = graph.edges[(node_id, level)]
61 if len(linked) <= cap:
62 return
63 keep = sorted(
64 linked, key=lambda other: squared_l2(graph.vectors[node_id], graph.vectors[other])
65 )[:cap]
66 dropped = set(linked) - set(keep)
67 graph.edges[(node_id, level)] = keep
68 for other in dropped:
69 graph.edges[(other, level)] = [n for n in graph.edges.get((other, level), []) if n != node_id]
70
71def insert(graph: ToyHNSW, node_id: int, vector: tuple[float, ...], level: int) -> None:
72 graph.vectors[node_id] = vector
73 for layer in range(level + 1):
74 graph.edges.setdefault((node_id, layer), [])
75 if graph.entry_point is None:
76 graph.entry_point = node_id
77 graph.max_level = level
78 return
79
80 entry = graph.entry_point
81 for layer in range(graph.max_level, level, -1):
82 entry = greedy_search(graph, vector, entry, layer)
83 for layer in range(min(level, graph.max_level), -1, -1):
84 candidates = search_layer(graph, vector, entry, layer, graph.ef_construction)
85 chosen = sorted(candidates, key=lambda i: squared_l2(vector, graph.vectors[i]))[: graph.M]
86 cap = 2 * graph.M if layer == 0 else graph.M
87 for neighbor in chosen:
88 if neighbor == node_id:
89 continue
90 if neighbor not in graph.edges[(node_id, layer)]:
91 graph.edges[(node_id, layer)].append(neighbor)
92 graph.edges.setdefault((neighbor, layer), [])
93 if node_id not in graph.edges[(neighbor, layer)]:
94 graph.edges[(neighbor, layer)].append(node_id)
95 prune(graph, neighbor, layer, cap)
96 prune(graph, node_id, layer, cap)
97 entry = min(candidates, key=lambda i: squared_l2(vector, graph.vectors[i]))
98 if level > graph.max_level:
99 graph.entry_point = node_id
100 graph.max_level = level
101
102def search(graph: ToyHNSW, query: tuple[float, ...], k: int = 2, ef_search: int = 4) -> list[int]:
103 if graph.entry_point is None:
104 raise ValueError("empty graph")
105 if ef_search < k:
106 raise ValueError("ef_search must be at least k")
107 current = graph.entry_point
108 for layer in range(graph.max_level, 0, -1):
109 current = greedy_search(graph, query, current, layer)
110 candidates = search_layer(graph, query, current, 0, ef_search)
111 return sorted(candidates, key=lambda i: squared_l2(query, graph.vectors[i]))[:k]
112
113rng = random.Random(0)
114mL = 1 / math.log(16)
115draws = 0
116level = 0
117while level == 0:
118 level = sample_level(mL, rng)
119 draws += 1
120print(f"mL for M=16: {mL:.3f}")
121print(f"P(level >= 1) = {1 / 16:.1%}")
122print(f"first upper-layer draw: level {level} after {draws} samples")
123
124graph = ToyHNSW()
125for node_id, vector, level in [
126 (0, (0.0, 0.0), 1),
127 (1, (0.2, 0.1), 0),
128 (2, (0.3, 0.0), 1),
129 (3, (5.0, 5.0), 0),
130 (4, (5.1, 4.8), 0),
131 (5, (0.1, 0.3), 0),
132]:
133 insert(graph, node_id, vector, level)
134
135query = (0.25, 0.05)
136nearest_ids = search(graph, query, k=2, ef_search=4)
137exact_ids = sorted(graph.vectors, key=lambda i: squared_l2(query, graph.vectors[i]))[:2]
138print(f"nearest ids: {nearest_ids}")
139print(f"exact top-2: {exact_ids}")1mL for M=16: 0.361
2P(level >= 1) = 6.2%
3first upper-layer draw: level 1 after 36 samples
4nearest ids: [1, 2]
5exact top-2: [1, 2]The toy returns the exact top-2 because its graph and beam are generous for six vectors. That is a correctness check for the implementation, not a production recall result.
If you set ef_search = 5 but ask for k = 10, most implementations enforce ef_search >= k because you need at least k candidates to return k results. The real risk is subtler: if ef_search is only slightly larger than k, you might miss better neighbors outside the explored candidate set. That's the recall-latency trade-off in action.
Common mistake: Setting
ef_searchfrom intuition rather than an evaluation curve. A small beam can miss true neighbors; an unnecessarily large beam spends latency without a useful recall gain. Measureef_searchvalues against exact top-kresults on representative queries.
Key parameters
M, ef_construction, and ef_search are the knobs that actually move recall, RAM, build time, and latency. Use the table as a starting map, then measure on your queries before copying a default.
| Parameter | Meaning | Typical Value | Effect |
|---|---|---|---|
M | Max neighbors per node on upper layers | 16-48 | Higher = better recall, higher memory usage and slower builds. |
M0 | Max neighbors on layer 0 (often 2*M) | 2*M | Denser base layer, better local connectivity, more memory. |
ef_construction | Candidate list size during insert | 100-400 | Higher = better graph quality, slower indexing. |
ef_search | Candidate list size during query | k to a few hundred | Higher = better recall, higher latency. Many implementations require ef_search >= k. |
mL | Controls the layer sampling distribution | Often near | Higher = more upper-layer nodes, shallower descents. |
M0 (often ) is why layer 0 is denser than the upper layers.[2] At 4 bytes per packed neighbor id, adds 128 bytes of layer-0 ids on top of a 1,536-d float32 vector (6,144 bytes). The embedding still dominates; the memory cliff at LLM sizes is uncompressed vectors, not the graph overlay. Compression (PQ, disk-backed graphs) is what changes the budget.
1def hnsw_layer0_bytes_per_vector(dimensions: int, M: int) -> dict[str, int]:
2 vector = dimensions * 4
3 neighbor_ids = (2 * M) * 4
4 return {
5 "vector": vector,
6 "layer0_ids": neighbor_ids,
7 "total": vector + neighbor_ids,
8 }
9
10budget = hnsw_layer0_bytes_per_vector(1536, 16)
11print(f"float32 vector: {budget['vector']:,} bytes")
12print(f"layer-0 ids at M=16: {budget['layer0_ids']:,} bytes")
13print(f"vector share: {budget['vector'] / budget['total']:.1%}")1float32 vector: 6,144 bytes
2layer-0 ids at M=16: 128 bytes
3vector share: 98.0%ef_search is the query-time knob: raise it to spend latency for recall, without rebuilding. The lab below picks the cheapest measured point that clears a recall floor and a p95 budget.
1measurements = [
2 {"ef_search": 24, "recall": 0.91, "p95_ms": 4.1},
3 {"ef_search": 48, "recall": 0.97, "p95_ms": 6.8},
4 {"ef_search": 96, "recall": 0.985, "p95_ms": 12.4},
5]
6required_recall = 0.96
7latency_budget_ms = 10.0
8
9eligible = [
10 point for point in measurements
11 if point["recall"] >= required_recall and point["p95_ms"] <= latency_budget_ms
12]
13choice = min(eligible, key=lambda point: point["p95_ms"])
14print(f"release ef_search={choice['ef_search']} recall={choice['recall']:.3f} p95_ms={choice['p95_ms']}")1release ef_search=48 recall=0.970 p95_ms=6.8IVF (Inverted File Index)
While HNSW is graph-based, IVF is a partition-based algorithm. It divides the vector space into distinct regions called Voronoi cells, where every point is closer to one centroid than to any other centroid. During search, the algorithm only looks inside a small subset of those cells.
Core concept: the centroid shard map
Ask the same question as before: where can we avoid work without losing the rollback runbook? If a true neighbor sits just across a partition boundary, opening only the closest partition will miss it. IVF protects against that miss by choosing a few coarse regions rather than one.
Training happens before serving. Run K-means clustering on a representative sample to learn cluster centers, or centroids. In Faiss terminology, is usually nlist; those centroids become the coarse map.
Indexing assigns each incoming vector to its nearest centroid and stores its ID in that centroid's inverted list. At query time, score all centroids, keep the closest nprobe lists, then score only their candidates. IVF-Flat uses exact distances inside those lists; IVF-PQ can score compressed codes with asymmetric distance computation (ADC) lookup tables.[3]

A worked example: counting comparisons
Suppose you have 1 million embeddings with 128 dimensions. Compare brute force against IVF with nlist = 1,024 and nprobe = 5. Brute force performs distance computations per query.
IVF first scores its centroids. With balanced lists, each list averages vectors, so five probes add about vector scores.
Together, centroid scoring and list scans cost roughly distance computations. That's about a 169× reduction in distance computations. Real systems are slower than this model because lists are uneven and each comparison has overhead, but the principle holds: IVF refuses to scan most of the database. nlist = 1,024 is a round teaching number here, not a universal default.
1def balanced_ivf_scores(total_vectors: int, nlist: int, nprobe: int) -> int:
2 return nlist + round(total_vectors / nlist * nprobe)
3
4total_vectors = 1_000_000
5nlist = 1_024
6nprobe = 5
7estimated_scores = balanced_ivf_scores(total_vectors, nlist, nprobe)
8
9print(f"flat vector scores: {total_vectors:,}")
10print(f"balanced IVF vector and centroid scores: {estimated_scores:,}")
11print(f"estimated reduction: {total_vectors / estimated_scores:.1f}x")1flat vector scores: 1,000,000
2balanced IVF vector and centroid scores: 5,907
3estimated reduction: 169.3xA boundary miss: nprobe = 1 drops a true neighbor
Before running the lab, predict what nprobe = 1 keeps. Query (4.9, 0) is closer to centroid 0, so only list 0 opens. Vector 3 at (5.4, 0) is the true second neighbor, but it lives in list 1 and stays invisible until both lists are probed.
The example uses two hand-set centroids: payments-like vectors near the origin and GPU-like vectors near (10, 0). It isolates the failure mode without hiding it behind clustering noise.
Faiss's IndexIVFFlat is the production form of this loop: train centroids, assign inverted lists, then search with nprobe.[3]
1def squared_l2(left: tuple[float, ...], right: tuple[float, ...]) -> float:
2 return sum((a - b) ** 2 for a, b in zip(left, right))
3
4centroids = {0: (0.0, 0.0), 1: (10.0, 0.0)}
5vectors = {
6 0: (0.2, 0.1),
7 1: (0.4, -0.1),
8 2: (4.6, 0.0),
9 3: (5.4, 0.0),
10 4: (9.8, 0.2),
11}
12
13def nearest_centroid(vector: tuple[float, ...]) -> int:
14 return min(centroids, key=lambda cid: squared_l2(vector, centroids[cid]))
15
16inverted_lists: dict[int, list[int]] = {0: [], 1: []}
17for vector_id, vector in vectors.items():
18 inverted_lists[nearest_centroid(vector)].append(vector_id)
19
20def ivf_search(query: tuple[float, ...], nprobe: int, k: int = 2) -> tuple[list[int], list[int]]:
21 ranked_lists = sorted(centroids, key=lambda cid: squared_l2(query, centroids[cid]))[:nprobe]
22 scored: list[tuple[float, int]] = []
23 for list_id in ranked_lists:
24 for vector_id in inverted_lists[list_id]:
25 scored.append((squared_l2(query, vectors[vector_id]), vector_id))
26 scored.sort()
27 return [vector_id for _, vector_id in scored[:k]], ranked_lists
28
29query = (4.9, 0.0)
30exact = sorted(vectors, key=lambda vid: squared_l2(query, vectors[vid]))[:2]
31nprobe_1, lists_1 = ivf_search(query, nprobe=1)
32nprobe_2, lists_2 = ivf_search(query, nprobe=2)
33
34print(f"inverted lists: {inverted_lists}")
35print(f"exact top-2: {exact}")
36print(f"nprobe=1 lists {lists_1} -> {nprobe_1}")
37print(f"nprobe=2 lists {lists_2} -> {nprobe_2}")1inverted lists: {0: [0, 1, 2], 1: [3, 4]}
2exact top-2: [2, 3]
3nprobe=1 lists [0] -> [2, 1]
4nprobe=2 lists [0, 1] -> [2, 3]nprobe is the main query-time knob. If lists were perfectly balanced, the scanned fraction would be about nprobe / nlist. Real lists are uneven, but the direction still holds: more probes mean higher recall and more work.
nprobe regime | What gets scanned | Practical effect |
|---|---|---|
Very small (1 to a few lists) | Only the closest coarse cells | Lowest latency, highest miss rate. |
| Moderate | A small fraction of the database | Usually the best operating point. |
Large (nprobe close to nlist) | Most or all inverted lists | Approaches exhaustive search and often loses the speed advantage. |
Why does increasing nprobe usually improve IVF recall while increasing query work?
Answer
IVF scores vectors only inside selected lists. Probing more lists gives the search more chances to inspect a true neighbor that landed outside the closest centroid's cell, but each added list contributes more candidate vectors to score.
Distance metrics
Before comparing index families, fix the ranking rule. The same stored vectors can produce a different neighbor order when magnitude matters, so changing the metric changes both search and any IVF centroids trained for it. Faiss supports L2 and inner product directly across its main ANN indexes, and cosine similarity is usually implemented by normalizing vectors and then using inner product search.[3]
L2 distance is the standard Euclidean objective. Use it when vector magnitude carries signal or when the model was trained with an L2-style loss.
Inner product (IP) ranks by maximum dot-product. Query norm doesn't affect ranking, but database vector norms do, so large-norm items can dominate if that's how the embeddings behave.
Cosine similarity ignores magnitude and keeps only direction. Normalize both database and query vectors, then use inner product search. On normalized vectors, L2 and inner product give equivalent rankings because .
| Metric | What it measures | Best when | IVF retrain needed? |
|---|---|---|---|
| L2 (Euclidean) | Straight-line distance in space | Magnitude carries signal | Yes, if switching from another metric |
| Inner product | You want maximum dot-product ranking | Yes, if switching from another metric | |
| Cosine | Direction only, no magnitude | You only care about semantic similarity | Yes, on normalized vectors |
Metric choice changes the geometry seen by the coarse quantizer. If you switch an IVF system from raw L2 search to cosine search, retrain the centroids on normalized vectors rather than reusing the old clustering.
1import math
2
3def normalize(vector: list[float]) -> list[float]:
4 norm = math.sqrt(sum(value * value for value in vector))
5 return [value / norm for value in vector]
6
7vectors = [[3.0, 0.0], [1.0, 1.0], [0.0, 2.0]]
8query = [1.0, 0.8]
9normalized_vectors = [normalize(vector) for vector in vectors]
10normalized_query = normalize(query)
11inner_product = [
12 sum(left * right for left, right in zip(vector, normalized_query))
13 for vector in normalized_vectors
14]
15l2 = [
16 sum((left - right) ** 2 for left, right in zip(vector, normalized_query))
17 for vector in normalized_vectors
18]
19
20print(f"inner-product rank: {sorted(range(3), key=lambda i: -inner_product[i])}")
21print(f"L2 rank after normalization: {sorted(range(3), key=lambda i: l2[i])}")1inner-product rank: [1, 0, 2]
2L2 rank after normalization: [1, 0, 2]HNSW vs IVF
| Feature | HNSW | IVF-Flat |
|---|---|---|
| Category | Proximity graph | Clustering / inverted file |
| Search cost | Empirically sublinear on suitable well-built graphs; benchmark on your data | Centroid scoring plus scanning roughly nprobe / nlist of the data if lists are balanced |
| Memory | Raw vectors plus about layer-0 neighbor ids | Raw vectors plus ids; no graph overlay |
| Training step | None | Required to learn centroids |
| Index updates | Inserts are natural; deletes are implementation-specific | Inserts are easy, but centroid quality can drift over time |
| Recall | Often strong at a fixed in-RAM latency budget; workload dependent | Tunable via nprobe; workload dependent |
| Build time | Slow | Faster once centroids are trained |
| Best for | Low-latency, high-recall search in RAM | Large datasets where you want a controllable scan fraction or a base for PQ |
The biggest operational difference is that IVF requires a training step on representative data before indexing. If your vector distribution drifts, stale coarse centroids can unbalance lists or lower recall. HNSW doesn't have that centroid-training dependency. At typical LLM embedding sizes the extra graph ids are a small add-on to the same raw vectors IVF-Flat already stores; HNSW's usual extra costs are build time and delete behavior, not a second copy of the corpus.
Product Quantization (PQ)
For billion-scale datasets, storing full float32 vectors in RAM is usually impractical on a single RAM-resident node.
One billion vectors at 768 float32 dimensions need about 3 TB of raw vector storage alone. Add index overhead, operating-system buffers, and application memory on top of that.
You have 1B vectors, raw float32 storage needs terabytes of RAM, and one node only has 128 GB. What must product quantization buy you first before anything else matters?
Answer
It must buy memory reduction. By replacing sub-vectors with compact centroid IDs, PQ lets far more vectors stay resident, which is what makes any fast single-node search plan feasible in the first place.
Product Quantization (PQ) compresses vectors by breaking them into smaller chunks and quantizing those chunks independently.[4] Combined with IVF, PQ is one established way to make billion-point memory budgets tractable; whether a deployment also needs sharding depends on code size, metadata, throughput, and availability requirements.
The algorithm
Product quantization divides the high-dimensional space into lower-dimensional subspaces and quantizes each one independently. A single full-vector codebook would need an impractically large number of centroids to represent many combinations of values. PQ factorizes that codebook into smaller pieces that are cheaper to train and store.
First split the original -dimensional vector into sub-vectors of dimension . A 768-dimension vector split into 96 sub-vectors yields chunks of dimension 8.
Then cluster each subspace separately. Run K-means on training data to find a codebook for each chunk, often with centroids. Finally, encode each sub-vector as the ID of its nearest centroid. The full vector becomes small IDs instead of floats.
In an IVF-PQ system, those PQ codes are usually built on the residual vector after subtracting the assigned coarse centroid, not on the raw vector itself.[3] Quantizing the smaller residual usually gives better accuracy for the same code size.

By using centroids, an ID requires just 1 byte of storage (since ). A 768-dim float vector (3072 bytes) compressed with becomes an array of 96 single-byte IDs, taking up exactly 96 bytes. That's 32× compression for the vector code before ids, codebooks, centroids, and other index overhead.
1def bytes_per_raw_vector(dimensions: int) -> int:
2 return dimensions * 4
3
4def bytes_per_pq_code(chunks: int, bits_per_chunk: int = 8) -> int:
5 return chunks * bits_per_chunk // 8
6
7raw_bytes = bytes_per_raw_vector(768)
8code_bytes = bytes_per_pq_code(96)
9ivf_pq_bytes_with_id = code_bytes + 8
10
11print(f"raw vector bytes: {raw_bytes}")
12print(f"PQ code bytes: {code_bytes}")
13print(f"IVF-PQ code plus 64-bit id bytes: {ivf_pq_bytes_with_id}")
14print(f"raw-to-code compression: {raw_bytes / code_bytes:.0f}x")1raw vector bytes: 3072
2PQ code bytes: 96
3IVF-PQ code plus 64-bit id bytes: 104
4raw-to-code compression: 32xOPQ: rotating before you split
Plain PQ has a weakness: it splits dimensions into fixed contiguous chunks. If the variance in your embeddings is unevenly spread across dimensions, some subspaces carry most of the signal while others are nearly constant. Their codebooks spend centroids on uninformative directions.
Optimized Product Quantization (OPQ) learns an orthogonal rotation matrix and applies it before the split, distributing useful variation across chunks.[5] An orthogonal rotation preserves L2 distances, so it costs one matrix multiply per query but loses no geometric information before quantization.
In Faiss you can request OPQ with a factory string such as OPQ96,IVF4096,PQ96 (keeps the original dimension) or OPQ96_384,IVF4096,PQ96 (rotates and reduces to 384-d, so each of the 96 chunks is 4-d). OPQ is a candidate to benchmark at the same code size; its recall benefit depends on the embedding distribution and training sample.
Asymmetric distance computation (ADC)
We don't decompress vectors to search them. For each query sub-vector, calculate its distance to every centroid once and keep those values in a lookup table. A stored code then needs only one lookup per chunk and a sum.
Mathematically, the squared Euclidean distance between a query vector and a quantized vector (approximated by codebook centroids ) is:
Where is the -th sub-vector of the query, and is the centroid for the -th sub-vector of . This is the core of Asymmetric Distance Computation (ADC): the query stays uncompressed while the database uses codes. Instead of reconstructing an approximate full vector and computing distance against it, the query-time distance for any stored vector becomes a table lookup and sum.
In IVF-PQ, Faiss's default is to encode the residual after subtracting the coarse centroid.[3] ADC then compares the query residual to those residual codes, so the lookup tables are rebuilt (or selected) per probed list rather than once globally. The lab below shows the simpler non-residual case: one table per subspace, then a sum of lookups.
Why does ADC stay practical at large N while reconstructing every approximate vector doesn't?
Answer
ADC computes query-to-centroid distances once, then scores each database vector with cheap table lookups and additions. Reconstructing every approximate vector would reintroduce much more per-vector work at query time.
1def squared_l2(left: list[float], right: list[float]) -> float:
2 return sum((a - b) ** 2 for a, b in zip(left, right))
3
4def adc_pq_search(
5 query: list[float],
6 pq_codes: list[list[int]],
7 codebooks: list[list[list[float]]],
8 k: int = 2,
9) -> list[int]:
10 subspaces = len(codebooks)
11 dim_per_chunk = len(query) // subspaces
12 tables = []
13 for index, codebook in enumerate(codebooks):
14 query_chunk = query[index * dim_per_chunk : (index + 1) * dim_per_chunk]
15 tables.append([squared_l2(query_chunk, centroid) for centroid in codebook])
16
17 distances = []
18 for row in pq_codes:
19 distances.append(sum(tables[index][code] for index, code in enumerate(row)))
20 ranked = sorted(range(len(pq_codes)), key=lambda vector_id: (distances[vector_id], vector_id))
21 return ranked[:k]
22
23query = [0.0, 0.0, 0.0, 0.0]
24codebooks = [
25 [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0]],
26 [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0]],
27]
28pq_codes = [
29 [0, 0],
30 [1, 0],
31 [0, 1],
32 [1, 1],
33]
34
35print(f"nearest ids: {adc_pq_search(query, pq_codes, codebooks, k=2)}")1nearest ids: [0, 1]Filtered search
The index can behave well for an unfiltered query and still fail once access rules narrow the candidate set. A support tool asks for "runbooks like this one, but only from the last 30 days and only for the EU region." Before choosing a filter strategy, predict the failure: will the filter shrink work, empty the candidate buffer, or block graph routes needed to reach allowed neighbors? Three strategies make that trade-off concrete.
Filter then exact scan: first finds rows matching the predicate, then scores only that subset. If the allowed set is small, this is exact and can be efficient. It gets costly as the allowed set grows.
Post-filtering (search, then filter): runs normal ANN search for some candidate count, then discards anything that fails the predicate. This keeps the graph intact, but it has an overfiltering failure mode: if the predicate is selective (say only 1% of vectors match), the initial ANN candidate buffer may contain zero matches, so you return fewer than k results or none at all. pgvector applies filters after an approximate index scan and can expand with iterative index scans (hnsw.iterative_scan / ivfflat.iterative_scan) until enough matches appear or a budget is exhausted.[1]
Allow-list graph traversal: the walk can still traverse disallowed nodes for connectivity, but only allowed nodes count toward the result set. Weaviate's pre-filtered HNSW does this. Since v1.34, ACORN is the default filter strategy; a sweeping walk remains available. ACORN is most useful when a restrictive filter correlates poorly with the vector neighborhood, so the graph region nearest the query is mostly disallowed.[6][7] A tiny allowed set can still make a flat scan cheaper.
Those three placements:

Filter placement is a security boundary as well as a recall knob. Trusted filter-then-exact or in-traversal allow-lists keep unauthorized vectors out of the application path; app-side post-filtering after unrestricted ANN can leak privileged text through logs, traces, caches, or partial failures even when the final answer list is scrubbed. RAG Security & Access Control treats that trusted-plane contract in depth.
1candidate_ids = ["eu-1", "us-1", "us-2", "us-3", "eu-2"]
2region = {"eu-1": "eu", "us-1": "us", "us-2": "us", "us-3": "us", "eu-2": "eu"}
3
4def post_filter(candidates: list[str], required_region: str, k: int) -> list[str]:
5 return [item for item in candidates if region[item] == required_region][:k]
6
7initial_candidates = candidate_ids[:3]
8expanded_candidates = candidate_ids
9
10print(f"initial result: {post_filter(initial_candidates, 'eu', k=2)}")
11print(f"expanded result: {post_filter(expanded_candidates, 'eu', k=2)}")1initial result: ['eu-1']
2expanded result: ['eu-1', 'eu-2']Why can a selective filter make post-filtering return fewer than k results?
Answer
Post-filtering ranks the top candidates first, then drops non-matches. If only a tiny fraction of vectors satisfy the predicate, the ANN candidate buffer may contain few or no matches, so the final list comes up short. Over-fetching or in-traversal filtering avoids this.
Four questions a vendor page can't answer
When you evaluate a vector database, ask four questions that survive vendor churn. Each one connects an index choice to an operating constraint:
- Which ANN families are exposed: HNSW, IVF, PQ, disk-backed graphs, or a managed index whose internals aren't exposed?
- How does filtered search work: before search, after search, or during traversal?
- Where does the index body live: RAM, SSD, or object storage?
- Which compression knobs are available, and what recall do they cost?
That framing travels better than product trivia. As a working heuristic, Postgres-style extensions expose a small set of explicit index types such as HNSW or IVFFlat inside SQL workflows.
General-purpose vector databases often center HNSW for RAM-resident search, then layer in quantization and filter-aware traversal. That makes filtered-search behavior part of the index evaluation, not a later integration detail.
Faiss and Faiss-derived stacks expose broad ANN menus, which shifts more tuning responsibility onto the engineer. The freedom is useful when you can own benchmarking and deployment decisions.
Storage-first systems move more bytes out of RAM and onto SSD or object storage, which improves cost efficiency but makes IO behavior part of your latency budget.
These questions lead to a workload decision, not a vendor slogan: HNSW is a candidate when you want strong in-memory recall without centroid training, while IVF and PQ are candidates when scan fraction or bytes per vector become central constraints. Choose from measured recall, tail latency, build/update cost, and memory on your workload.
Scaling to billions
When the rollback corpus grows past RAM, two budgets compete: compress the vectors, or move most of the index body to storage. A billion-vector incident-search platform might compress each trace-summary vector with PQ, keep a sparse graph in memory, or compare both against a sharded design. The right branch depends on bytes, recall, and tail latency together.
1. Hybrid approaches (IVF-PQ)
Combining IVF (to reduce search scope) and PQ (to compress vectors) targets both budgets. In Faiss, IndexIVFPQ stores about ceil(m_pq * nbits / 8) + 8 bytes per vector for the PQ code plus the vector id, where m_pq is the number of PQ sub-vectors/codebooks. A 32-byte code therefore lands near 40 bytes per vector, so 1B vectors is roughly 40 GB before centroids and other overhead. The savings buy capacity, but quantization error and extra knobs can cost recall compared with HNSW.
2. Disk-based indexes (DiskANN)
Microsoft's DiskANN[8] keeps a compact in-memory structure and places the large graph body on SSD. The graph is still navigational, but its layout is optimized for storage access rather than assuming every edge traversal hits RAM. Search quality therefore depends on read amplification and layout as well as graph degree and beam width.
The NeurIPS 2019 paper abstract reports >5000 queries per second, <3 ms mean latency, and 95%+ 1-recall@1 on SIFT1B on a 16-core machine with 64 GB RAM plus SSD. Those are benchmark-specific measurements, not a universal promise for every embedding workload.
3. Distributed sharding
If neither compression nor storage fits the single-node budget, shard horizontally with scatter-gather.

Split 1B vectors into shards, such as 10 shards of 100M each. A gateway broadcasts the query to all nodes (scatter); each node returns its local top-; then the gateway merges those packets into the global top- (gather).
Full fan-out cuts local work but exposes the request to coordination overhead and the slowest shard. Throughput doesn't scale linearly, and tail latency (P99) is a property of the whole scatter-gather path, not any one shard.
When vector indexes break
Most failures announce themselves as one of three symptoms: recall drops after the data or metric changes, results underfill after filtering or deletes, or p99 rises while local work looks healthy. Use the table to connect each symptom to the budget that moved and the evidence needed before changing a knob.
| Symptom | Likely cause | Fix |
|---|---|---|
| IVF recall drops after a data shift | centroids no longer represent new embeddings | retrain on representative data and gate on held-out recall@k |
| HNSW causes out-of-memory failures | raw vectors (plus graph ids and allocator overhead) exceed budget | measure bytes per vector at production settings; evaluate PQ or IVF-PQ before blaming graph degree |
| Search is fast but misses neighbors | nprobe or ef_search is too small | tune family-specific search budgets against exact top-k |
| Cosine migration breaks IVF recall | old coarse quantizer was trained in raw L2 geometry | normalize training, database, and query vectors; retrain centroids |
| Deletes create stale hits or recall holes | tombstones consume traversal budget until compaction | set delete SLOs and rebuild when tombstone fraction or dead-node cost crosses budget |
| More shards worsen p99 | full fan-out waits for slowest shard and merge | measure tail latency and routing overhead, not local shard latency alone |
Index family trade-offs
| Index family | Core idea | Main strength | Main cost | Reach for it when |
|---|---|---|---|---|
| HNSW | Hierarchical proximity graph | Often strong in-RAM recall/latency behavior | Build time, delete behavior, and raw-vector RAM | Low latency matters, the working set's float32 vectors fit in RAM, and evaluation supports it |
| IVF-Flat | Coarse partitioning plus exact scan inside probed lists | Simple and tunable | Needs training and still stores raw vectors | You want controllable scan fraction without graph complexity |
| IVF-PQ | IVF plus compressed vector codes | Much lower bytes per vector | Quantization error and more tuning | Memory cost dominates and some recall loss is acceptable |
| DiskANN | SSD-backed graph index | Single-node scale beyond RAM | Storage-aware engineering complexity | The dataset is larger than RAM but one fast SSD-backed node is still attractive |
Evaluate HNSW when you can afford RAM and need low-latency high-recall retrieval. Evaluate IVF-PQ when raw vector storage becomes the bottleneck. Consider DiskANN-style systems when a graph index remains attractive but the index body no longer fits in memory. In each case, release from a recall-and-latency curve measured on representative queries.
Release from a measured index frontier
Freeze the embedding model, distance normalization, representative query set, filters, and exact top- baseline. That fixes what "correct" means while you compare candidates.
Then sweep each candidate's search budget and plot recall against p95 latency and bytes per vector under target concurrency. Add build time, update/delete behavior, and filtered-search completeness to the release scorecard. Choose the cheapest point that clears every constraint, not the index with the best isolated benchmark.