Skip to content
ML to LLMs

Module 16: Retrieval-augmented generation 40 min9 min quick readLLMs

Vector search

Pick a similarity metric and an approximate nearest-neighbor index (IVF, HNSW, product quantization), trade recall against latency and memory, and combine BM25 with dense retrieval using reciprocal rank fusion.

Before this: The RAG pipeline, Word embeddings, K-nearest neighbors

Read on GitHub

Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.

Mental model. Vector search is a librarian who has shelved every document by meaning. Checking every book against your question is exact but slow. A good librarian walks to the two or three most promising shelves and checks only those books. That is fast, and it is usually right, but sometimes the best book was shelved one aisle over. Every vector index is a different way of deciding which shelves to walk to.

You will learn to

  • Choose between cosine similarity, dot product, and Euclidean (L2) distance, and explain what normalization does to each.
  • Compute the cost of exact k-nearest-neighbor search and of an IVF index.
  • Explain how IVF, HNSW, and product quantization work and which knobs trade recall for speed or memory.
  • Measure recall@k of an approximate index against brute force.
  • Combine BM25 and dense results with reciprocal rank fusion, and avoid metadata-filtering pitfalls.
  • Plan index updates, embedding-model upgrades, and the choice between a vector database, pgvector, and an in-memory index.

Why it matters. In the RAG pipeline, retrieval sets the ceiling on answer quality. Behind "retrieve top-kk" sits a search index with its own accuracy, latency, and memory budget. When a RAG system misses an obvious document, the cause is often an index setting (nprobe, efSearch), a filter applied in the wrong place, or a query embedded with a different model than the documents. These are the questions interviewers ask when a role includes search or RAG.

1. Intuition

Embeddings as points. An embedding model turns each chunk of text into a vector of a few hundred to a few thousand numbers. Texts with similar meaning land close together. A query is embedded with the same model, and search means "find the kk stored vectors closest to this one".

Exact search is simple and linear. Compare the query with every stored vector, keep the best kk. With a million vectors, that is a million distance computations per query. It is perfectly accurate and fine for tens of thousands of vectors. It gets expensive at hundreds of millions, or at thousands of queries per second.

Approximate nearest neighbor (ANN) search gives up a little accuracy for a lot of speed. Three families dominate:

  • IVF (inverted file). Cluster the vectors with k-means into nlist cells. At query time, find the nprobe cells whose centroids are closest to the query, and only scan vectors in those cells. A true neighbor that sits just across a cell boundary is missed unless you probe that cell too.
  • HNSW (hierarchical navigable small world). Build a graph where each vector links to a few dozen near neighbors, plus sparse upper layers with long-range links. Search starts at the top, greedily hops toward the query, then explores a candidate list of size efSearch at the bottom layer. Fast and accurate, but the links cost memory and the whole graph usually lives in RAM.
  • Product quantization (PQ). Compress each vector by splitting it into chunks and replacing each chunk with the ID of its nearest codebook entry. A 3 KB vector becomes about 100 bytes. Distances become approximate, so PQ is often combined with IVF (IVF-PQ) and followed by re-scoring the top candidates with full vectors.

Hybrid search. Dense vectors match meaning but can miss exact tokens: error codes, product SKUs, rare names. Keyword search (BM25) matches those exactly. Running both and merging the ranked lists with reciprocal rank fusion is a strong default.

2. Visualization

Approximate nearest neighbors (IVF)

500 synthetic 2D vectors. Click or drag to move the query. IVF clusters them into cells and scans only the nprobe cells nearest the query.

IVF search02468100246810dimension 1dimension 2
querytrue top-k, foundtrue top-k, missed× centroids (bold = probed)

IVF compared the query with 16 centroids, then scanned 82 of 500 vectors in 2 cells: 98 distance computations instead of 500. It found 9 of the true 10 nearest neighbors (recall@10 = 0.90).

The 1 missed neighbor sits in a cell that was not probed, even though it is close to the query: queries near cell boundaries are where IVF loses recall. Raise nprobe to recover them at extra cost.

