WORKBENCH
03 · DataLesson 3.6

Nearest neighbors at scale.

How do you find the closest embedding among 100 million without checking them all?

Listen · 15 min0:00 / 14:40

Vector search is how a computer finds, among millions of stored passages, the few whose meaning sits closest to your question, when each passage has been boiled down to a long list of numbers.

It’s the step that runs before your cross-encoder ever sees anything, and it quietly sets a ceiling on how good your ranker can look. When a search result is bad, the fault is often here, in a stage that’s easy to treat as plumbing. It’s also a favourite interview question, because the honest answer is a page of arithmetic about bytes and memory speed.

It leans on two things you already have: the napkin math from How does it scale?, and the idea from Why ~17 tokens per second? that a chip is often stuck waiting for numbers to arrive from memory rather than working on them. After it, Ranking metrics and eval sets asks how sure you can be that one search setup really beats another, and the “match by meaning” cache from Caching and Redis turns out to be this same machinery.

Today

You’ll want the napkin math from How does it scale? and the “waiting on memory versus working” idea from Why ~17 tokens per second?; Why is NumPy 100x faster? helps too. Plan on one evening to read and one long session on the Spark.

The Problem

Your cross-encoder is good. Give it a query and a passage and it reads them together, every word of one attending to every word of the other, and outputs one relevance score. It is also slow on purpose: one forward pass of a small transformer per pair. Call it a millisecond per pair on a GPU when batched, which is generous.

Now the index has 100 million passages. Scoring all of them for one query is 100 million forward passes. At a millisecond each that is 28 hours. Per query.

So nobody does that. Search systems run in two stages. First, a cheap stage pulls a thousand or so plausible candidates out of the hundred million. Then the expensive model, your model, reorders only those. The ranker can only reorder what it is handed: if the right passage isn’t in the thousand, no amount of training on your side brings it back.

The cheap stage, for a growing share of systems, works like this. A small bi-encoderA model that turns a query and a document into vectors separately, so documents can be turned into vectors once, ahead of time. Relevance is then a simple vector comparison. Contrast with a cross-encoder, which must see both texts together. turns every passage into a list of numbers ahead of time, say 768 of them. At query time it turns the query into 768 numbers too, and the job becomes: which of the 100 million stored lists is closest to this one?

That sounds easy. It’s one dot product per passage. Let’s see what “one dot product per passage” costs when there are 100 million passages, and then spend the rest of the lesson cheating our way out of it.

The Idea

An embedding is a point

An embeddingA fixed-length list of numbers that a model outputs for a piece of text (or an image, or a user). The model is trained so that things with similar meaning get similar lists. is a list of numbers, like 768 floats. Read the list as coordinates, and it is a point in a 768-dimensional space. Two numbers would be a point on a map. Three would be a point in a room. 768 is a point in a space you can’t picture, but every rule you know from the map still holds: points have distances, and some points are closer than others.

The embedding modelThe neural network that produces embeddings, for example a small sentence-transformer. Trained so that a query lands near the passages that answer it. was trained to arrange that space for you. A query like “how do I reset my router” lands near passages about routers and modem lights and far from passages about pasta. Search becomes geometry: find the stored points nearest the query point. That task is called kNNk-nearest neighbours: given a query point, find the k stored points closest to it. “Nearest neighbour search” usually means this..

FIG_001[ POINTS, ANGLES, DISTANCES ]
EMBEDDING SPACE (2 OF 768 DIMS)QUERYreset routermodem lightswifi dropspasta recipetomato saucegpu memorytax formsabL2θUNIT CIRCLEcos θ = a·b / (|a| |b|)unit length: |a − b|² = 2 − 2 cos θ
Left: meaning becomes position, and search becomes 'which points are near the query'. Right: three ways to say 'near'. Cosine looks only at the angle. Dot product is the angle times both lengths. Euclidean (L2) is the straight-line gap. On unit-length vectors all three rank neighbours identically.

Three ways to say “near”

There are three common measures, and you will see all three in library flags.

Here is the identity that ties them together. Expand the squared L2 distance:

|a − b|²  =  |a|²  +  |b|²  −  2 (a · b)

Now suppose every vector has length 1 (you normalizeDivide a vector by its own length so it has length exactly 1. It keeps the direction and throws away the size.d them). Then |a|² = |b|² = 1, the dot product is the cosine, and

|a − b|²  =  2 − 2 cos θ

The squared distance is a fixed, decreasing function of the cosine. So sorting by smallest L2, by biggest dot product, and by biggest cosine gives the exact same order. That’s why a lot of systems normalize everything and then use whichever operation their hardware or library does fastest, which is usually a plain dot product.

When the vectors aren’t normalized, they disagree. Dot product rewards length: a long vector that points roughly the right way can beat a short one that points exactly the right way. Some retrieval models are trained to use that (length can encode “this passage is generally useful”), and for those you must search by dot product, not cosine. The rule: use the similarity the model was trained with. The model card tells you. The small sentence-transformer you’ll use in Build It was trained with cosine and outputs normalized vectors, so all three agree.

Predict firstwrite it down

Two unit-length vectors have cosine similarity 0.8. What’s their Euclidean distance? If a third vector has cosine 0.6 with the first, is it closer or farther in L2?

Reveal

|a − b|² = 2 − 2(0.8) = 0.4, so the distance is √0.4 ≈ 0.63. For cosine 0.6: 2 − 1.2 = 0.8, distance ≈ 0.89. Farther. Lower cosine always means larger L2 on unit vectors, so the rankings match.

Brute force, and what it costs

