Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Ask How do I rotate an API key? and your search engine might hand back a beautifully formatted quota dashboard instead of key rotation steps. The LLM then generates an eloquent, authoritative answer about daily rate limits. It sounds convincing, but it didn't receive the evidence required to solve the developer's problem.
In Clustering and PCA, we examined geometry in vector spaces and compared cosine similarity with dot products. The EM clustering walkthrough grouped unlabeled observations into latent Gaussian mixtures. Retrieval tackles an operational challenge: given millions of documentation chunks, which specific passages contain the factual evidence to answer an arbitrary developer query?
Using one question and four representative passages, this analysis works through the full evidence-selection stack: sparse lexical matching with BM25, dense bi-encoder embeddings, candidate fusion with RRF, and cross-encoder reranking. We can then audit Approximate Nearest Neighbor (ANN) indices to isolate approximate search boundary misses from representation errors.
One question, four possible sources
Here's the developer question:
How do I rotate an API key?
The documentation assistant retrieves four candidate passages:
| Doc | Passage | Relevance judgment |
|---|---|---|
d1 | "API key rotation guide. Create a replacement key, deploy it, then revoke the old key." | Direct answer. |
d2 | "Generate a new credential before disabling the previous credential." | Relevant paraphrase. |
d3 | "Change a project display name in workspace settings." | Not relevant. |
d4 | "API key quota dashboard. Usage limits reset daily." | Related words, wrong problem. |
The gap between d2 and d4 highlights the central challenge of search. Passage d4 shares literal vocabulary (API key), yet it's completely unhelpful for rotation. Meanwhile, d2 directly answers how to rotate credentials without using the exact phrase API key.
Retrieval evaluation demands relevance judgments tied strictly to specific questions, not broad topical categories.