Try it (predict first, then check):

  1. Set nprobe to 1 and drag the query onto a boundary between two cells. Predict recall@10. Then raise nprobe one step at a time until recall reaches 1.
  2. Set nprobe equal to the number of clusters. Predict recall and the number of distance computations compared with brute force. Why is it slightly more than brute force?
  3. Increase the number of clusters from 8 to 32 while keeping nprobe at 2. Predict what happens to cost and to average recall.
  4. Raise kk from 5 to 20 with everything else fixed. Does recall go up or down, and why?

3. The math

Symbols

SymbolMeaningShape
qqquery embedding[d][d]
xix_istored vector ii[d][d]
XXall stored vectors[N,d][N, d]
NNnumber of stored vectorsscalar
ddembedding dimensionscalar
kknumber of neighbors returnedscalar
nlistn_{\text{list}}IVF number of cells (k-means clusters)scalar
nproben_{\text{probe}}IVF cells scanned per queryscalar
MMHNSW links per node (upper layers; 2M2M at layer 0)scalar
mmPQ number of subvectorsscalar
ccRRF smoothing constant (commonly 60)scalar

Similarity metrics

dot(q,x)=q⋅x,cos⁡(q,x)=q⋅x∥q∥ ∥x∥,L2(q,x)=∥q−x∥\text{dot}(q, x) = q \cdot x, \qquad \cos(q, x) = \frac{q \cdot x}{\lVert q\rVert\,\lVert x\rVert}, \qquad \text{L2}(q, x) = \lVert q - x\rVert

If every vector has length 1, the three agree on the ranking: cos⁡=dot\cos = \text{dot}, and ∥q−x∥2=2−2cos⁡(q,x)\lVert q - x\rVert^2 = 2 - 2\cos(q, x). So normalize at write time and at query time, then use dot product (the cheapest).

Worked example. q=[1,0]q = [1, 0], a=[0.8,0.6]a = [0.8, 0.6] (length 1), b=[3,4]b = [3, 4] (length 5).

  • Dot: q⋅a=0.8q \cdot a = 0.8, q⋅b=3q \cdot b = 3. Dot ranks bb first because bb is long.
  • Cosine: cos⁡(q,a)=0.8/1=0.8\cos(q, a) = 0.8/1 = 0.8, cos⁡(q,b)=3/5=0.6\cos(q, b) = 3/5 = 0.6. Cosine ranks aa first.
  • L2: ∥q−a∥=0.22+0.62=0.4=0.632\lVert q - a\rVert = \sqrt{0.2^2 + 0.6^2} = \sqrt{0.4} = 0.632, ∥q−b∥=22+42=20=4.472\lVert q - b\rVert = \sqrt{2^2 + 4^2} = \sqrt{20} = 4.472. L2 ranks aa first.
  • After normalizing, b→[0.6,0.8]b \to [0.6, 0.8]. Now dot =0.6= 0.6, and ∥q−b∥2=0.42+0.82=0.8=2−2(0.6)\lVert q - b\rVert^2 = 0.4^2 + 0.8^2 = 0.8 = 2 - 2(0.6). All three metrics agree.

Use the metric the embedding model was trained with. Models trained with a dot-product objective may encode useful information (such as confidence or popularity) in the vector length, and normalizing them throws it away.

Exact search cost

Brute force computes NN distances of dd multiply-adds each: O(N⋅d)O(N \cdot d) per query, plus selecting the top kk. With N=1,000,000N = 1{,}000{,}000 and d=768d = 768: 768,000,000768{,}000{,}000 multiply-adds per query. The work is a single matrix-vector product, so it runs at full hardware speed, which is why brute force on a GPU is competitive up to surprisingly large NN.

IVF cost

A query computes nlistn_{\text{list}} centroid distances, then scans about nprobenlistN\frac{n_{\text{probe}}}{n_{\text{list}}} N vectors (if cells are balanced).

costIVF≈(nlist+nprobenlistN) d\text{cost}_{\text{IVF}} \approx \Big(n_{\text{list}} + \frac{n_{\text{probe}}}{n_{\text{list}}}N\Big)\, d