The honest way to find the nearest neighbours: compute the similarity between the query and every stored vector, keep the top k. This is brute-force kNNExact nearest neighbour search by comparing the query to every stored vector. Always correct. Cost grows linearly with the number of vectors. Also called flat or exhaustive search. (FAISS calls it a “flat” index). It’s exact. Let’s price it, back-of-envelope style.

For N vectors of d dimensions, one query costs N × d multiply-adds. And to do them, the machine has to read every stored number once.

For 100 million passages at 768 dimensions in 32-bit floats:

Predict firstwrite it down

Is that 1.1 seconds limited by compute or by memory? The Spark’s GPU can do on the order of tens of trillions of 32-bit multiply-adds per second (check the spec sheet). Work out how long the 77 billion multiply-adds would take if memory were free.

Reveal

At even 10 trillion multiply-adds per second, 77 billion take about 8 ms. Reading the bytes takes about 1,100 ms. Memory is the bottleneck by two orders of magnitude.

Here’s the reason, in the language of Why ~17 tokens per second?: each 4-byte number is read once and used in exactly one multiply-add for this query. That’s a quarter of a multiply-add per byte. The chip can do dozens of operations in the time it takes to fetch one byte, so it sits waiting. Single-query brute force is bandwidth-bound, exactly like decoding one token: read everything, do almost nothing with each piece.

The same fix applies, too. Batch 1,000 queries together and each number you read gets used 1,000 times. Now it’s a matrix multiply, compute-bound, and throughput jumps. You’ll measure this. It’s why offline jobs (like computing ground truth) brute force happily on a GPU, while a live service answering one query at a time can’t.

Play with the knobs. Note how the read time scales with every factor, and which ones you can actually change.

FIG_002[ WHAT BRUTE FORCE COSTS ]
dims
stored as
machine
INDEX SIZE VS MEMORY
index multiply-adds / query read time / query max queries / s, one at a time
One query, checked against every stored vector. Index size is vectors × bytes per vector, and the time to read it once at the machine's spec-sheet memory bandwidth is a floor on single-query latency. Fewer bytes per vector (fp16, int8, product quantization) helps linearly. Only not reading most of the vectors helps by orders of magnitude.

Two ways out appear.

  1. Read fewer bytes per vector. Store them smaller. fp16 halves it, int8 quarters it, and product quantization (below) shrinks each vector to tens of bytes.
  2. Read fewer vectors. Don’t look at most of the index. Somehow jump to the right neighbourhood and only check points there.

Every serious vector index does one or both. And every one of them gives up the guarantee of the exact answer to do it.

Approximate, and the triangle

ANNApproximate nearest neighbour search: return points that are probably the nearest, or very close to the nearest, in exchange for being much faster than brute force. search means: return the true nearest neighbours most of the time, much faster. The way you score “most of the time” is recall@kFor ANN: of the true k nearest neighbours (found by brute force), what fraction did the index return in its top k? 0.95 means it found 9.5 of the true 10, on average over queries..

Be careful, because you already know a different recall from ranking. In ranking metrics, recall asks “did we find the relevant documents?”, judged by human labels. ANN recall asks “did we find the same top 10 that brute force would?”, judged by the exact search over the same embeddings. The first measures the embedding model. The second measures only the index. If the embedding model ranks the right passage 400th, a perfect index still returns it 400th. Keep the two apart: ANN recall@10 = 0.98 can sit next to a retrieval recall@10 of 0.6.

Every index lives somewhere in a triangle with three corners:

You can buy any one with the other two. Look at more of the index: recall up, speed down. Compress the vectors: memory down, recall down. Add a graph of links between vectors: speed and recall up, memory up. Index build time is a fourth cost that hides off to the side: some indexes take hours to build and can’t easily absorb new vectors.

The rest of the Idea is three tricks. Each one is a different bet about which corner to give up.

IVF: sort the library into rooms

Picture a library with 100 million books and no catalogue. Brute force reads every spine. A smarter librarian first sorts the books into a thousand rooms by topic. When you ask a question, she figures out which few rooms your question belongs to, and only searches those.

That’s an IVFInverted file index. Split the vectors into clusters ahead of time; store, for each cluster, the list of vectors in it. At query time, find the few clusters nearest the query and only search their lists.. Mechanically:

  1. Train. Run k-meansA clustering algorithm: pick k centre points, assign every vector to its nearest centre, move each centre to the average of its vectors, repeat. The result splits the space into k regions. on (a sample of) your vectors to get nlist cluster centres, called centroidThe centre point of a cluster: the average of the vectors assigned to it.s. Each centroid owns the region of space closer to it than to any other centroid. Those regions are called Voronoi cellsThe regions you get by giving every point in space to its nearest centre. Draw the borders and the space looks like a cracked tile floor..
  2. Add. Put every vector in the list of its nearest centroid.
  3. Search. Compare the query to all nlist centroids (cheap: a thousand, not a hundred million). Pick the nprobe nearest. Brute-force only the vectors in those cells.

The cost per query is roughly nlist + nprobe × (N / nlist) distance computations. For 100 million vectors, nlist = 65,536 (2¹⁶) and nprobe = 64: that’s 65,536 + 64 × 1,526 ≈ 160,000 distances instead of 100 million. About 600× less work.

Where’s the catch? The border. The query’s true nearest neighbour can sit just across the line in a cell you didn’t open. In 768 dimensions, a cell has a huge number of neighbouring cells, and the query often lands near several borders at once. nprobe is the dial: probe 1 cell and you miss a lot; probe more and recall climbs toward 1 while speed falls linearly. The FAISS docs suggest nlist somewhere between about 4√N and 16√N; then you sweep nprobe.

Product quantization: describe each vector in a few bytes