Sparse retrieval starts with an inverted index
Classical lexical search represents documents by their individual terms. Instead of linearly reading through all passages at query time, an inverted index maps each unique vocabulary token to a sorted posting list of document identifiers containing that token.[1]
At scale, posting lists are compressed using delta-encoding (such as Frame of Reference or variable-byte encodings) so that lookups only touch documents that contain at least one query term. The query execution time drops from passage evaluations to .
Production tokenization requires careful engineering: stripping punctuation carelessly can ruin technical searches. An error code like AUTH_401 or an API symbol like rotate_api_key() must stay intact. For this minimal walkthrough, tokenization lowercases text, removes punctuation, and filters out common stopwords.
Our test query filters down to three terms: rotate, api, and key. The script below builds the inverted index and prints each term's posting list. Notice: rotate matches nothing because our passage used rotation, while api and key point only to d1 and d4.
1import re
2from collections import defaultdict
3
4docs = {
5 "d1": "API key rotation guide. Create a replacement key, deploy it, then revoke the old key.",
6 "d2": "Generate a new credential before disabling the previous credential.",
7 "d3": "Change a project display name in workspace settings.",
8 "d4": "API key quota dashboard. Usage limits reset daily.",
9}
10query = "How do I rotate an API key?"
11stopwords = {"a", "an", "and", "do", "for", "how", "i", "in", "it", "the", "then", "to", "your"}
12
13def tokenize(text: str) -> list[str]:
14 return [word for word in re.findall(r"[a-z]+", text.lower()) if word not in stopwords]
15
16postings = defaultdict(list)
17for doc_id, passage in docs.items():
18 for term in sorted(set(tokenize(passage))):
19 postings[term].append(doc_id)
20
21query_terms = tokenize(query)
22print("query terms:", query_terms)
23for term in query_terms:
24 print(f"{term:6} -> {postings[term]}")
25assert query_terms == ["rotate", "api", "key"]
26assert postings["rotate"] == []
27assert postings["api"] == ["d1", "d4"]
28assert "d2" not in postings["key"]1query terms: ['rotate', 'api', 'key']
2rotate -> []
3api -> ['d1', 'd4']
4key -> ['d1', 'd4']Passage d2 is judged relevant by human reviewers, but it doesn't appear in any posting list for our query terms. This is the classic vocabulary mismatch problem. Lexical indices can't see through synonyms or paraphrased syntax on their own.
BM25 scores lexical evidence
Once candidate documents are identified from posting lists, how should we score them?
In passage d1, the term api appears once, while key appears three times. Early TF-IDF implementations assumed linear term frequency: repeating a word ten times made the passage ten times as relevant (). In technical documentation, that behavior rewards keyword stuffing: a verbose release note mentioning API key twelve times easily beats a concise, direct troubleshooting snippet.
The Okapi BM25 algorithm solves this by applying an asymptotic saturation curve to term frequency, combined with document length normalization.[2][3] The contribution of query term in document is:
Here, is the count of term in document , is the token length of document , and is the average document length across the corpus. The formula relies on two parameters:
- Term frequency saturation (): Controls how quickly term frequency gains level off. As , the fractional term approaches . Standard engines like Lucene set . A lower setting (such as 0.5) saturates almost immediately, acting like a binary presence check. A higher setting (such as 2.0) gives more weight to repeated terms.
- Document length normalization (): Governs how severely long documents are penalized. If , full length normalization applies, scaling the penalty directly with document length. If , document length is completely ignored. Lucene defaults to , penalizing verbose documents while acknowledging that comprehensive guides naturally contain more words.[4]
For Inverse Document Frequency (IDF), Robertson-Spärck Jones weighting uses . When a term appears in more than half the documents, that formula turns negative. Lucene uses a positive variant:
is the total document count and counts documents containing term .
Computing BM25 scores illustrates the length factor in practice. After stopword removal, d1 has 11 tokens, and the four passages average 8 tokens (). For api in d1, and . With :
The length normalization factor is . Plugging these into the formula yields . Repeating this for key () gives . The total BM25 score for d1 is .
| Query term | Count in d1 | Documents with term | Score contribution |
|---|---|---|---|
rotate | 0 | 0 | 0.000 |
api | 1 | 2 | 0.601 |
key | 3 | 2 | 1.008 |
The three occurrences of key contribute compared to api's . Tripling the word count didn't triple the score.
The script below scores all passages and sorts the candidate list.
1import math
2import re
3from collections import Counter
4
5docs = {
6 "d1": "API key rotation guide. Create a replacement key, deploy it, then revoke the old key.",
7 "d2": "Generate a new credential before disabling the previous credential.",
8 "d3": "Change a project display name in workspace settings.",
9 "d4": "API key quota dashboard. Usage limits reset daily.",
10}
11query = "How do I rotate an API key?"
12stopwords = {"a", "an", "and", "do", "for", "how", "i", "in", "it", "the", "then", "to", "your"}
13
14def tokenize(text: str) -> list[str]:
15 return [word for word in re.findall(r"[a-z]+", text.lower()) if word not in stopwords]
16
17tokens = {doc_id: tokenize(text) for doc_id, text in docs.items()}
18query_terms = tokenize(query)
19average_length = sum(len(row) for row in tokens.values()) / len(tokens)
20document_frequency = Counter(term for row in tokens.values() for term in set(row))
21
22def bm25(doc_id: str, k1: float = 1.2, b: float = 0.75) -> float:
23 score = 0.0
24 counts = Counter(tokens[doc_id])
25 for term in query_terms:
26 frequency = counts[term]
27 if frequency == 0:
28 continue
29 df = document_frequency[term]
30 idf = math.log(1 + (len(docs) - df + 0.5) / (df + 0.5))
31 denominator = frequency + k1 * (1 - b + b * len(tokens[doc_id]) / average_length)
32 score += idf * frequency * (k1 + 1) / denominator
33 return score
34
35ranking = sorted(((doc_id, bm25(doc_id)) for doc_id in docs), key=lambda item: (-item[1], item[0]))
36assert ranking[0][0] == "d1"
37assert ranking[1][0] == "d4"
38assert ranking[2][1] == 0.0
39for rank, (doc_id, score) in enumerate(ranking, start=1):
40 print(f"#{rank} {doc_id}: {score:.3f}")
41sparse_candidates = [doc_id for doc_id, score in ranking if score > 0]
42assert sparse_candidates == ["d1", "d4"]
43print("matching candidates:", sparse_candidates)1#1 d1: 1.609
2#2 d4: 1.386
3#3 d2: 0.000
4#4 d3: 0.000
5matching candidates: ['d1', 'd4']BM25 ranks d1 first () and d4 second (). Documents d2 and d3 score zero because they contain no query terms.
Notice what happened: BM25 ranks d4 (the quota dashboard) above d2 (the credential guide) simply because d4 contains the exact tokens api and key. Lexical search alone can't bridge semantic intent. That's why we need a dense retrieval lane.
Repetition saturates instead of winning automatically
Isolating the saturation factor clarifies how behaves. Holding document length and IDF constant (), the term weight simplifies to:
For , the upper bound as frequency climbs is . The next lab traces how the curve flattens as word frequency increases from 1 to 20:
1def term_weight(frequency: int, k1: float = 1.2) -> float:
2 return frequency * (k1 + 1) / (frequency + k1)
3
4limit = 1.2 + 1
5for frequency in (1, 2, 4, 20):
6 weight = term_weight(frequency)
7 remaining = limit - weight
8 print(f"api count={frequency:2}: tf weight={weight:.3f} gap to limit={remaining:.3f}")1api count= 1: tf weight=1.000 gap to limit=1.200
2api count= 2: tf weight=1.375 gap to limit=0.825
3api count= 4: tf weight=1.692 gap to limit=0.508
4api count=20: tf weight=2.075 gap to limit=0.125The first occurrence contributes . By the twentieth occurrence, the weight has reached , closing within of the theoretical ceiling. BM25 prevents keyword stuffing from overwhelming the search results.
Dense retrieval recovers paraphrases
Dense retrieval represents queries and passages as continuous vectors in using neural bi-encoders.[5]
A bi-encoder architecture uses two separate encoder towers: a query encoder and a passage encoder . Because the passage representations don't depend on the incoming query, we can precompute and index every document vector offline. When a developer submits a query at runtime, the system runs a single forward pass and computes vector similarity against the indexed document vectors.
To see how directional similarity works in practice, consider a 3D coordinate fixture. These synthetic vectors place the query close to the paraphrased credential guide:
| Item | Vector | Intended meaning |
|---|---|---|
| query | [1.0, 0.8, 0.0] | API-key rotation intent |
d1 | [1.0, 0.4, 0.0] | exact rotation guide |
d2 | [0.9, 0.9, 0.0] | paraphrased credential guide |
d3 | [0.0, 0.2, 1.0] | project settings |
d4 | [0.4, 0.0, 0.3] | related API quota |
Cosine similarity measures the angle between vectors, normalizing out differences in vector length:
For passage d2, the dot product is . Dividing by the norms () gives approximately . Even though d2 says credential instead of API key, its direction aligns almost perfectly with the query's intent.
1import math
2
3vectors = {
4 "query": [1.0, 0.8, 0.0],
5 "d1": [1.0, 0.4, 0.0],
6 "d2": [0.9, 0.9, 0.0],
7 "d3": [0.0, 0.2, 1.0],
8 "d4": [0.4, 0.0, 0.3],
9}
10
11def dot(left: list[float], right: list[float]) -> float:
12 if len(left) != len(right):
13 raise ValueError("vector dimensions must match")
14 return sum(a * b for a, b in zip(left, right))
15
16def l2(vector: list[float]) -> float:
17 return math.sqrt(dot(vector, vector))
18
19def cosine(left: list[float], right: list[float]) -> float:
20 numerator = dot(left, right)
21 denominator = l2(left) * l2(right)
22 if denominator == 0:
23 raise ValueError("cosine is undefined for a zero vector")
24 return numerator / denominator
25
26ranking = sorted(
27 ((doc_id, cosine(vectors["query"], vector)) for doc_id, vector in vectors.items() if doc_id != "query"),
28 key=lambda item: (-item[1], item[0]),
29)
30assert ranking[0][0] == "d2"
31assert abs(ranking[0][1] - 0.994) < 0.001
32for rank, (doc_id, score) in enumerate(ranking, start=1):
33 print(f"#{rank} {doc_id}: {score:.3f}")1#1 d2: 0.994
2#2 d1: 0.957
3#3 d4: 0.625
4#4 d3: 0.123Dense retrieval recovers d2 () as its top candidate, solving the vocabulary mismatch that caused BM25 to drop it. It also ranks d1 high () while pushing the quota distractor d4 down to .
Similarity must match the embedding contract
If vectors are unit-normalized (), dot product and cosine similarity produce the exact same rank order because the denominator is always .
If vectors aren't normalized, vector magnitude can overturn angular alignment. An unnormalized dot product rewards longer vectors, which can lead to misleading search results:
1import math
2
3query = [1.0, 0.8, 0.0]
4candidates = {
5 "aligned_paraphrase": [1.0, 0.8, 0.0],
6 "large_partial_match": [6.0, 0.0, 0.0],
7}
8
9def dot(left: list[float], right: list[float]) -> float:
10 return sum(a * b for a, b in zip(left, right))
11
12def l2(vector: list[float]) -> float:
13 return math.sqrt(dot(vector, vector))
14
15dot_ranking = sorted(candidates, key=lambda name: -dot(query, candidates[name]))
16cosine_ranking = sorted(
17 candidates,
18 key=lambda name: -dot(query, candidates[name]) / (l2(query) * l2(candidates[name])),
19)
20
21assert dot_ranking[0] == "large_partial_match"
22assert cosine_ranking[0] == "aligned_paraphrase"
23print("dot-product winner:", dot_ranking[0])
24print("cosine winner: ", cosine_ranking[0])1dot-product winner: large_partial_match
2cosine winner: aligned_paraphraseThe dot product with large_partial_match is , overpowering aligned_paraphrase () simply because of its length. Cosine normalizes out length, correctly picking the aligned paraphrase.
Always verify the distance contract your embedding model was trained for. If a model was trained on cosine distance, storing unnormalized embeddings in an inner-product index changes the ranking semantics.
A query contains an exact error code plus a paraphrase of the surrounding symptom. Why can a hybrid retriever outperform either BM25 or dense retrieval alone?
Answer
BM25 preserves exact lexical evidence such as the error code, while dense retrieval can recover semantically similar wording. Fusion lets both signals contribute without pretending their raw scores share one scale.
Fuse independent retrieval lanes
Neither retrieval strategy is flawless on its own:
- Sparse BM25: Excellent for exact identifiers, error codes, UUIDs, and rare function names. It fails on paraphrases and conceptual synonyms.
- Dense bi-encoders: Excellent for semantic intent, broad themes, and natural language paraphrases. They struggle with rare alphanumeric tokens, version tags, and exact keyword matches.
Because their strengths are complementary, production search pipelines run both in parallel:
| Lane | Returned ranking | Strengths & weaknesses |
|---|---|---|
| BM25 sparse | d1, d4 | Catches api key, misses paraphrase d2 |
| Dense cosine | d2, d1, d4 | Catches paraphrase d2, ranks quota d4 lower |
How should we combine these results?
BM25 produces unbounded scores (from 0 to 20+), while cosine similarity is bounded between -1 and 1. We can't simply add them together without distorting the results.
Engineers typically use one of two fusion methods:
- Normalized score blending: Rescale BM25 and cosine scores to via min-max scaling () and compute a weighted sum . While this retains relative score margins, it's sensitive to distribution shifts and outlier scores on individual queries.
- Reciprocal Rank Fusion (RRF): Ignores score magnitudes entirely and evaluates relevance purely by rank position across result lists.[6]
The RRF formula for document over ranked lists is:
Here, is the 1-indexed rank of document in list . If list didn't return document , that lane contributes zero.
The constant (defaulting to 60 in Lucene, Elasticsearch, and Azure AI Search) acts as a smoothing parameter. For , rank 1 contributes , while rank 2 contributes . The gap between adjacent ranks is small (). This dampens the impact of extreme outliers and rewards candidates that earn consensus across multiple lanes.