Worked example: N=1,000,000N = 1{,}000{,}000, d=768d = 768, nlist=1024n_{\text{list}} = 1024, nprobe=8n_{\text{probe}} = 8.

  • Scanned vectors: 81024×1,000,000=7,812.5\frac{8}{1024} \times 1{,}000{,}000 = 7{,}812.5.
  • Distances: 1024+7,812.5=8,836.51024 + 7{,}812.5 = 8{,}836.5.
  • Multiply-adds: 8,836.5×768=6,786,4328{,}836.5 \times 768 = 6{,}786{,}432.
  • Fraction of brute force: 6,786,432/768,000,000=0.88%6{,}786{,}432 / 768{,}000{,}000 = 0.88\%, about 113×113\times fewer.

A common starting point is nlist≈Nn_{\text{list}} \approx \sqrt{N} (here about 1,000) and then tuning nproben_{\text{probe}} on a recall target. Cells are rarely balanced in practice; a few large cells make some queries much slower than average.

Recall@k for an index

recall@k=∣ANN top-k  ∩  exact top-k∣k\text{recall@}k = \frac{|\text{ANN top-}k \;\cap\; \text{exact top-}k|}{k}

This is recall of the index against brute force, not retrieval recall against human relevance labels. Example: exact top-10 is items 0 to 9. The index returns 0 to 7, plus 42 and 99. Overlap is 8, so recall@10 =8/10=0.8= 8/10 = 0.8. Measure it on a few hundred real queries, not random vectors, because real queries cluster where your data is dense.

HNSW memory and knobs

Each node stores up to 2M2M neighbor IDs at the bottom layer (upper layers add a small fraction more). With M=32M = 32 and 4-byte IDs: 64×4=25664 \times 4 = 256 bytes per vector, so 256256 MB of links for a million vectors, on top of the vectors themselves. Knobs: MM (more links: higher recall, more memory, slower build), efConstruction (build-time search width: better graph, slower build), and efSearch (query-time candidate list, must be at least kk: higher recall, higher latency). efSearch is the knob you tune per query.

Product quantization memory

Split each dd-dimensional vector into mm subvectors of d/md/m dimensions. For each position, learn a codebook of 256 centroids with k-means. Store each subvector as one byte (its centroid ID).

Worked example: N=1,000,000N = 1{,}000{,}000, d=768d = 768.

  • Raw float32: 1,000,000×768×4=3,072,000,0001{,}000{,}000 \times 768 \times 4 = 3{,}072{,}000{,}000 bytes =3.072= 3.072 GB. In float16: 1.5361.536 GB.
  • PQ with m=96m = 96 (subvectors of 768/96=8768/96 = 8 dimensions): 9696 bytes per vector, so 9696 MB. Compression: 3072/96=32×3072 / 96 = 32\times.
  • Codebooks: 96×256×896 \times 256 \times 8 floats ×4\times 4 bytes =786,432= 786{,}432 bytes, under 1 MB.
  • Search: for each query, precompute a 96×25696 \times 256 table of distances from each query subvector to each centroid. Then each stored vector's approximate distance is the sum of 96 table lookups, with no multiplications.

Reciprocal rank fusion

BM25 scores and cosine similarities live on different scales, so adding them directly is fragile. RRF uses only ranks:

RRF(doc)=∑rankers j1c+rankj(doc),c=60\text{RRF}(\text{doc}) = \sum_{\text{rankers } j} \frac{1}{c + \text{rank}_j(\text{doc})}, \qquad c = 60

Worked example. Dense ranking: A, B, C, D. BM25 ranking: E, F, A, B.

  • A: 160+1+160+3=0.016393+0.015873=0.032266\frac{1}{60+1} + \frac{1}{60+3} = 0.016393 + 0.015873 = 0.032266.
  • B: 162+164=0.016129+0.015625=0.031754\frac{1}{62} + \frac{1}{64} = 0.016129 + 0.015625 = 0.031754.
  • E: 161=0.016393\frac{1}{61} = 0.016393 (BM25 only).
  • F: 162=0.016129\frac{1}{62} = 0.016129.
  • C: 163=0.015873\frac{1}{63} = 0.015873.
  • D: 164=0.015625\frac{1}{64} = 0.015625.

Fused order: A, B, E, F, C, D. Documents both rankers like (A, B) rise to the top. E, BM25's first pick that dense search missed, still beats C and D. The large constant cc keeps one ranker's first place from dominating.

Metadata filtering