IVF reads fewer vectors. It doesn’t make them smaller, so 100 million × 768 floats still needs 307 GB. PQCompress a vector by cutting it into m short pieces and replacing each piece with the ID (one byte) of the closest entry in a small learned codebook for that piece. A 768-float vector becomes m bytes. fixes the memory corner.

The analogy: describing a face to a sketch artist. You don’t send a photo. You say “eyes: type 17, nose: type 203, mouth: type 4”, picking each from a book of 256 standard shapes. The artist rebuilds an approximate face from a handful of numbers.

PQ does exactly that to a vector:

  1. Cut the 768 numbers into m = 96 consecutive pieces of 8 numbers each.
  2. For piece 1, run k-means with 256 centres on piece 1 of all your vectors. That’s piece 1’s codebookFor one piece of the vector, a list of 256 representative short vectors learned by k-means. A piece is stored as the index (0-255) of the closest one, which fits in one byte.. Same for each of the 96 pieces.
  3. Store each vector as 96 bytes: for each piece, the ID of its nearest codebook entry.

The memory math: 768 × 4 = 3,072 bytes becomes 96 bytes. 32× smaller. 100 million vectors go from 307 GB to 9.6 GB, plus the IDs that IVF stores alongside (8 bytes each in FAISS, another 0.8 GB). That fits on the Spark with room to spare. Use m = 48 and it’s 64× smaller, at a further cost in accuracy.

FIG_003[ PRODUCT QUANTIZATION ]
ONE VECTOR · 768 FLOATS · 3,072 BYTES8 dims8 dims8 dims8 dims… 91 more pieces …8 dimsNEAREST OF 256 CODEBOOK ENTRIES, PER PIECE17203488131…STORED: 96 ONE-BYTE CODES · 96 BYTES · 32× SMALLERAT QUERY TIMETABLE, ONCE PER QUERY96 pieces × 256 entries= similarity of each querypiece to each codebook entrySCORE ONE STORED VECTORT[0][17] + T[1][203] + T[2][4] + … + T[95][131]96 lookups, no decompressionBYTES READ PER VECTOR: 96, NOT 3,072
Top: a 768-float vector is cut into 96 pieces of 8; each piece is replaced by the ID of its nearest entry in that piece's 256-entry codebook. 3,072 bytes become 96. Bottom: at query time you never decompress. Precompute the query's similarity to every codebook entry once (a 96 × 256 table), then each stored vector's score is 96 table lookups added up.

The clever part is the bottom half of the figure. To score a compressed vector you don’t rebuild it. You split the query into the same 96 pieces and compute, once, the similarity of each query piece to each of the 256 codebook entries for that piece. That’s a 96 × 256 table, about 25,000 numbers, small enough to sit in cache. Now any stored vector’s (approximate) dot product with the query is the sum of 96 table lookups, one per code. This is called ADCScoring compressed database vectors against an uncompressed query, using a precomputed table. “Asymmetric” because only one side is compressed, which keeps more accuracy than compressing both.. Fewer bytes read, cheap arithmetic, and in a bandwidth-bound problem fewer bytes is exactly what you need.

The price is accuracy. Each piece is snapped to the nearest of 256 shapes, so every score is a little wrong, and the ranking near the top gets shuffled. The standard fix is cheap: ask the PQ index for the top 100, then re-score those 100 with the full-precision vectors (kept on disk or in slower memory) and keep the best 10. This “compressed search, exact re-rankTake a short list of candidates from a cheap scorer and re-order it with a more accurate (and more expensive) scorer.” pattern is the whole lesson in miniature, and you’ll see it again at the end.

In FAISS the combination of the two ideas is IVF65536,PQ96: room-sorting to read fewer vectors, PQ to make each one small.

HNSW: highways, then local roads

The third trick throws out the rooms entirely. Instead, build a graph: connect each vector to a handful of its near neighbours. To search, start somewhere, look at your neighbours, step to whichever is closest to the query, and repeat until no neighbour is closer. That’s greedy searchAt each step, move to whichever neighbour is closest to the target, and stop when none is closer. Fast, but can get stuck in a spot that is locally best and not globally best. on a graph, and it’s how you’d find a friend’s house in a town with no map: keep taking whichever street heads most directly toward it.

On its own this is slow for a big graph. If every link is short, crossing the whole space takes thousands of tiny steps. The fix is the one road networks use. You don’t drive across the country on residential streets. You take local roads to a highway, the highway across most of the distance, and local roads again at the end.

A HNSWHierarchical Navigable Small World graph. Vectors are linked into several layers of graphs: the top layers hold few vectors with long links, the bottom layer holds every vector with short links. Search walks down the layers. index builds exactly that, in layers:

Each vector’s top layer is chosen at random when it’s inserted, with exponentially falling odds, like flipping coins. If that sounds like a skip listA sorted linked list with extra “express” lists on top that skip over many elements, built by coin flips. Search runs along the express lane, then drops down. Lookups take about log N steps., it is: HNSW is the skip list idea with graphs instead of sorted lists.

FIG_004[ HNSW: THE LAYERS ]
LAYER 2HIGHWAYLAYER 1REGIONALLAYER 0LOCAL ROADSEVERY POINTENTRYQUERY2 LONG HOPS · 1 MEDIUM · 2 SHORT
The same points on three layers, drawn on one line for clarity. The search enters at the top, takes a few long hops toward the query, drops down a layer from wherever it stopped, and finishes with short hops on the bottom layer, which holds every point. Each layer down has more nodes and shorter links.

Three parameters control it, and they are the ones you’ll tune:

Two more details from the paper that matter. The search on the upper layers is plain greedy (ef = 1): those layers only need to get you to the right neighbourhood. And when choosing a node’s links, HNSW doesn’t just take the M nearest. It skips a candidate if it’s closer to an already-chosen neighbour than to the node itself. That keeps a few links pointing away, toward other clusters, instead of M links all pointing into the same tight huddle. Without it, clustered data breaks the graph into islands that greedy search can’t leave.

Now try both indexes on a small map. Click to place a query anywhere. In HNSW mode, step through and watch the big dark dots (the upper-layer nodes) carry the search across the map in a few hops, then the bottom layer fill in locally. In IVF mode, watch it measure the cell centres first and then scan whole cells.

FIG_005[ WALK THE INDEX ]
index

distances computed 0brute force 600found – of the true top 5
600 points in 2D. HNSW: greedy hops on the sparse upper layers, then a beam search of width ef on the bottom layer. IVF: rank all 12 cell centres, then scan the nprobe nearest cells exactly. The counter compares distance computations with brute force's 600. In 2D both indexes rarely miss; in 768 dimensions the misses are real, and ef and nprobe are how you buy them back. You'll measure that in Build It.

Things to try:

  1. HNSW, query in a far corner. The first hops are huge. Count the hops per layer: roughly constant, a handful each, which is why the cost grows roughly with log N instead of N.
  2. HNSW, slide ef from 5 to 40. The distance count rises and the found set stays perfect. This 2D map is too easy to show misses. On real 768-dimension data, low ef misses a lot (you’ll see recall around 0.8-0.9 at small ef).
  3. IVF, nprobe = 1, put the query right on a dotted border. Some true neighbours sit in the cell next door, and IVF misses them. Bump nprobe to 2.
  4. Compare the counters. On this map HNSW checks about 40-70 points, IVF with nprobe = 2 about 120. Brute force always 600. At 100 million points the gap is enormous.

Why not always HNSW?

HNSW is the default in most vector databases because its recall-vs-speed curve is usually the best at a given memory budget, and it handles inserts without retraining. But look at the memory: it stores full vectors plus the links. 100 million × (3,072 + ~300) bytes is about 340 GB. It doesn’t compress, and its access pattern hops randomly through memory, so it really wants the whole thing in RAM.

A rough guide:

IndexMemory per 768-d vectorSpeed at high recallNotes
Flat (brute force)3,072 Bslowestexact; the ground truth
IVF-Flat3,072 B + 8 B IDfastneeds training; recall falls off near cell borders
IVF-PQ (m = 96)~104 Bfast30× less memory; re-rank with full vectors for accuracy
HNSW (M = 32)~3,300 Bfastestbig; no training; easy inserts; slow builds; deletes are awkward

At the 100 million scale on one box, IVF-PQ is often the only thing that fits. At a few million, HNSW in RAM is hard to beat. At billions, people combine the ideas (graph indexes over compressed vectors, with full vectors on SSD, as in Microsoft’s DiskANN).

Filters make everything harder

Real queries come with conditions: “nearest passages, but only English, only from the last month, only this customer’s documents”. This sounds trivial and is one of the hardest problems in the field.

Production systems usually decide per query, using an estimate of how selective the filter is: pre-filter plus brute force when few vectors pass, filtered graph search otherwise. Keep this in mind when someone says “just add a WHERE clause to the vector search”.

Hybrid search: words and vectors

Embeddings are good at meaning (“cheap flights” finds “low-cost airfare”) and bad at exact strings: product codes, error messages, rare names, a specific version number. The decades-old keyword method is the opposite. BM25A classic keyword ranking formula (Robertson and others, 1990s). Scores a document by how often it contains the query’s words, weighting rare words more and dampening repeated ones and long documents. Runs on an inverted index of words. scores documents by the query words they contain, with rare words counting more. It finds CUDA error 700 instantly, and has no idea that “car” and “automobile” mean the same thing.

So many systems run both and merge the lists: hybrid searchRunning a keyword search (like BM25) and a vector search for the same query and merging their result lists, to get the strengths of both.. The scores aren’t comparable (BM25 is unbounded, cosine sits between −1 and 1), so the usual merge ignores scores and uses ranks: RRFMerge several ranked lists by giving each document the sum over lists of 1 / (k + its rank in that list), with k usually 60. Documents near the top of several lists win.. Each document gets 1/(60 + rank) from each list it appears in, summed. Simple, robust, and hard to beat without training a model to do the merge.

The two-stage pipeline you work on

Put it all together and you get the shape of most modern search stacks:

FIG_006[ RETRIEVE CHEAP, RERANK EXPENSIVE ]
CORPUS100MpassagesANN INDEXHNSW / IVF-PQ~1-10 msBM25keyword index~1-10 ms10001000FUSERRFtop 100CROSS-ENCODER100 forward passes~10-100 ms GPUTOP10CHEAP PER ITEM, MANY ITEMSEXPENSIVE PER ITEM, FEW ITEMSRECALL IS SET HEREPRECISION IS SET HERE
Each stage sees fewer items and spends more per item. The ANN index and BM25 each pull about a thousand candidates out of 100 million in milliseconds; fusion merges them; the cross-encoder spends real compute on just the top 100. Latencies are rough orders of magnitude; you'll measure your own.

The division of labour is the important idea. The first stage’s job is recall: get the right passage somewhere into the candidate list. It can be sloppy about order. The second stage’s job is precision: put the right passage at the top. It can be slow per item, because there are only a hundred items.