The following function computes RRF rankings for both and :
1from collections import defaultdict
2
3bm25 = ["d1", "d4"] # zero-score documents aren't candidates
4dense = ["d2", "d1", "d4"] # top three of the cosine ranking
5
6def rrf(rankings, k=60):
7 if k < 0:
8 raise ValueError("k must be nonnegative")
9 scores = defaultdict(float)
10 for ranking in rankings:
11 if len(set(ranking)) != len(ranking):
12 raise ValueError("each lane must return unique document IDs")
13 for rank, document in enumerate(ranking, start=1):
14 scores[document] += 1 / (k + rank)
15 return scores
16
17for k in (60, 0):
18 scores = rrf([bm25, dense], k=k)
19 fused = sorted(scores, key=lambda document: (-scores[document], document))
20 expected = ["d1", "d4", "d2"] if k == 60 else ["d1", "d2", "d4"]
21 assert fused == expected
22 print(f"k={k}")
23 for rank, document in enumerate(fused, start=1):
24 print(f" #{rank} {document}: {scores[document]:.6f}")1k=60
2 #1 d1: 0.032522
3 #2 d4: 0.032002
4 #3 d2: 0.016393
5k=0
6 #1 d1: 1.500000
7 #2 d2: 1.000000
8 #3 d4: 0.833333At , document d1 wins () because it ranked high in both lanes. But notice document d4: because it received votes from both lanes (#2 in BM25, #3 in dense), its fused score () beats d2 (), which received votes only from the dense lane.
This outcome carries an important operational lesson: if you pass only the top two fused candidates to the downstream stage, you'll forward d1 and d4, dropping the paraphrase d2 completely. Candidate retrieval needs a wide enough shortlist (such as top 50 to 100) so that high-quality single-lane discoveries survive.
Why does RRF use ranks instead of averaging the BM25 and cosine scores?
Answer
BM25 and cosine have different score scales. RRF combines rank positions instead. It discards score magnitudes, which also means it discards any useful information in their margins.
Reranking spends compute on the shortlist
Why don't we use a bi-encoder for everything?
A bi-encoder separates queries and documents into distinct vector computations:
The query never directly interacts with document tokens inside the transformer layers. The embedding vector must compress all subtle technical semantics into a single fixed-length vector.
A cross-encoder reranker eliminates this compression bottleneck.[7] It concatenates the query and document into a single token sequence:
The concatenated sequence passes through all transformer layers together. Every query token can attend directly to every document token via full bidirectional self-attention ( compute cost per candidate). The model outputs a calibrated relevance score from the [CLS] representation.
Evaluating a million passages with a cross-encoder would require a million full transformer forward passes, which is computationally impossible for real-time applications.
Production architectures organize retrieval into a multi-stage cascade:

First-stage hybrid retrieval acts as a wide, computationally light net that filters millions of documents down to 50 or 100 candidates. The cross-encoder then focuses its heavy attention computation only on that shortlist.
The critical constraint is that a reranker can only reorder candidates it receives. If the candidate retrieval stage fails to include a relevant passage, no reranker can salvage it:
1reranker_score = {"d1": 0.55, "d2": 0.96, "d4": 0.12}
2
3def rerank(candidates):
4 return sorted(candidates, key=lambda doc: -reranker_score[doc])
5
6for name, candidates in [
7 ("sparse-only top2", ["d1", "d4"]),
8 ("hybrid top3", ["d1", "d4", "d2"]),
9]:
10 ordered = rerank(candidates)
11 recovered = "d2" in ordered and ordered[0] == "d2"
12 print(f"{name:16}: top={ordered[0]} recovered_best={recovered}")1sparse-only top2: top=d1 recovered_best=False
2hybrid top3 : top=d2 recovered_best=TrueWhen the candidate pool is restricted to ["d1", "d4"], the reranker picks d1. It never sees d2. When the hybrid pool expands to ["d1", "d4", "d2"], the reranker evaluates d2 and correctly promotes it to rank 1.
The two stages require distinct evaluation metrics: candidate generation must be measured by Recall@K, while reranking is measured by MRR@K or nDCG@K.
Measure ranking before evaluating answers
Before evaluating LLM generation faithfulness, measure the accuracy of the retrieved evidence against judged evaluation queries:
- Hit Rate @ K: The fraction of queries where at least one relevant passage appears in the top results.
- Relevance Recall @ K: The fraction of all judged relevant passages for a query that appear in the top results, averaged across the query set.
- Mean Reciprocal Rank (MRR @ K): The average of , where is the position of the first relevant passage retrieved. If no relevant passage appears in the top , the reciprocal rank for that query is 0.[8]
The script below evaluates two candidate runs on three judged test queries:
1gold = {
2 "q1 api key rotation": {"d1", "d2"},
3 "q2 replace credential": {"d2"},
4 "q3 quota dashboard": {"d4"},
5}
6run_a = {
7 "q1 api key rotation": ["d1", "d4", "d2"],
8 "q2 replace credential": ["d3", "d4", "d2"],
9 "q3 quota dashboard": ["d4", "d1", "d2"],
10}
11run_b = {
12 "q1 api key rotation": ["d1", "d2", "d4"],
13 "q2 replace credential": ["d2", "d1", "d4"],
14 "q3 quota dashboard": ["d4", "d1", "d2"],
15}
16
17def evaluate(run, k=2):
18 if k <= 0:
19 raise ValueError("k must be positive")
20 hits = []
21 reciprocals = []
22 recalls = []
23 for question, relevant in gold.items():
24 ranking = run.get(question, [])
25 if len(set(ranking)) != len(ranking):
26 raise ValueError("rankings must contain unique document IDs")
27 hits.append(any(doc in relevant for doc in ranking[:k]))
28 recalls.append(len(set(ranking[:k]) & relevant) / len(relevant))
29 first_rank = next((rank for rank, doc in enumerate(ranking[:3], start=1) if doc in relevant), None)
30 reciprocals.append(0.0 if first_rank is None else 1 / first_rank)
31 return sum(hits) / len(gold), sum(recalls) / len(gold), sum(reciprocals) / len(gold)
32
33for name, run in [("run A", run_a), ("run B", run_b)]:
34 hit_rate, recall, mrr = evaluate(run)
35 print(f"{name}: hit@2={hit_rate:.3f} recall@2={recall:.3f} mrr@3={mrr:.3f}")
36
37hit_a, recall_a, mrr_a = evaluate(run_a)
38assert abs(hit_a - 2 / 3) < 1e-9
39assert abs(recall_a - 0.5) < 1e-9
40assert abs(mrr_a - (1 + 1 / 3 + 1) / 3) < 1e-9
41assert evaluate(run_b) == (1.0, 1.0, 1.0)
42assert evaluate({}) == (0.0, 0.0, 0.0)1run A: hit@2=0.667 recall@2=0.500 mrr@3=0.778
2run B: hit@2=1.000 recall@2=1.000 mrr@3=1.000Notice how these metrics capture different behaviors: on query q1, run A achieves a Hit Rate of at (because d1 was returned), but its Recall@2 is only because d2 was pushed to rank 3. On query q2, run A gets a Hit Rate of and Recall of at , but still gets a non-zero reciprocal rank contribution of in MRR@3 because d2 appeared at rank 3.
Approximate search is a recall decision
Flat exact search computes distances against every single vector in the database. For documents in dimensions, exhaustive scanning requires distance operations per query.
For passages and dimensions, an exact scan performs floating-point multiplications per query. At 200 concurrent queries per second, exact search exhausts CPU and GPU compute budgets.
To scale beyond hundreds of thousands of vectors, systems rely on Approximate Nearest Neighbor (ANN) search algorithms. ANN trades a tiny fraction of recall (typically 1% to 5%) in exchange for substantial latency reductions and higher throughput.[9]
Three indexing families power modern vector databases:
| Index Family | Core Mechanism | Primary Tuning Knobs | Trade-offs & Risks |
|---|---|---|---|
| IVF (Inverted File Index)[9] | Partitions space into Voronoi cells via k-means. Queries visit only the closest centroids. | nlist (partition count), nprobe (cells searched per query) | Queries near partition boundaries miss nearest neighbors in unprobed adjacent cells. |
| HNSW (Hierarchical Navigable Small World)[10][11] | Multi-layer proximity skip-graph. Top sparse layers provide long-range routing; bottom layer contains fine connections. | (links per node), (build beam), (query beam) | Unmatched recall-latency Pareto frontier; high RAM usage due to storing graph edges in memory. |
| PQ (Product Quantization)[12] | Slices -dimensional vectors into subvectors, clustering each into 256 centroids (1 byte each). | (subvector count), codebook centroid count | Up to 96% memory reduction; distance approximation errors can shuffle neighbor rankings. |
In an IVF index, the search budget is governed by nprobe: setting nprobe = 1 searches only the single closest Voronoi centroid, which is fast but risky. Increasing nprobe widens the search radius across neighboring cells, recovering recall at the cost of checking more vectors.
In HNSW graphs, the runtime knob is efSearch: increasing the priority queue size expands graph exploration, approaching exact search recall while increasing latency.