Suppose a user may only see 2% of the corpus. Post-filtering (retrieve top 10, then drop disallowed ones) keeps on average 10×0.02=0.210 \times 0.02 = 0.2 results: usually none. To expect 10 survivors you would need to fetch 10/0.02=50010 / 0.02 = 500 candidates. Pre-filtering (restrict the search to allowed vectors) returns 10 correct results, but it interacts with the index: an HNSW traversal that must skip 98% of nodes can get stuck, and IVF cells may contain few allowed vectors. Good engines use filter-aware traversal, per-tenant partitions, or fall back to brute force over the allowed subset when it is small. Access-control filters must be enforced in the retrieval query, never left to the LLM.

4. Implementation

The full script is code/16-rag/vector_search_scratch.py (NumPy only, about two seconds on a laptop). It builds each index type from section 3 on its own k-means and measures recall against exact search on synthetic clustered vectors (64 clusters in 32 dimensions; real embeddings cluster too, but less cleanly).

Build it from scratch

Step 1: exact search. Normalize every vector once, at write time, so cosine becomes a dot product. Then the whole search is one matrix-vector product. argpartition finds the top kk in O(N)O(N) without sorting all NN scores; only those kk get sorted.

python
def normalize(X):
    return X / np.linalg.norm(X, axis=-1, keepdims=True)

def exact_topk(X, q, k):                          # X: [N, d] normalized, q: [d] normalized
    s = X @ q                                     # one matrix-vector product: N dot products
    top = np.argpartition(-s, k)[:k]              # top k in O(N), unsorted
    return top[np.argsort(-s[top])]

Predict: you normalize the stored vectors but forget to normalize the query. Does the ranking change?

Step 2: k-means. Both IVF and PQ are built on it. Squared distances come from ∥x∥2−2x⋅c+∥c∥2\lVert x\rVert^2 - 2x \cdot c + \lVert c\rVert^2, so every point-to-centroid distance is one matrix product instead of a Python loop.

python
def kmeans(X, k, rng, iters=10):
    C = X[rng.choice(len(X), k, replace=False)]   # start from k random points
    for _ in range(iters):
        d2 = (X**2).sum(1)[:, None] - 2 * X @ C.T + (C**2).sum(1)   # squared distances [N, k]
        lab = d2.argmin(1)
        for j in range(k):
            if (lab == j).any():
                C[j] = X[lab == j].mean(0)
    return C, lab

Predict: the shapes are [N, 1], [N, k], and [k]. Which broadcasting rule makes the sum [N, k]?

Step 3: IVF. Cluster the data into nlistn_{\text{list}} cells and store each cell's member IDs (the inverted lists). A query compares itself to the centroids, opens the nproben_{\text{probe}} nearest cells, and runs exact search on only those members.

python
class IVF:
    def __init__(self, X, nlist, rng):
        self.X = X
        self.C, lab = kmeans(X, nlist, rng)
        self.lists = [np.flatnonzero(lab == c) for c in range(nlist)]   # inverted lists

    def search(self, q, k, nprobe):
        cells = np.argsort(-(self.C @ q))[:nprobe]                     # nearest centroids
        cand = np.concatenate([self.lists[c] for c in cells])
        return cand[np.argsort(-(self.X[cand] @ q))[:k]], len(cand)

Predict: a query's true neighbor sits just across the border of the nearest cell. Which knob fixes the miss, and what does it cost?

Step 4: product quantization. Cut each vector into mm subvectors and run k-means with 256 centroids on each position, so a subvector is stored as one byte. At query time, score the query's subvectors against every centroid once (an [m,256][m, 256] table); each stored vector's score is then mm table lookups added up.

python
class PQ:
    def __init__(self, X, m, rng):
        self.m, self.ds = m, X.shape[1] // m      # m subvectors of ds dimensions
        self.books, codes = [], []
        for j in range(m):
            sub = X[:, j * self.ds:(j + 1) * self.ds]
            C, lab = kmeans(sub, 256, rng)        # 256 centroids -> a code fits in one byte
            self.books.append(C); codes.append(lab)
        self.codes = np.stack(codes, 1).astype(np.uint8)   # [N, m]: the whole stored index

    def search(self, q, k):
        table = np.stack([self.books[j] @ q[j * self.ds:(j + 1) * self.ds] for j in range(self.m)])  # [m, 256]
        s = table[np.arange(self.m), self.codes].sum(1)    # m lookups per vector, no float math on X
        return np.argsort(-s)[:k]