That gives you a budget to reason about. Say the whole request must finish in 100 ms. If the reranker scores 100 pairs in 40 ms, the retrieval stage gets what’s left. If you rerank 300 pairs instead of 100, you might catch a few more good passages that retrieval ranked low, but you pay 3× on the reranker. And an ANN index at recall@10 = 0.95 instead of 0.99 might cost you nothing at all end to end, because the passages it misses are the near-duplicates at ranks 8-10 that the reranker would have reordered anyway. Or it might cost you a lot. You can’t reason your way to the answer. You measure it, end to end, in NDCGNormalized discounted cumulative gain. Each relevant result earns credit that shrinks with its position (1/log₂(rank + 1)), summed and divided by the best possible score, so 1.0 means a perfect ordering. NDCG@10 looks only at the top 10., which is what the Build is for.

Build It

Everything runs on the Spark. Make a folder, say ~/workbench/vecsearch/, and a virtual environment. The libraries: faiss-cpu (Meta’s vector search library; it has ARM64 Linux wheels on PyPI, but check, and the GPU build is usually installed through conda, so check its install docs for ARM64), hnswlib (the reference HNSW implementation from the paper’s authors), sentence-transformers for the models, and datasets for MS MARCO.

Get a slice of MS MARCOAgent: fine

MS MARCO passage ranking has about 8.8 million passages and a “dev small” set of 6,980 queries with human labels (usually one relevant passage per query). Embedding all 8.8M is a good overnight job later. Tonight, build a 500,000-passage slice that is guaranteed to contain the relevant passages for 1,000 dev queries, padded with random others as distractors.

The BEIR copy on Hugging Face is the easiest to load (the corpus parquet is about 1.6 GB):

# make_slice.py
import random, json, pyarrow.parquet as pq
from huggingface_hub import hf_hub_download
from datasets import load_dataset

corpus_path = hf_hub_download("BeIR/msmarco", "corpus/corpus-00000-of-00001.parquet", repo_type="dataset")
query_path = hf_hub_download("BeIR/msmarco", "queries/queries-00000-of-00001.parquet", repo_type="dataset")
qrels = load_dataset("BeIR/msmarco-qrels", split="validation")  # the dev small labels

random.seed(0)
rel: dict[str, set[str]] = {}
for r in qrels:
    rel.setdefault(str(r["query-id"]), set()).add(str(r["corpus-id"]))
qids = random.sample(sorted(rel), 1000)
need = set().union(*(rel[q] for q in qids))

corpus = pq.read_table(corpus_path, columns=["_id", "text"]).to_pandas()
is_rel = corpus["_id"].isin(need)
others = corpus[~is_rel].sample(500_000 - int(is_rel.sum()), random_state=0)
sl = corpus[is_rel]._append(others).sample(frac=1, random_state=0).reset_index(drop=True)
sl.to_parquet("passages.parquet")

queries = pq.read_table(query_path).to_pandas().set_index("_id")["text"]
json.dump({q: {"text": queries[q], "rel": sorted(rel[q])} for q in qids}, open("queries.json", "w"))
print(len(sl), "passages,", len(qids), "queries")

(Column names and file paths are from the BEIR dataset card as of late 2026; if they’ve moved, look at the card. _append is pandas’ internal name for concat on a frame; swap in pd.concat if your pandas complains.)

Embed it on the GPUAgent: fine

Use a small bi-encoder trained on MS MARCO with cosine similarity: sentence-transformers/msmarco-MiniLM-L6-cos-v5 (384 dimensions, 6 layers). Ask for normalized output so dot product equals cosine.

# embed.py
import json, time, numpy as np, pandas as pd
from sentence_transformers import SentenceTransformer

m = SentenceTransformer("sentence-transformers/msmarco-MiniLM-L6-cos-v5", device="cuda")
p = pd.read_parquet("passages.parquet")
qs = json.load(open("queries.json"))

t = time.perf_counter()
xb = m.encode(p["text"].tolist(), batch_size=512, normalize_embeddings=True,
              convert_to_numpy=True, show_progress_bar=True).astype("float32")
print(f"{len(xb)} passages in {time.perf_counter() - t:.0f}s")
xq = m.encode([v["text"] for v in qs.values()], normalize_embeddings=True).astype("float32")
np.save("xb.npy", xb); np.save("xq.npy", xq)
print(xb.shape, xq.shape, "norms:", np.linalg.norm(xb[:3], axis=1))

Write down the passages-per-second number. It’s the cost of re-indexing when you change the embedding model, which is a real operational cost: 8.8M passages at your rate is how long?

Brute force: the ground truth and the stopwatchHands only

Brute force does two jobs: it’s the exact answer every index is graded against, and it’s the baseline latency. Write it three ways and time each.

# brute.py
import time, numpy as np, torch

xb = np.load("xb.npy"); xq = np.load("xq.npy"); k = 10

def topk_numpy_one(q: np.ndarray, k: int) -> np.ndarray:
    """One query (shape [d]) against all of xb. Return indices of the k highest dot products, best first.
    Hint: xb @ q, then np.argpartition for the top k, then sort just those k."""
    ...

def topk_numpy_batch(Q: np.ndarray, k: int) -> np.ndarray:
    """All queries at once (shape [nq, d]) -> [nq, k]. One matrix multiply."""
    ...

def topk_torch_gpu(Q: torch.Tensor, B: torch.Tensor, k: int) -> torch.Tensor:
    """Same, on the GPU with torch.topk. Time it with torch.cuda.synchronize() before stopping the clock."""
    ...

if __name__ == "__main__":
    # 1. time topk_numpy_one over 100 queries -> ms per query
    # 2. time topk_numpy_batch over all 1000 -> ms per query
    # 3. time topk_torch_gpu, batch of 1 and batch of 1000 (warm up once first)
    # 4. save the exact top-100 for every query as gt.npy (you'll need 100, not just 10, later)
    ...

