|
2 | 2 | date: 2026-06-03 |
3 | 3 | tag: search |
4 | 4 | title: "How ScaNN works" |
5 | | -read: 8 min |
6 | | -deck: "Google's vector search algorithm explained with analogies — partitioning, approximate scoring, and why compression isn't just compression." |
| 5 | +read: 9 min |
| 6 | +deck: "Google's vector search algorithm from the ground up — with diagrams. Partitioning, approximate scoring, and why compression isn't just compression." |
7 | 7 | --- |
8 | 8 |
|
9 | | -You're building a music recommendation system. You have 100 million songs. A user plays one and you want to find the 10 most similar songs — instantly, every time. |
| 9 | +You're running an e-commerce search engine. A user types "red running shoes" and you want to find the 10 most relevant products — not just keyword matches, but semantically similar ones. You have 10 million product embeddings. You need a result in under 50 milliseconds. |
10 | 10 |
|
11 | | -The obvious approach: compare the query song to every other song, rank by similarity, return top 10. That's 100 million comparisons per request. At any real scale, that's too slow. |
| 11 | +The obvious approach — compare the query vector to every product, rank by similarity, return top 10 — is too slow at that scale. 10 million dot products, 768 dimensions each, at every query. |
12 | 12 |
|
13 | | -ScaNN is Google's solution to this problem. It's the algorithm behind several of Google's production search and recommendation systems, open-sourced in 2020. It solves the speed problem in three phases, and each phase has a neat idea behind it. |
| 13 | +ScaNN (Scalable Nearest Neighbors) is Google's solution. It's been open-sourced since 2020 and underlies several of Google's production retrieval systems. It solves the speed problem in three phases, each built on a specific insight about the structure of similarity search. |
14 | 14 |
|
15 | | -## first: what is "similarity" here? |
| 15 | +## the three phases |
16 | 16 |
|
17 | | -Before the algorithm, a quick grounding. In vector search, each item (a song, a product, a document) is represented as a list of numbers — a *vector*. These numbers capture some learned sense of the item's meaning or character: similar items end up with similar vectors. |
| 17 | +<div data-widget="scann-pipeline"></div> |
18 | 18 |
|
19 | | -Similarity between two vectors is usually measured as a dot product: multiply each pair of corresponding numbers and add them up. A high result means the vectors are pointing in the same direction — the items are similar. This is called MIPS: Maximum Inner Product Search. ScaNN is built to do MIPS fast. |
| 19 | +ScaNN breaks the search into three stages, each dramatically narrowing the candidate set: |
20 | 20 |
|
21 | | -## phase 1: partitioning — stop searching everywhere |
| 21 | +1. **Partitioning** — group the corpus into neighborhoods, search only the most relevant ones. |
| 22 | +2. **Approximate scoring** — use compressed vectors for fast similarity estimates. |
| 23 | +3. **Exact rescoring** — rerank a small shortlist with precise dot products. |
22 | 24 |
|
23 | | -The first idea is simple: **don't look at everything**. |
| 25 | +## phase 1: partitioning |
24 | 26 |
|
25 | | -Think of a library with 100,000 books. If you want books similar to a mystery novel you enjoyed, you don't start at shelf A1 and check every book. You walk to the mystery section. You've already cut the search down to a few thousand books. |
| 27 | +The core idea: most of the corpus is irrelevant to any given query. Before scoring anything, figure out *where in the space* the answer probably lives — then only look there. |
26 | 28 |
|
27 | | -ScaNN does the same thing. At index time, it runs k-means clustering on all the vectors and groups them into *k* clusters — neighborhoods of similar vectors. Each cluster has a centroid, which is the average point at the center of that group. |
| 29 | +ScaNN runs k-means clustering on the corpus at index time. This groups all 10 million products into `k` clusters — neighborhoods of similar items. Each cluster has a centroid (its average position). |
28 | 30 |
|
29 | 31 | At query time: |
30 | 32 |
|
31 | | -1. Score the query against all `k` centroids. This is cheap — just `k` comparisons. |
32 | | -2. Pick the top `t` most similar centroids (the most promising neighborhoods). |
33 | | -3. Only look at vectors inside those `t` clusters. |
| 33 | +1. Score the query against all `k` centroids. This is cheap: just `k` dot products. |
| 34 | +2. Pick the top `t` closest centroids — the most promising neighborhoods. |
| 35 | +3. Score only the vectors inside those `t` clusters. |
34 | 36 |
|
35 | | -With 10 million vectors and `k = 3,000` clusters (~√10M), each cluster holds about 3,000 vectors. If you search the top 100 clusters, you're scoring 300,000 vectors instead of 10 million — a 33x reduction before doing anything else. |
| 37 | +A practical rule of thumb is `k ≈ √N`. With 10M products, that's roughly 3,000 clusters of ~3,000 vectors each. If you search the top 100 clusters, you're scoring 300,000 vectors instead of 10 million — a 33× speedup before any other optimization. |
36 | 38 |
|
37 | | -The dial here is `t`: how many clusters to search. More clusters → better results → higher latency. In practice, searching 3–10% of clusters gives good recall for most datasets. |
| 39 | +<div data-widget="scann-partition"></div> |
38 | 40 |
|
39 | | -## phase 2: approximate scoring — compress the vectors |
| 41 | +Toggle the query above to see how different searches land in different parts of the product space. The key point: vectors outside the searched clusters are never touched — no memory reads, no dot products. |
40 | 42 |
|
41 | | -After partitioning you still have hundreds of thousands of vectors to score. Storing 10 million vectors at 768 dimensions in full precision is ~30GB. Doing exact dot products across that is expensive even after the partitioning step. |
| 43 | +The tradeoff dial is `t` (how many clusters to search). More clusters → better recall, higher latency. Most production systems search 3–10% of clusters. |
42 | 44 |
|
43 | | -So ScaNN compresses the vectors. The technique is called **quantization** — instead of storing each vector precisely, you store an approximation that's cheap to compute with. |
| 45 | +## phase 2: approximate scoring |
44 | 46 |
|
45 | | -Here's the analogy: think of your music taste as a point on a map. Storing your exact location takes many decimal places. Instead, you just say "I'm in neighborhood #42." You lose some precision, but you can compare neighborhoods cheaply. |
| 47 | +After partitioning, you still have ~300,000 vectors to score. Doing exact dot products for all of them is still expensive — 300,000 × 768-dimensional float multiplications, repeated for every query. |
46 | 48 |
|
47 | | -In quantization, ScaNN pre-computes a "codebook" of reference vectors during index building. Each database vector is then encoded as a sequence of references into that codebook — a short code instead of a full vector. At query time, ScaNN pre-computes how similar the query is to each codebook entry, then scores every compressed vector via fast table lookups instead of full dot products. |
| 49 | +ScaNN compresses the vectors. The technique is **quantization**: instead of storing each vector as 768 float32 numbers, store it as a compact code that's fast to compare. |
48 | 50 |
|
49 | | -This is dramatically faster. You're looking up pre-computed numbers in a small table, not doing hundreds of floating-point multiplications per vector. |
| 51 | +Here's how it works. At index time, ScaNN groups each vector's dimensions into sub-blocks and builds a small "codebook" of reference patterns for each block. A 768-dimensional vector might be split into 8 blocks of 96 dimensions, with a 256-entry codebook per block. Each vector is then encoded as 8 numbers (codebook indices), one per block — instead of 768 floats. |
50 | 52 |
|
51 | | -## the clever part: compression that cares about what matters |
| 53 | +At query time, ScaNN precomputes the dot product between the query and every codebook entry (8 blocks × 256 entries = 2,048 lookups). Then scoring any compressed vector is just 8 table lookups and an addition — dramatically faster than a full dot product. |
52 | 54 |
|
53 | | -Standard compression minimizes error uniformly — it tries to reconstruct each vector as accurately as possible in all directions, equally. That sounds right. But for similarity search, it's the wrong goal. |
| 55 | +This is product quantization (PQ), and it's standard. What makes ScaNN different is *how* it trains the compressor. |
54 | 56 |
|
55 | | -Here's why. You only care about getting the *ranking* right for the vectors with the *highest* similarity to your query. The vector at rank 47,000 can have a completely wrong approximate score and it doesn't matter — you were never going to return it. |
| 57 | +## the key insight: compress where it matters |
56 | 58 |
|
57 | | -And here's the key geometry: vectors with high similarity to your query tend to be *pointing in the same direction* as your query. If you think of the query as an arrow, the relevant results are other arrows roughly aligned with it. |
| 59 | +Standard PQ trains the codebooks to minimize reconstruction error — the distance between the original vector and its compressed version, averaged uniformly across all directions. That sounds right, but it's the wrong goal for search. |
58 | 60 |
|
59 | | -When you compress a vector and introduce a small error, that error can be decomposed into two parts: |
60 | | -- **parallel error** — error in the direction the vector is pointing |
61 | | -- **perpendicular error** — error in the sideways directions |
| 61 | +Here's why. When you compress a vector `x` to `x̃`, you introduce an error `e = x̃ − x`. That error shows up in your similarity estimates: instead of computing the true dot product `⟨q, x⟩`, you compute the approximate `⟨q, x̃⟩`. The difference is `⟨q, e⟩`. |
62 | 62 |
|
63 | | -For two arrows pointing roughly the same way, the parallel error is what throws off the dot product between them. Perpendicular error mostly cancels out when you compute the dot product. |
| 63 | +Now split the error into two parts: one component pointing in the same direction as `x` (parallel), and one pointing sideways (perpendicular). |
64 | 64 |
|
65 | | -Standard quantization treats both error types equally. **Anisotropic vector quantization (AVQ)** — ScaNN's approach — penalizes parallel error more heavily during training. The result: the compressed vectors are more accurate in the direction that matters for ranking, at the cost of being slightly less accurate sideways. |
| 65 | +<div data-widget="scann-error"></div> |
66 | 66 |
|
67 | | -A concrete analogy: imagine rating restaurants by how much you'd enjoy them. You care a lot about cuisine type (Italian vs. Thai) and not much about how many plants are in the decor. Good compression for *you specifically* would preserve cuisine type precisely and be approximate on the plants. ScaNN does the same — it figures out which "directions" matter for inner product search and compresses to be accurate there. |
| 67 | +Step through the diagram above. The key observation at step 3: when `q` and `x` are similar (roughly aligned — which they must be for `x` to be a relevant result), the query `q` is approximately parallel to `x`. That means: |
68 | 68 |
|
69 | | -The practical effect: at the same compression ratio, AVQ produces better ranking of the top results than standard quantization does. You're not paying more — you're spending the same compression budget more wisely. |
| 69 | +- **Parallel error** `e∥` is in a direction `q` is sensitive to — it directly changes the dot product estimate. |
| 70 | +- **Perpendicular error** `e⊥` is in a direction `q` is mostly blind to — it largely cancels out. |
70 | 71 |
|
71 | | -## phase 3: rescoring — clean up the ranking |
| 72 | +Standard PQ treats both error types equally. **Anisotropic vector quantization (AVQ)** — ScaNN's approach — penalizes parallel error more during training. The quantizer learns codebooks that are more accurate in the direction that matters for ranking, accepting more perpendicular error in exchange. |
72 | 73 |
|
73 | | -Approximate scoring gives you a rough ranked list. It's good but not perfect — the item that should be rank 2 might have slipped to rank 8 due to compression error. |
| 74 | +Same compression ratio. Better ranking of the results that actually matter. |
74 | 75 |
|
75 | | -The fix is cheap: take the top-c candidates from phase 2 (say, the top 200), and compute their exact similarity scores. No compression, no shortcuts — just the real dot product. Rerank those 200 and return the final top 10. |
| 76 | +## phase 3: exact rescoring |
76 | 77 |
|
77 | | -This works because `c` is tiny compared to `N`. You did the hard work narrowing from 10 million to 200. Re-scoring 200 vectors exactly is fast. The cost is proportional to `c`, not `N`. |
| 78 | +After approximate scoring, you have a ranked shortlist of ~200 candidates. Quantization errors can scramble this list slightly — an item that should be rank 3 might appear at rank 11. |
78 | 79 |
|
79 | | -The full pipeline then looks like: |
| 80 | +The fix is straightforward: take those 200 candidates and compute their exact dot products. Rerank. Return top 10. |
80 | 81 |
|
81 | | -``` |
82 | | -10,000,000 vectors |
83 | | - → partition: score 3,000 centroids → pick top 100 clusters |
84 | | - → approximate score: ~300,000 vectors via fast table lookups → top 200 |
85 | | - → exact rescore: 200 vectors → top 10 returned |
86 | | -``` |
| 82 | +This is fast because you're doing exact arithmetic on 200 vectors, not 10 million. The expensive part was finding those 200. Rescoring them is cheap — and it brings accuracy back close to brute-force quality. |
87 | 83 |
|
88 | | -Each step is much faster than the one before, and the final answer is nearly as accurate as searching everything exactly. |
| 84 | +The three phases form a cost pyramid: |
89 | 85 |
|
90 | | -## SOAR: the problem at the borders |
| 86 | +| phase | candidates | cost | |
| 87 | +|---|---|---| |
| 88 | +| centroid scoring | 3,000 centroids | cheap | |
| 89 | +| AQ scoring | ~300,000 vectors | fast (table lookups) | |
| 90 | +| exact rescoring | ~200 candidates | fast (small count) | |
| 91 | +| brute force | 10,000,000 vectors | too slow | |
91 | 92 |
|
92 | | -There's a subtle issue with single-cluster assignment. A vector sitting near the boundary between two clusters might belong to cluster A, but some queries that would love that vector happen to search only cluster B. |
| 93 | +## SOAR: the boundary problem |
93 | 94 |
|
94 | | -It's like a book about "spy thrillers" that could reasonably sit in either the Mystery section or the Thriller section. If the librarian files it in Mystery but a reader browsing Thrillers would have loved it, they never find it. |
| 95 | +There's a subtle failure mode in the partitioning phase. Each vector is assigned to exactly one cluster — its nearest centroid. But vectors near the boundary between two clusters can fall through the cracks. |
95 | 96 |
|
96 | | -SOAR (Google, NeurIPS 2023) fixes this by assigning borderline vectors to **two** clusters instead of one. The primary cluster is the nearest centroid as normal. The secondary cluster is chosen with a specific rule: pick the backup cluster such that its centroid is a good proxy for this vector *from the perspective of likely queries*. |
| 97 | +Imagine a product that's borderline between "running shoes" and "athletic footwear." Its true nearest centroid says "athletic footwear," but queries for running shoes mostly search the "footwear" cluster. If that cluster isn't in the top-t for the query, the product is never scored. |
97 | 98 |
|
98 | | -The math behind choosing the secondary cluster is what gives SOAR its name (Spilling with Orthogonality-Amplified Residuals), but the intuition is: find a backup neighborhood where queries that would want this item are likely to look. The vector gets a second chance to be found without you having to search more clusters. |
| 99 | +This is the boundary recall problem: a correct result gets missed because its assigned cluster wasn't selected, even though it's very similar to the query. |
99 | 100 |
|
100 | | -The cost: each borderline vector appears in two posting lists, so storage increases by up to 2x. The benefit: meaningfully better recall at the same search latency. |
| 101 | +**SOAR** (NeurIPS 2023) fixes this by assigning each boundary vector to *two* clusters instead of one. The secondary cluster is chosen so that its centroid is a good proxy for the vector from the perspective of likely queries. Specifically: the residual from the backup centroid should be nearly perpendicular to typical query directions — ensuring queries that would want this vector will find it via the backup cluster. |
| 102 | + |
| 103 | +The cost is roughly 2× storage for boundary vectors. The benefit is meaningfully better recall without searching more clusters. |
101 | 104 |
|
102 | 105 | ## pre-filtering: searching a subset |
103 | 106 |
|
104 | | -One last thing ScaNN handles cleanly: filters. "Find similar songs — but only from artists I follow." |
| 107 | +One more thing ScaNN handles cleanly. Suppose the user adds a filter: "red running shoes, under $100." You need similar products *and* matching the price constraint. |
| 108 | + |
| 109 | +The naive approach (post-filter): find the 10 most similar products, then apply the filter. If only 1% of products are under $100, most results get discarded. You return 1 product instead of 10. |
105 | 110 |
|
106 | | -The naive approach (post-filtering) finds the 10 most similar songs from 100M, then checks if you follow the artist. If you follow 1% of artists, 9 of those 10 results get discarded. You return 1 song instead of 10. |
| 111 | +ScaNN supports filtering *during* scoring rather than after: |
107 | 112 |
|
108 | | -ScaNN can apply the filter *during* scoring instead of after. At the cluster level, it skips entire clusters that contain no songs from artists you follow. At the vector level, it skips individual vectors that don't match the filter before computing any approximate score. |
| 113 | +- **At the cluster level**: skip any cluster that contains zero products matching the filter. No quantized scoring at all for those clusters. |
| 114 | +- **At the vector level**: inside a selected cluster, skip any vector that fails the filter before computing its approximate dot product. |
109 | 115 |
|
110 | | -This works because ScaNN's phases are independent scoring steps — there's no graph to navigate, no requirement that all vectors be reachable. You can freely skip any vector and the remaining ones are still found correctly. Filtering just reduces work; it doesn't break anything structural in the index. |
| 116 | +This works cleanly because ScaNN's scoring is independent per vector — there's no graph to navigate, no connectivity to preserve. Skipping a vector just means not scoring it. The remaining vectors are still found correctly. |
111 | 117 |
|
112 | | -## the shape of the algorithm |
| 118 | +## putting it together |
113 | 119 |
|
114 | 120 | Each phase of ScaNN answers one question: |
115 | 121 |
|
116 | | -- **Partitioning** — *where* in the space should we look? |
117 | | -- **Approximate scoring (AVQ)** — *how cheaply* can we score vectors there? |
118 | | -- **Rescoring** — *how accurately* do we finalize the ranking? |
| 122 | +- **Partitioning** — where in the space should we look? |
| 123 | +- **AQ scoring** — how cheaply can we score candidates there? |
| 124 | +- **Rescoring** — how accurately do we finalize the ranking? |
119 | 125 |
|
120 | | -The interesting part is the compression. Anisotropic quantization isn't just an implementation detail — it reflects a real insight about what makes MIPS different from generic vector compression. The error that matters is the error in the direction the query is pointing. Spend your compression budget there. |
| 126 | +The interesting design is the compression. Anisotropic quantization isn't just an implementation tweak — it reflects a real asymmetry in similarity search: the error that corrupts ranking is directional. The error in the query's direction matters; error sideways mostly doesn't. Spending the compression budget to reduce directional error is the right trade, and it's why ScaNN consistently outperforms standard PQ on recall benchmarks at the same compression ratio. |
121 | 127 |
|
122 | 128 | — v |
0 commit comments