Predict: section 3's example has d=768d = 768 and m=96m = 96. How big is the lookup table per query, in entries?

Step 5: BM25 and fusion. BM25 is TF-IDF with two fixes: term frequency saturates (k1k_1 caps how much a repeated word helps) and long documents are discounted (bb normalizes by length). RRF then merges any number of rankings using ranks only, so BM25 scores and cosine scores never need to share a scale.

python
def bm25_scores(docs, query, k1=1.5, b=0.75):
    toks = [re.findall(r"\w+", d.lower()) for d in docs]
    avgdl = sum(map(len, toks)) / len(toks)
    df = Counter(t for d in toks for t in set(d))
    scores = []
    for d in toks:
        tf, s = Counter(d), 0.0
        for t in re.findall(r"\w+", query.lower()):
            if t in tf:
                idf = math.log((len(toks) - df[t] + 0.5) / (df[t] + 0.5) + 1)
                s += idf * tf[t] * (k1 + 1) / (tf[t] + k1 * (1 - b + b * len(d) / avgdl))
        scores.append(s)
    return scores

def rrf(rankings, c=60):
    s = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            s[doc] = s.get(doc, 0.0) + 1 / (c + rank)
    return sorted(s, key=s.get, reverse=True), s

Predict: with k1=1.5k_1 = 1.5 and an average-length document, how much more does a word appearing 10 times add than a word appearing once? (Compute tf(k1+1)tf+k1\frac{tf(k_1+1)}{tf + k_1} for both.)

Use the library

FAISS names each idea in an index string, and vector databases expose the same knobs:

python
import faiss                                         # pip install faiss-cpu
d = X.shape[1]
index = faiss.index_factory(d, "IVF1024,PQ16", faiss.METRIC_INNER_PRODUCT)
index.train(X)                                       # k-means for the cells and the PQ codebooks
index.add(X)
index.nprobe = 8                                     # cells scanned per query
scores, ids = index.search(Q, 10)

hnsw = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT)   # M = 32
hnsw.hnsw.efSearch = 64                              # query-time candidate list

In Postgres, pgvector offers ivfflat (with ivfflat.probes) and hnsw (with hnsw.ef_search) indexes. What the libraries add: SIMD and GPU kernels, HNSW graphs (not built here: the build is long, and the knobs in section 3 are what you tune), on-disk indexes, filtered search, and incremental inserts.

Run it

bash
uv run code/16-rag/vector_search_scratch.py

Key lines of output (synthetic vectors):

text
dot a=0.8 b=3.0 | cos a=0.8 b=0.6 | L2 a=0.632 b=4.472
nprobe= 1: recall@10 0.745, vectors scanned 1.1% of N
nprobe= 4: recall@10 0.995, vectors scanned 3.4% of N
nprobe=16: recall@10 1.000, vectors scanned 12.2% of N
memory: float32 128 bytes/vector -> PQ 16 bytes/vector (8x smaller)
recall@10 PQ only 0.379; PQ top-100 + exact rerank 0.947
query 'E1234': [1.23, 0.0, 0.0, 0.0] <- exact IDs are BM25's strength
fused: ['A', 'B', 'E', 'F', 'C', 'D'] {'A': 0.032266, 'B': 0.031754, ...}

The first and last lines reproduce section 3's worked examples. IVF reaches 99.5% recall while scanning 3.4% of the vectors. PQ alone is crude, because neighbors in tight clusters differ by less than the quantization error, but as a shortlist followed by exact rescoring of 100 candidates it recovers most of the recall: the standard production pattern. These recall numbers come from clean synthetic clusters; measure your own index on real queries. The cost arithmetic and the section 2 figure are in code/16-rag/vector_search.py.

5. Engineering

Choosing the embedding model and dimension. Evaluate candidate models on your queries and documents with a labeled set, not on a public leaderboard alone. Dimension drives memory and latency linearly: 3072 dimensions cost 4 times what 768 cost. Some models are trained so that a prefix of the vector is itself a good embedding (Matryoshka-style), letting you truncate to 256 or 512 dimensions and re-normalize. Quantizing stored vectors to int8 or binary with a full-precision re-score of the top candidates is another common memory lever. Read the model card for input formatting: many retrieval embedding models are asymmetric and expect a different prefix or instruction on queries than on documents (E5 uses query: and passage: ), and skipping it quietly lowers recall.