Before you run it, predict: the slice is 500,000 × 384 × 4 bytes. How many MB is that, and at the Spark’s spec-sheet bandwidth what is the fastest one query could possibly be?

Three indexes, three dialsHands only

Build each index once, then sweep its query-time dial. For every setting record recall@10 (against gt.npy), queries per second, and memory. The FAISS calls have this shape (check the FAISS wiki if signatures have moved):

import faiss, hnswlib

d = xb.shape[1]
flat = faiss.IndexFlatIP(d); flat.add(xb)                               # brute force, for sanity

quant = faiss.IndexFlatIP(d)                                            # IVF: k-means cells
ivf = faiss.IndexIVFFlat(quant, d, nlist, faiss.METRIC_INNER_PRODUCT)
ivf.train(xb); ivf.add(xb); ivf.nprobe = 16
D, I = ivf.search(xq, 10)

quant2 = faiss.IndexFlatIP(d)                                           # IVF + PQ: m bytes per vector
ivfpq = faiss.IndexIVFPQ(quant2, d, nlist, m, 8, faiss.METRIC_INNER_PRODUCT)

hnsw = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT)          # HNSW, M = 32
hnsw.hnsw.efConstruction = 200; hnsw.add(xb); hnsw.hnsw.efSearch = 64

h = hnswlib.Index(space="ip", dim=d)                                    # or hnswlib directly
h.init_index(max_elements=len(xb), M=32, ef_construction=200)
h.add_items(xb); h.set_ef(64); labels, dists = h.knn_query(xq, k=10)

What to write yourself:

  • recall_at_k(I, gt, k): mean over queries of |set(I[i][:k]) & set(gt[i][:k])| / k.
  • A sweep loop. IVF: nlist = 4096 (about 6√N for N = 500k), nprobe in 1, 2, 4, 8, 16, 32, 64, 128. IVF-PQ: m = 48 (384 / 8 dims per piece), same nprobe values. HNSW: M 16 and 32, efSearch in 10, 16, 32, 64, 128, 256.
  • Timing done two ways: the whole batch of 1,000 queries at once (throughput), and one query at a time in a loop (latency, what a live service sees). Pin threads with faiss.omp_set_num_threads(1) for the per-query numbers so you compare algorithms, not thread pools.
  • Memory: compute it from the formulas in the Idea section (vectors × bytes + IDs + links) and check against the size of faiss.serialize_index(index) (a byte array) or the process’s RSS.
  • Build time for each index. HNSW’s will surprise you.
  • One more variant: IVF-PQ asked for the top 100, then re-scored with the full vectors from xb, keeping the best 10. Recall@10 of that.
Plot the trade-offAgent: review after

Make two plots, with matplotlib:

  1. Recall@10 (x) vs queries per second (y, log scale), one line per index type, one point per dial setting. This is the standard ANN-benchmarks plot. Up and to the right is better. Mark the brute-force point.
  2. Recall@10 vs memory (MB), same points.

Then have an agent review the measurement code (not the plotting) for unfair comparisons: warm-up runs missing, thread counts differing between indexes, including build time in query time, gt computed with a different metric than the index.

Two stages: ANN, then your cross-encoderHands only

Now the part that matters for your job. Wire retrieval into a reranker and measure what reaches the user. Use your own cross-encoder if you can load it here; otherwise the public cross-encoder/ms-marco-MiniLM-L-6-v2, which takes (query, passage) pairs and returns one score per pair.

# two_stage.py
from sentence_transformers import CrossEncoder
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device="cuda")  # or your model

def retrieve(qvec, index, depth: int) -> list[int]:
    """Top `depth` passage row ids from the ANN index."""
    ...

def rerank(qtext: str, ids: list[int], top: int) -> list[int]:
    """Score (qtext, passage) for every id with ce.predict(pairs, batch_size=...), return the best `top` ids."""
    ...

def ndcg_at_10(ranked_ids: list[int], relevant: set[int]) -> float:
    """Binary relevance: DCG = sum over hits of 1/log2(rank + 1), divided by the best possible DCG."""
    ...

Run this grid over the 1,000 queries and record mean NDCG@10, MRR@10 (one over the rank of the first relevant passage, 0 if it’s not in the top 10; MS MARCO’s official metric), recall@100 (of labelled-relevant passages, not ANN recall) and per-query latency of each stage:

  • Retrieval alone (no rerank, depth 100 so recall@100 means something), with brute force and with HNSW at efSearch 100 and 256. FAISS quietly uses max(efSearch, k), so an efSearch below the depth you ask for does nothing.
  • Retrieve depth 1000 with HNSW (ef at least 1000, since ef must be at least k), rerank the top 10, 50, 100, 200 with the cross-encoder.
  • Same, but with brute-force retrieval, to isolate what the approximate index costs end to end.

Hints: map passage row ids to their BEIR _id to compare against labels. Time the reranker with the GPU synchronized. Use batch_size around 64-128 for the cross-encoder and note how latency grows with rerank depth.

Done when
  • gt.npy exists and every index’s recall@10 is measured against it.
  • You have the recall-vs-QPS and recall-vs-memory plots with three index families on them.
  • You’ve filled in the two-stage table and can say, with numbers, how much NDCG the approximate index costs after reranking, and what reranking 200 instead of 100 buys.
  • You can pick an index and its settings for “100 ms budget, 128 GB box, 100M passages” and defend it with arithmetic.

Measure

Fill these in on the Spark. Predict each blank cell before you run it.

Brute force (500k × 384, fp32)