Watch an IVF probe miss the nearest passage
A one-dimensional coordinate fixture demonstrates how boundary misses occur in code.
Consider a 1D space with two centroids: centroid 0 at , and centroid 1 at . The Voronoi boundary between them falls at .
Our target document key_rotation sits at (inside list 1). A developer query arrives at . The distance to centroid 0 is , while the distance to centroid 1 is .
Because centroid 0 is slightly closer, single-probe search (nprobe = 1) routes the query exclusively into list 0. It never opens list 1, missing key_rotation even though it's only units away:
1documents = {
2 "generic_api": 0.0,
3 "key_rotation": 5.1,
4 "quota_page": 9.0,
5}
6centroids = {0: 0.0, 1: 10.0}
7lists = {0: [], 1: []}
8for doc, value in documents.items():
9 bucket = min(centroids, key=lambda i: abs(centroids[i] - value))
10 lists[bucket].append(doc)
11query = 4.9
12
13def nearest(candidates: list[str]) -> str:
14 return min(candidates, key=lambda doc: abs(documents[doc] - query))
15
16exact = nearest(list(documents))
17ordered_lists = sorted(centroids, key=lambda bucket: abs(centroids[bucket] - query))
18one_probe = nearest(lists[ordered_lists[0]])
19two_probe = nearest(lists[ordered_lists[0]] + lists[ordered_lists[1]])
20
21assert exact == "key_rotation"
22assert one_probe == "generic_api"
23assert two_probe == "key_rotation"
24print("exact nearest: ", exact)
25print("nprobe=1 result:", one_probe)
26print("nprobe=2 result:", two_probe)1exact nearest: key_rotation
2nprobe=1 result: generic_api
3nprobe=2 result: key_rotationThe miss has nothing to do with embedding quality: the vector representations were accurate. It's an indexing artifact. When an approximate retriever drops relevant results, benchmark exact search on the same vectors first. If exact search finds the answer and ANN misses it, adjust nprobe or efSearch before retraining embeddings.
Put an exact-search gate around ANN settings
Before deploying an ANN index configuration, measure ANN Recall@K against an exact brute-force scan across a representative evaluation query set:
This measures index fidelity, distinct from relevance evaluation. It checks whether the approximate index returns the same mathematical neighbors as brute-force search:
1exact_top1 = ["key_rotation", "quota_page", "display_name", "duplicate_secret"]
2ann_fast = ["generic_api", "quota_page", "display_name", "generic_api"]
3ann_tuned = ["key_rotation", "quota_page", "display_name", "duplicate_secret"]
4
5def recall_at_one(approximate, exact):
6 if not exact or len(approximate) != len(exact):
7 raise ValueError("provide nonempty, equally sized query-aligned result lists")
8 matches = sum(found == expected for found, expected in zip(approximate, exact))
9 return matches / len(exact)
10
11print(f"fast setting recall@1: {recall_at_one(ann_fast, exact_top1):.2f}")
12print(f"tuned setting recall@1: {recall_at_one(ann_tuned, exact_top1):.2f}")
13print("launch only after checking latency alongside recall")
14assert abs(recall_at_one(ann_fast, exact_top1) - 0.5) < 1e-9
15assert recall_at_one(ann_tuned, exact_top1) == 1.01fast setting recall@1: 0.50
2tuned setting recall@1: 1.00
3launch only after checking latency alongside recallThe ann_fast setting cuts latency by skipping neighboring clusters, but drops ANN Recall@1 to . The ann_tuned setting inspects adjacent cells, restoring recall to .
When tuning production vector databases, plot the full Pareto frontier of ANN Recall versus p99 query latency across varying efSearch or nprobe values. Choose the most aggressive setting that keeps ANN recall above your target threshold (typically 95% to 98%).
An approximate index lowers latency but drops candidate recall@20 from 0.94 to 0.81. Can a stronger reranker recover the missing relevant documents?
Answer
No. A reranker can reorder only candidates it receives. Tune the approximate-search knob against candidate recall and latency before spending more compute downstream.
A retrieval review checklist
Our worked examples isolated distinct failure modes across the retrieval pipeline:
- Lexical vocabulary misses: Handled by adding a dense bi-encoder lane.
- Keyword stuffing: Handled by BM25 term frequency saturation ().
- Score scale distortion: Handled by Reciprocal Rank Fusion ().
- Shortlist truncation losses: Handled by tuning the first-stage candidate cutoff .
- ANN partition boundary misses: Handled by tuning index knobs (
nprobe,efSearch) against exact brute-force search.
Before blaming an LLM for hallucinating, audit the retrieval stages systematically:
| Stage | Diagnostic Question | Validation Artifact |
|---|---|---|
| Relevance Ground Truth | Which passages actually answer each question? | Held-out query-to-passage judgment labels |
| Sparse BM25 Lane | Do exact identifiers, error codes, and flags survive? | Lexical Top-K candidate audit report |
| Dense Bi-Encoder Lane | Do semantic paraphrases rank near the top? | Semantic similarity ranking distribution |
| Rank Fusion (RRF) | Do both lanes contribute without scale distortions? | Fused candidate lists with lane attribution |
| Cross-Encoder Reranker | Is the best factual evidence placed at rank 1? | Reranked MRR@10 and nDCG@10 metrics |
| ANN Index Tuning | Did approximation settings drop true nearest neighbors? | ANN vs Exact Recall@K Pareto curve |
This disciplined separation prevents premature model retraining. When retrieval delivers verified evidence, the downstream LLM generation becomes significantly more reliable.
Practice tasks
Using the four-document fixture, alter one mechanism at a time and observe how the metrics respond:
- In
bm25-from-scratch.py, add a fifth documentd5: "Rotate an API key safely."and five distractor documents mentioning onlyAPI key quota limits. Recalculate document frequencies and IDFs. How do and change, and which term gains higher IDF weight? - In
reciprocal-rank-fusion.py, introduce a dense-only candidated5at dense rank 4. How doesd5score under RRF with ? If downstream reranking takes only the top 3 candidates, doesd5survive? - Add a fourth judged query with a single relevant passage appearing at rank 3 in
run_b. Recalculate Hit Rate@2, Relevance Recall@2, and MRR@3. - Shift the IVF query vector from to . Which centroid is probed first under
nprobe = 1, and does single-probe search recoverkey_rotation?
Practice guidance
- With 10 total documents,
rotateappears once (), whileapiappears in 8 documents (). The positive IDF forrotateis , whileapidrops to . Rarity amplifies the discriminative power ofrotate. - Document
d5receives from dense retrieval and from BM25. It ranks behindd1,d4, andd2. With a top-3 cutoff,d5is dropped before reranking. - With four queries and the fourth query's first relevant passage at rank 3, Hit Rate@2 and Recall@2 drop to . MRR@3 becomes .
- At , centroid 10 () is closer than centroid 0 (). List 1 is selected first, immediately finding
key_rotation() even withnprobe = 1.