Reindexing when the model changes. Vectors from two different embedding models are not comparable, even at the same dimension. Changing the model means re-embedding the whole corpus. Do it as a blue-green migration: build the new index alongside the old one, compare recall on your eval set, switch reads, then delete the old index. Store the model name and version with every vector and every request trace.

Updates and deletes. IVF appends new vectors to their nearest cell, but the centroids were trained on old data, so cells drift out of balance as the data changes; retrain periodically. HNSW inserts are incremental but deletes are usually tombstones that the search skips until a rebuild or compaction. Plan for re-index jobs, and track the fraction of deleted entries.

Latency budget. Tune on a recall target, for example "recall@10 at least 0.95 against brute force at p95 latency under 20 ms", and report both. A reranker after retrieval can recover precision, but it cannot recover a document the index never returned.

Which system.

OptionGood forWatch out for
In-memory (NumPy, FAISS in-process)up to a few million vectors, batch jobs, prototypesno persistence, filtering, or multi-writer story
pgvector (Postgres)data already in Postgres, transactions, joins with metadata, moderate scaletuning HNSW/IVF settings, filtered queries, memory on one node
Dedicated vector database or search enginelarge scale, hybrid search, filtering, replication, multi-tenancyanother system to run, sync, and secure
Watch out

Failure modes. Query embedded with a different model or version than the corpus; mixing normalized and unnormalized vectors; post-filtering that returns nothing; nprobe or efSearch left at a tiny default; stale IVF centroids after heavy inserts; deleted documents still returned; measuring recall on random vectors instead of real queries.

Common mistakes

  • Using cosine for a model trained with dot product, or the reverse, without checking.
  • Tuning the LLM prompt when the index recall against brute force is 0.7.
  • Assuming exact search is always too slow. Under a few hundred thousand vectors, brute force is often fast enough and has perfect recall.
  • Adding BM25 and cosine scores directly without normalization, instead of RRF or normalized scores.
  • Forgetting that HNSW memory includes the graph, not just the vectors.

6. Knowledge check

Quiz: Vector search

Question 1 of 9
Calculationmedium

An IVF index holds N=2,000,000N = 2{,}000{,}000 vectors in nlist=2000n_{\text{list}} = 2000 balanced cells. With nprobe=10n_{\text{probe}} = 10, how many distance computations does one query make in total (centroids plus scanned vectors)?

Practice exercise. You store N=10,000,000N = 10{,}000{,}000 vectors with d=1024d = 1024 in float32. (a) How much memory do the raw vectors take? (b) With PQ using m=128m = 128 one-byte codes, how much? (c) An IVF index has nlist=4096n_{\text{list}} = 4096 and nprobe=16n_{\text{probe}} = 16. Approximately how many vectors does one query scan?

Solution

(a) 107×1024×4=40,960,000,00010^7 \times 1024 \times 4 = 40{,}960{,}000{,}000 bytes =40.96= 40.96 GB. (b) 107×128=1,280,000,00010^7 \times 128 = 1{,}280{,}000{,}000 bytes =1.28= 1.28 GB, a 32×32\times compression (4096/1284096 / 128). (c) 164096×107=39,062.5\frac{16}{4096} \times 10^7 = 39{,}062.5 vectors, plus 4096 centroid distances, about 43,15943{,}159 distances in total (0.43% of brute force).

Implementation challenge. Extend the script: build an IVF index on the synthetic data, then plot recall@10 against nprobe for nlist∈{32,128,512}n_{\text{list}} \in \{32, 128, 512\} on the same cost axis. Which nlist gives the best recall at 5% of brute-force cost?

Solution sketch

Loop over nlist, build IVF(X, nlist), and for each nprobe average recall and (nlist + scanned) / N over the held-out queries. Small nlist scans big cells (high recall per probe, but each probe is expensive); large nlist pays more for centroid comparisons and needs more probes because neighbors spread across more cells. The best setting is usually in the middle, near N\sqrt{N}, and depends on how clustered the data is. That is why you tune it on real data.