Methodms per queryEffective GB/sYour prediction
numpy, one query at a time
numpy, batch of 1,000n/a
torch GPU, batch of 1
torch GPU, batch of 1,000n/a

Effective GB/s for the one-at-a-time rows is index bytes / time per query. Compare it to the 273 GB/s spec. You won’t reach it from one CPU thread; the GPU path should get much closer.

Indexes (1 thread, one query at a time)

IndexSettingRecall@10QPSMemoryBuild time
Flat—1.0000
IVF4096,Flatnprobe 8
IVF4096,Flatnprobe 32
IVF4096,PQ48nprobe 32
IVF4096,PQ48 + exact re-rank of 100nprobe 32
HNSW32ef 16
HNSW32ef 64
HNSW32ef 256

Two stages (1,000 dev queries)

RetrievalRerank depthNDCG@10MRR@10Retrieval msRerank ms
Brute forcenone0
HNSW ef 100none0
Brute force100
HNSW ef 100010
HNSW ef 1000100
HNSW ef 1000200

For reference, I ran the same pipeline on an M4 Pro MacBook (spec-sheet bandwidth 273 GB/s, same as the Spark), embedding on the Mac’s GPU, FAISS 1.15 on the CPU, one thread for the index rows. The embedding took 699 s for 500,000 passages, about 715 per second. Yours will differ; the shapes should not.

Brute force (768 MB)ms per queryEffective GB/s
numpy, one query at a time (multithreaded BLAS)7.0109
FAISS flat, one query at a time, 1 thread10.970
FAISS flat, batch of 1,000, 1 thread0.28n/a
torch on the Mac GPU, batch of 13.8204
torch on the Mac GPU, batch of 1,0000.58n/a
Index (1 thread)Recall@10QPS, batchedms per query, one at a timeMemoryBuild
Flat1.0003,60510.9768 MB0
IVF4096,Flat nprobe 80.68130,5060.13772 MB16 s
IVF4096,Flat nprobe 320.8229,1060.19772 MB16 s
IVF4096,Flat nprobe 1280.9132,4330.50772 MB16 s
IVF4096,PQ48 nprobe 320.62411,3630.1828 MB20 s
IVF4096,PQ48 nprobe 32 + exact re-rank of 1000.81928 MB + full vectors20 s
HNSW32 ef 160.8978,0010.13904 MB71 s
HNSW32 ef 640.9762,7550.38904 MB71 s
HNSW32 ef 2560.9968281.37904 MB71 s

Two-stage, first 300 of the 1,000 queries, public MiniLM cross-encoder on the Mac GPU, HNSW32:

RetrievalRerank depthNDCG@10MRR@10Retrieval msRerank ms
Brute forcenone0.7740.7396.80
HNSW ef 100none0.7630.7290.60
HNSW ef 256none0.7750.7401.30
Brute force1000.8410.8097.2125
HNSW ef 1000100.8080.7844.716
HNSW ef 1000500.8410.8104.661
HNSW ef 10001000.8410.8094.7125
HNSW ef 10002000.8410.8094.7233

What the numbers should tell you:

Brute force is bandwidth-bound one query at a time and compute-bound in a batch. One query at a time, the best path read 768 MB in 3.8 ms, about 200 GB/s, close to the spec. A batch of 1,000 on one CPU thread was 39× faster per query than one at a time on that same thread, because each byte read got reused 1,000 times. That’s the Predict answer, measured. (If your numpy batch row looks slow, check you used argpartition, not a full sort of 500,000 scores per query: my first try did, and the sort cost more than the matrix multiply.)

Real embeddings are hard for IVF. With 4,096 cells of about 120 vectors each, nprobe = 8 found only 68% of the true top 10. The neighbours of a 384-dimension query are scattered across many cells. HNSW at ef = 64 got 0.976 at 0.38 ms, about 28× faster than single-thread brute force. That’s why HNSW is the default when it fits in RAM.

PQ trades recall for 27× less memory, and exact re-ranking buys most of it back. 48 bytes per vector (plus the 8-byte ID) dropped recall from 0.82 to 0.62 at nprobe = 32. Re-scoring the top 100 with the full vectors brought it to 0.82: exactly IVF-Flat’s recall at the same nprobe. The re-rank fixes PQ’s shuffling of the order; it can’t fix what IVF never looked at.

HNSW builds slowly. 71 s for half a million vectors on 10 threads, against 16 s for IVF. At 100 million, multiply by more than 200 (insertion cost grows faster than linearly). Plan for hours.

End to end, the approximate index cost nothing. After reranking the top 100, HNSW and brute force both scored NDCG@10 0.841. The few true neighbours HNSW misses rarely matter once a cross-encoder reorders the list. Reranking added 0.07 NDCG over retrieval alone, which is the reason your job exists.

Rerank depth past 50 bought nothing here and cost linearly. 50, 100 and 200 all scored 0.841, while rerank time went 61 → 125 → 233 ms on the Mac’s GPU. On the Spark your cross-encoder will be faster, but the shape holds: find the knee of NDCG against depth and stop there. One caveat: these scores are high (full MS MARCO MRR@10 for small bi-encoders is closer to 0.3) because a 500k slice has 17× fewer distractors than the real 8.8M corpus. Compare configurations against each other, not against leaderboards.

Break It

1. Forget to normalize. Re-embed the slice without normalize_embeddings=True (or multiply a random 10% of xb rows by 3) and search with IndexFlatIP. Recall against the cosine ground truth drops, and the top results fill up with long vectors. Then normalize and search with IndexFlatL2: rankings match IndexFlatIP exactly, which is the identity from the Idea, checked. Lesson: the metric is part of the model’s contract.