Summary

  • Normalize embeddings and the three metrics agree; then use dot product. Otherwise use the metric the model was trained with.
  • Exact search costs O(N⋅d)O(N \cdot d) per query and has perfect recall. It is often fine up to a few hundred thousand vectors.
  • IVF scans nprobe/nlistn_{\text{probe}}/n_{\text{list}} of the data; HNSW walks a neighbor graph tuned by MM and efSearch; PQ compresses vectors about 32 times at some accuracy cost.
  • Measure index recall@k against brute force on real queries, and tune for a recall target at a latency budget.
  • Fuse BM25 and dense rankings with RRF, enforce permission filters inside retrieval, and re-embed everything when the embedding model changes.

Next: Evaluating LLM systems

Related: The RAG pipeline · K-nearest neighbors · K-means · Cheat sheet: vector search

Go deeper, code-first (AI Engineering from Scratch): Image Retrieval & Metric Learning

Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Information Retrieval and Search, Embedding Models — The 2026 Deep Dive, Embeddings & Vector Representations, Hybrid Retrieval with BM25 and Dense Embeddings.

Interview angle

Explain how an HNSW index finds nearest neighbors, and what efSearch and M control.

HNSW is a layered proximity graph. Every vector is a node in the bottom layer, linked to about 2M2M near neighbors; a random, exponentially shrinking subset also appears in upper layers with longer-range links. A query starts at an entry point in the top layer and greedily moves to whichever neighbor is closer to the query, drops a layer when it can't improve, and repeats. At the bottom it runs a best-first search keeping a candidate list of size efSearch, and returns the best kk. MM is a build-time choice: more links give better recall and robustness but cost memory (about 2M×42M \times 4 bytes per vector, so 256 MB per million vectors at M=32M = 32) and slower builds. efSearch is the query-time knob: raise it for recall, lower it for latency. It must be at least kk.

When would you pick IVF-PQ over HNSW, and when would you skip ANN entirely?

Pick HNSW when the vectors fit in RAM and you want high recall at low latency with little tuning. Its cost is memory: full vectors plus the graph. Pick IVF-PQ when memory is the constraint, for example hundreds of millions of vectors: PQ stores each vector in tens of bytes instead of kilobytes, about 32 times smaller, and IVF limits each query to a few cells. You pay with lower raw recall, so you usually re-score the top few hundred candidates with full-precision vectors. Skip ANN when NN is small, up to a few hundred thousand vectors, or when filters cut the candidate set to a few thousand. Brute force is one matrix-vector product, has perfect recall, needs no tuning or rebuilds, and handles deletes trivially.

After adding a "department" filter, users of small departments get zero or irrelevant results. What is happening and how do you fix it?

Most likely the filter is applied after the vector search. The index returns the global top kk, and when a department holds 1% of documents, on average 0.01k0.01 k of them survive, often zero. Confirm by logging how many candidates are fetched versus returned after filtering, broken down by department size. Fixes: use the engine's pre-filtering or filter-aware search so the constraint is applied during traversal; raise the candidate count adaptively based on filter selectivity; for very selective filters, fall back to brute force over the matching subset, which is cheap; or partition the index by tenant or department if queries always filter on it. Check HNSW specifically, because heavy filtering can strand the greedy traversal in regions with no allowed nodes. And if this is an access-control filter, make sure it's enforced in retrieval, never by the LLM.

Estimate the memory for 50 million 768-dimensional embeddings, and how you would cut it.

Raw float32 is 50,000,000×768×4=153.650{,}000{,}000 \times 768 \times 4 = 153.6 GB. HNSW with M=32M = 32 adds roughly 64×4=25664 \times 4 = 256 bytes per vector at the bottom layer, about 12.8 GB, so roughly 166 GB in RAM: several nodes or a very large one. Levers, in order of quality cost: float16 halves it to 76.8 GB; int8 scalar quantization quarters it to 38.4 GB with small recall loss; truncating a Matryoshka-trained model to 256 dimensions divides by 3; PQ with 96 one-byte codes gives 50,000,000×96=4.850{,}000{,}000 \times 96 = 4.8 GB. With aggressive compression, keep full vectors on disk and re-score the top 100 to 200 candidates exactly. Then verify recall@k against brute force on real queries before shipping.