2. Make IVF’s training sample lie. Train the IVF quantizer on only the first 10,000 vectors after sorting the passages by length (or by any field correlated with topic), then add all 500k. Many cells end up huge and many nearly empty, and at the same nprobe your recall and speed both get worse, because a few giant cells are scanned every time. Print the sizes of the 20 biggest inverted lists (ivf.invlists.list_size(i)). This is what happens in production when the corpus drifts after the index was trained, and why IVF indexes get periodically retrained.

3. A selective filter. Tag each passage with a random “language” so that 1% are “de”. Ask for the top 10 German passages three ways: post-filter HNSW top 100 (count how often you get fewer than 10), post-filter top 5,000, and pre-filter then brute force over the ~5,000 matching vectors. The pre-filtered brute force is both exact and fast here. Now make the filter keep 50% and redo it. The winner flips.

4. Starve the reranker. Set HNSW efSearch = 10 and retrieve only 10 candidates, then rerank them. The cross-encoder can’t find a passage it was never shown: NDCG@10 is capped by retrieval’s recall@10. Push the depth to 100 and watch NDCG climb even though the ANN index itself didn’t change. Your ranker’s ceiling is set one stage earlier.

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. When do cosine similarity, dot product and Euclidean distance give the same ranking, and when don’t they?

When every vector has length 1. Then the dot product equals the cosine, and squared Euclidean distance is 2 − 2 × cosine, a decreasing function of it, so all three sort neighbours in the same order. Without normalization, dot product also rewards vector length, so a long vector pointing roughly the right way can outrank a short one pointing exactly right. Some models are trained to use that, so you use whatever similarity the model was trained with, and normalize only if it was trained with cosine.

2. How long does brute-force search over 100 million 768-dimension vectors take on one machine, and what’s the bottleneck?

100M × 768 × 4 bytes is 307 GB, which doesn’t fit in the Spark’s 128 GB at all, and every query has to read all of it. If it did fit, at about 273 GB/s that’s about 1.1 seconds per query. The 77 billion multiply-adds would take only milliseconds on the GPU, so it’s bandwidth-bound: each byte is used in a quarter of a multiply-add. Batching many queries turns it into a matrix multiply and makes it compute-bound, which is fine offline but not for live single queries. To be fast you need to read fewer bytes per vector (fp16, int8, product quantization) and, much more importantly, read fewer vectors (IVF, HNSW).

3. Explain IVF, PQ and HNSW to someone who has never heard of them, and name the dial on each.

IVF sorts vectors into clusters with k-means ahead of time; a query compares itself to the cluster centres and only searches the nprobe nearest clusters. Misses happen when a true neighbour sits just across a cluster border; raising nprobe fixes that at linear cost. PQ shrinks each vector by cutting it into pieces and storing, for each piece, the one-byte ID of the nearest of 256 learned shapes; a 768-float vector becomes 96 bytes, scored by adding up lookups in a small per-query table. Its dial is m, the number of pieces (more bytes, more accurate), and it’s usually followed by exact re-scoring of the top candidates. HNSW links every vector to a few near neighbours, and builds sparser layers on top with longer links, like highways over local roads; a search takes long greedy hops on top and refines on the bottom. Its query dial is efSearch, how many candidates the bottom-layer search keeps; build-time dials are M (links per node) and efConstruction.

4. How does each index scale to a billion vectors, and what would you build?

Brute force scales linearly in both memory and time: 3 TB of fp32 at 768 dims, seconds per query. HNSW’s query cost grows roughly with log N, but its memory grows linearly with full vectors plus links (over 3 TB), and it wants everything in RAM because it hops randomly; the build also takes a long time and is hard to parallelize across machines. IVF-PQ’s memory is around 100 bytes per vector (about 100 GB), and its query cost is roughly nlist + nprobe × N / nlist, so you grow nlist with N. For a billion I’d shard across several machines (each shard holding an IVF-PQ or a compressed-graph index), search all shards in parallel, merge their top-k, and keep full-precision vectors on SSD to re-score the top few hundred, the way DiskANN-style systems do. Sharding also brings tail latency into it: the query is as slow as the slowest shard.

5. In a retrieve-then-rerank pipeline, how do you decide the ANN settings and the rerank depth?

Start from the latency budget and measure end to end. The reranker’s cost grows linearly with depth (100 pairs vs 200 pairs), and the retrieval stage’s job is only to get the relevant passages into that depth, not to order them well. So ANN recall@10 isn’t the target; what matters is the final NDCG@10 after reranking, and whether the index’s misses fall inside or outside the reranked set. I’d sweep efSearch and rerank depth together, plot NDCG@10 against total p99 latency, and pick the cheapest point within a small margin of the brute-force-plus-rerank NDCG. Often a fairly sloppy index is fine because the reranker reorders the top anyway, and often hybrid retrieval (adding BM25 via RRF) buys more than a more exact index, because it catches passages the embedding model ranks badly.

Go Deeper

Connections

This lesson is How does it scale? and Why ~17 tokens per second? applied to search: brute force is one division (bytes over bandwidth), and the fix is the same as in decoding, read fewer bytes or reuse them across a batch. Product quantization is a cousin of Quantization: fewer bits per number, accuracy traded for bandwidth. IVF’s inverted lists and BM25’s word lists are both indexes in the sense of Indexes and B-trees: precomputed structure that lets a read skip most of the data, paid for at write time.

Next, Ranking metrics and eval sets goes deep on the NDCG and MRR you measured in the two-stage table, and on the harder question of whether 1,000 queries are enough to trust a difference of 0.01. And p99 and load testing explains why the retrieval stage’s per-query latency, not its batch throughput, is the number your search service lives or dies by.