LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnML Algorithms & EvaluationCore Retrieval Algorithms
🔍MediumRAG & Retrieval

Core Retrieval Algorithms

Build and evaluate the evidence-selection stage of a technical-docs assistant with BM25, dense similarity, rank fusion, reranking, and approximate search audits.

25 min read
Learning path
Step 41 of 196 in the full curriculum
Probabilistic Generative ModelsDecoding Algorithms

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:

DocPassageRelevance 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.

Comparison between sparse lexical retrieval, dense semantic retrieval, and human relevance judgments for a key rotation query. Lexical matching hits d1 and d4 but misses paraphrase d2; dense retrieval ranks d2 and d1 highest; human ground truth confirms d1 and d2 are valid answers.
Sparse lexical matching checks posting lists for literal query tokens, hitting d1 and d4 while completely missing the paraphrase d2. Dense semantic retrieval maps intent into continuous vector space, promoting d2 and d1 while suppressing distractors. Evaluating against human ground truth reveals the gaps of each lane.

Sparse retrieval starts with an inverted index

Classical lexical search represents documents by their individual terms. Instead of linearly reading through all NNN passages at query time, an inverted index maps each unique vocabulary token to a sorted posting list of document identifiers containing that token.[1]Reference 1Introduction to Information Retrieval.https://nlp.stanford.edu/IR-book/

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 O(N)O(N)O(N) passage evaluations to O(∑t∈Q∣postings(t)∣)O(\sum_{t \in Q} |\text{postings}(t)|)O(∑t∈Q​∣postings(t)∣).

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.

inspect-the-inverted-index.py
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"]
Output
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 (TF×IDFTF \times IDFTF×IDF). 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]Reference 2Okapi at TREC-3.https://trec.nist.gov/pubs/trec3/t3_proceedings.html[3]Reference 3The Probabilistic Relevance Framework: BM25 and Beyond.https://doi.org/10.1561/1500000019 The contribution of query term ttt in document ddd is:

Score(t,d)=IDF(t)⋅f(t,d)⋅(k1+1)f(t,d)+k1⋅(1−b+b⋅∣d∣avgdl)\mathrm{Score}(t, d) = \mathrm{IDF}(t) \cdot \frac{f(t,d) \cdot (k_1 + 1)}{f(t,d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\mathrm{avgdl}}\right)}Score(t,d)=IDF(t)⋅f(t,d)+k1​⋅(1−b+b⋅avgdl∣d∣​)f(t,d)⋅(k1​+1)​

Here, f(t,d)f(t, d)f(t,d) is the count of term ttt in document ddd, ∣d∣|d|∣d∣ is the token length of document ddd, and avgdl\mathrm{avgdl}avgdl is the average document length across the corpus. The formula relies on two parameters:

  1. Term frequency saturation (k1k_1k1​): Controls how quickly term frequency gains level off. As f(t,d)→∞f(t, d) \to \inftyf(t,d)→∞, the fractional term approaches k1+1k_1 + 1k1​+1. Standard engines like Lucene set k1=1.2k_1 = 1.2k1​=1.2. 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.
  2. Document length normalization (bbb): Governs how severely long documents are penalized. If b=1.0b = 1.0b=1.0, full length normalization applies, scaling the penalty directly with document length. If b=0.0b = 0.0b=0.0, document length is completely ignored. Lucene defaults to b=0.75b = 0.75b=0.75, penalizing verbose documents while acknowledging that comprehensive guides naturally contain more words.[4]Reference 4BM25Similarity: Apache Lucene 10.3.1https://lucene.apache.org/core/10_3_1/core/org/apache/lucene/search/similarities/BM25Similarity.html

For Inverse Document Frequency (IDF), Robertson-Spärck Jones weighting uses log⁡((N−df+0.5)/(df+0.5))\log((N - \mathrm{df} + 0.5) / (\mathrm{df} + 0.5))log((N−df+0.5)/(df+0.5)). When a term appears in more than half the documents, that formula turns negative. Lucene uses a positive variant:

IDF(t)=ln⁡(1+N−df(t)+0.5df(t)+0.5)\mathrm{IDF}(t) = \ln\left(1 + \frac{N - \mathrm{df}(t) + 0.5}{\mathrm{df}(t) + 0.5}\right)IDF(t)=ln(1+df(t)+0.5N−df(t)+0.5​)

NNN is the total document count and df(t)\mathrm{df}(t)df(t) counts documents containing term ttt.

Computing BM25 scores illustrates the length factor in practice. After stopword removal, d1 has 11 tokens, and the four passages average 8 tokens (avgdl=8\mathrm{avgdl} = 8avgdl=8). For api in d1, df=2\mathrm{df} = 2df=2 and f=1f = 1f=1. With N=4N = 4N=4:

IDF(api)=ln⁡(1+4−2+0.52+0.5)=ln⁡(2)≈0.693\mathrm{IDF}(\text{api}) = \ln\left(1 + \frac{4 - 2 + 0.5}{2 + 0.5}\right) = \ln(2) \approx 0.693IDF(api)=ln(1+2+0.54−2+0.5​)=ln(2)≈0.693

The length normalization factor is 1−0.75+0.75⋅(11/8)=1.281251 - 0.75 + 0.75 \cdot (11 / 8) = 1.281251−0.75+0.75⋅(11/8)=1.28125. Plugging these into the formula yields 0.6010.6010.601. Repeating this for key (f=3f = 3f=3) gives 1.0081.0081.008. The total BM25 score for d1 is 0.601+1.008=1.6090.601 + 1.008 = 1.6090.601+1.008=1.609.

Query termCount in d1Documents with termScore contribution
rotate000.000
api120.601
key321.008

The three occurrences of key contribute 1.0081.0081.008 compared to api's 0.6010.6010.601. Tripling the word count didn't triple the score.

The script below scores all passages and sorts the candidate list.

bm25-from-scratch.py
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)
Output
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 (1.6091.6091.609) and d4 second (1.3861.3861.386). 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 k1k_1k1​ behaves. Holding document length and IDF constant (∣d∣=avgdl|d| = \mathrm{avgdl}∣d∣=avgdl), the term weight simplifies to:

TF weight=f⋅(k1+1)f+k1\text{TF weight} = \frac{f \cdot (k_1 + 1)}{f + k_1}TF weight=f+k1​f⋅(k1​+1)​

For k1=1.2k_1 = 1.2k1​=1.2, the upper bound as frequency climbs is k1+1=2.2k_1 + 1 = 2.2k1​+1=2.2. The next lab traces how the curve flattens as word frequency increases from 1 to 20:

see-bm25-saturation.py
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}")
Output
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.125

The first occurrence contributes 1.0001.0001.000. By the twentieth occurrence, the weight has reached 2.0752.0752.075, closing within 0.1250.1250.125 of the theoretical 2.2002.2002.200 ceiling. BM25 prevents keyword stuffing from overwhelming the search results.

Dense retrieval recovers paraphrases

Dense retrieval represents queries and passages as continuous vectors in RD\mathbb{R}^DRD using neural bi-encoders.[5]Reference 5Dense Passage Retrieval for Open-Domain Question Answering.https://arxiv.org/abs/2004.04906

A bi-encoder architecture uses two separate encoder towers: a query encoder EQ(q)E_Q(q)EQ​(q) and a passage encoder ED(d)E_D(d)ED​(d). 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 EQ(q)E_Q(q)EQ​(q) 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:

ItemVectorIntended 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:

cos⁡(q,d)=q⋅d∥q∥2∥d∥2\cos(q, d) = \frac{q \cdot d}{\lVert q \rVert_2 \lVert d \rVert_2}cos(q,d)=∥q∥2​∥d∥2​q⋅d​

For passage d2, the dot product is 1.0(0.9)+0.8(0.9)+0.0(0.0)=1.621.0(0.9) + 0.8(0.9) + 0.0(0.0) = 1.621.0(0.9)+0.8(0.9)+0.0(0.0)=1.62. Dividing by the norms (1.64⋅1.62≈1.2806⋅1.2728≈1.6300\sqrt{1.64} \cdot \sqrt{1.62} \approx 1.2806 \cdot 1.2728 \approx 1.63001.64​⋅1.62​≈1.2806⋅1.2728≈1.6300) gives approximately 0.9940.9940.994. Even though d2 says credential instead of API key, its direction aligns almost perfectly with the query's intent.

rank-with-cosine-similarity.py
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}")
Output
1#1 d2: 0.994 2#2 d1: 0.957 3#3 d4: 0.625 4#4 d3: 0.123

Dense retrieval recovers d2 (0.9940.9940.994) as its top candidate, solving the vocabulary mismatch that caused BM25 to drop it. It also ranks d1 high (0.9570.9570.957) while pushing the quota distractor d4 down to 0.6250.6250.625.

Similarity must match the embedding contract

If vectors are unit-normalized (∥v∥2=1\lVert v \rVert_2 = 1∥v∥2​=1), dot product and cosine similarity produce the exact same rank order because the denominator is always 1.01.01.0.

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:

dot-product-versus-cosine.py
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])
Output
1dot-product winner: large_partial_match 2cosine winner: aligned_paraphrase

The dot product with large_partial_match is 6.0⋅1.0=6.06.0 \cdot 1.0 = 6.06.0⋅1.0=6.0, overpowering aligned_paraphrase (1.641.641.64) 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:

LaneReturned rankingStrengths & weaknesses
BM25 sparsed1, d4Catches api key, misses paraphrase d2
Dense cosined2, d1, d4Catches 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:

  1. Normalized score blending: Rescale BM25 and cosine scores to [0,1][0, 1][0,1] via min-max scaling (snorm=(s−smin⁡)/(smax⁡−smin⁡)s_{\text{norm}} = (s - s_{\min}) / (s_{\max} - s_{\min})snorm​=(s−smin​)/(smax​−smin​)) and compute a weighted sum S=α⋅Sbm25+(1−α)⋅SdenseS = \alpha \cdot S_{\text{bm25}} + (1 - \alpha) \cdot S_{\text{dense}}S=α⋅Sbm25​+(1−α)⋅Sdense​. While this retains relative score margins, it's sensitive to distribution shifts and outlier scores on individual queries.
  2. Reciprocal Rank Fusion (RRF): Ignores score magnitudes entirely and evaluates relevance purely by rank position across result lists.[6]Reference 6Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods.https://dl.acm.org/doi/10.1145/1571941.1572114

The RRF formula for document ddd over ranked lists LLL is:

RRF(d)=∑ℓ∈L1k+rℓ(d)\mathrm{RRF}(d) = \sum_{\ell \in L} \frac{1}{k + r_\ell(d)}RRF(d)=ℓ∈L∑​k+rℓ​(d)1​

Here, rℓ(d)r_\ell(d)rℓ​(d) is the 1-indexed rank of document ddd in list ℓ\ellℓ. If list ℓ\ellℓ didn't return document ddd, that lane contributes zero.

The constant kkk (defaulting to 60 in Lucene, Elasticsearch, and Azure AI Search) acts as a smoothing parameter. For k=60k = 60k=60, rank 1 contributes 1/(60+1)≈0.016391 / (60 + 1) \approx 0.016391/(60+1)≈0.01639, while rank 2 contributes 1/(60+2)≈0.016131 / (60 + 2) \approx 0.016131/(60+2)≈0.01613. The gap between adjacent ranks is small (0.000260.000260.00026). This dampens the impact of extreme outliers and rewards candidates that earn consensus across multiple lanes.

Reciprocal Rank Fusion pipeline combining BM25 sparse and dense cosine retrieval. d1 achieves rank 1 (0.032522) through dual-lane consensus; d4 reaches rank 2 (0.032002); dense-only paraphrase d2 takes rank 3 (0.016393). A top-2 cutoff drops the paraphrase, whereas a top-3 cutoff preserves it for cross-encoder reranking.
No sparse match means no sparse vote. RRF preserves d2 in the union, but ranks it below the quota distractor. A top-2 cutoff drops the paraphrase before reranking; a top-3 cutoff preserves it.

The following function computes RRF rankings for both k=60k = 60k=60 and k=0k = 0k=0:

reciprocal-rank-fusion.py
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}")
Output
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.833333

At k=60k = 60k=60, document d1 wins (0.0325220.0325220.032522) 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 (0.0320020.0320020.032002) beats d2 (0.0163930.0163930.016393), 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:

scorebi-encoder=EQ(q)⋅ED(d)\text{score}_{\text{bi-encoder}} = E_Q(q) \cdot E_D(d)scorebi-encoder​=EQ​(q)⋅ED​(d)

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]Reference 7Passage Re-ranking with BERT.https://arxiv.org/abs/1901.04085 It concatenates the query and document into a single token sequence:

[CLS]∘Query∘[SEP]∘Document[\text{CLS}] \circ \text{Query} \circ [\text{SEP}] \circ \text{Document}[CLS]∘Query∘[SEP]∘Document

The concatenated sequence passes through all transformer layers together. Every query token can attend directly to every document token via full bidirectional self-attention (O((LQ+LD)2)O((L_Q + L_D)^2)O((LQ​+LD​)2) 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:

Diagram showing User Query, BM25 Sparse, Dense ANN, and RRF Rank Fusion.
User Query, BM25 Sparse, Dense ANN, and RRF Rank Fusion.

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:

reranking-cannot-recover-a-missing-candidate.py
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}")
Output
1sparse-only top2: top=d1 recovered_best=False 2hybrid top3 : top=d2 recovered_best=True

When 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 KKK results.
  • Relevance Recall @ K: The fraction of all judged relevant passages for a query that appear in the top KKK results, averaged across the query set.
  • Mean Reciprocal Rank (MRR @ K): The average of 1/r1 / r1/r, where rrr is the position of the first relevant passage retrieved. If no relevant passage appears in the top KKK, the reciprocal rank for that query is 0.[8]Reference 8BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models.https://arxiv.org/abs/2104.08663

The script below evaluates two candidate runs on three judged test queries:

measure-hit-rate-and-mrr.py
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)
Output
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.000

Notice how these metrics capture different behaviors: on query q1, run A achieves a Hit Rate of 1.01.01.0 at K=2K = 2K=2 (because d1 was returned), but its Recall@2 is only 0.50.50.5 because d2 was pushed to rank 3. On query q2, run A gets a Hit Rate of 000 and Recall of 000 at K=2K = 2K=2, but still gets a non-zero reciprocal rank contribution of 1/31/31/3 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 NNN documents in DDD dimensions, exhaustive scanning requires O(N⋅D)O(N \cdot D)O(N⋅D) distance operations per query.

For N=106N = 10^6N=106 passages and D=1536D = 1536D=1536 dimensions, an exact scan performs 1.5×1091.5 \times 10^91.5×109 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]Reference 9Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734

Three indexing families power modern vector databases:

Index FamilyCore MechanismPrimary Tuning KnobsTrade-offs & Risks
IVF (Inverted File Index)[9]Reference 9Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734Partitions space into nlistnlistnlist Voronoi cells via k-means. Queries visit only the nprobenprobenprobe 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]Reference 10Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.https://arxiv.org/abs/1603.09320[11]Reference 11HNSW algorithm parametershttps://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.mdMulti-layer proximity skip-graph. Top sparse layers provide long-range routing; bottom layer contains fine connections.MMM (links per node), efConstructionefConstructionefConstruction (build beam), efSearchefSearchefSearch (query beam)Unmatched recall-latency Pareto frontier; high RAM usage due to storing graph edges in memory.
PQ (Product Quantization)[12]Reference 12Product Quantization for Nearest Neighbor Search.https://dblp.org/rec/journals/pami/JegouDS11Slices DDD-dimensional vectors into MMM subvectors, clustering each into 256 centroids (1 byte each).MMM (subvector count), codebook centroid count K∗K^*K∗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.

One-dimensional IVF partition boundary demonstration: query at 4.9 and key_rotation at 5.1 separated by centroid boundary at 5.0. Fast search with nprobe=1 visits only list 0 and misses the nearest neighbor; nprobe=2 opens both lists to recover key_rotation, matching exact brute-force scan.
The query sits at 4.9, just left of the split at 5.0. An nprobe of 1 never opens the list holding key_rotation at 5.1, skipping the true nearest neighbor. Increasing nprobe to 2 crosses the boundary and restores full recall.

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 0.00.00.0, and centroid 1 at 10.010.010.0. The Voronoi boundary between them falls at 5.05.05.0.

Our target document key_rotation sits at 5.15.15.1 (inside list 1). A developer query arrives at 4.94.94.9. The distance to centroid 0 is ∣4.9−0.0∣=4.9|4.9 - 0.0| = 4.9∣4.9−0.0∣=4.9, while the distance to centroid 1 is ∣4.9−10.0∣=5.1|4.9 - 10.0| = 5.1∣4.9−10.0∣=5.1.

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 0.20.20.2 units away:

ivf-probe-can-miss-a-neighbor.py
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)
Output
1exact nearest: key_rotation 2nprobe=1 result: generic_api 3nprobe=2 result: key_rotation

The 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:

ANN Recall@K=∣Top-Kapprox∩Top-Kexact∣K\text{ANN Recall@K} = \frac{|\text{Top-}K_{\text{approx}} \cap \text{Top-}K_{\text{exact}}|}{K}ANN Recall@K=K∣Top-Kapprox​∩Top-Kexact​∣​

This measures index fidelity, distinct from relevance evaluation. It checks whether the approximate index returns the same mathematical neighbors as brute-force search:

audit-ann-recall-before-launch.py
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.0
Output
1fast setting recall@1: 0.50 2tuned setting recall@1: 1.00 3launch only after checking latency alongside recall

The ann_fast setting cuts latency by skipping neighboring clusters, but drops ANN Recall@1 to 0.500.500.50. The ann_tuned setting inspects adjacent cells, restoring recall to 1.001.001.00.

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 (k1k_1k1​).
  • Score scale distortion: Handled by Reciprocal Rank Fusion (k=60k=60k=60).
  • Shortlist truncation losses: Handled by tuning the first-stage candidate cutoff KKK.
  • 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:

StageDiagnostic QuestionValidation Artifact
Relevance Ground TruthWhich passages actually answer each question?Held-out query-to-passage judgment labels
Sparse BM25 LaneDo exact identifiers, error codes, and flags survive?Lexical Top-K candidate audit report
Dense Bi-Encoder LaneDo 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 RerankerIs the best factual evidence placed at rank 1?Reranked MRR@10 and nDCG@10 metrics
ANN Index TuningDid 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:

  1. In bm25-from-scratch.py, add a fifth document d5: "Rotate an API key safely." and five distractor documents mentioning only API key quota limits. Recalculate document frequencies and IDFs. How do df(rotate)\mathrm{df}(\text{rotate})df(rotate) and df(api)\mathrm{df}(\text{api})df(api) change, and which term gains higher IDF weight?
  2. In reciprocal-rank-fusion.py, introduce a dense-only candidate d5 at dense rank 4. How does d5 score under RRF with k=60k = 60k=60? If downstream reranking takes only the top 3 candidates, does d5 survive?
  3. 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.
  4. Shift the IVF query vector from 4.94.94.9 to 5.25.25.2. Which centroid is probed first under nprobe = 1, and does single-probe search recover key_rotation?

Practice guidance

  1. With 10 total documents, rotate appears once (df=1\mathrm{df} = 1df=1), while api appears in 8 documents (df=8\mathrm{df} = 8df=8). The positive IDF for rotate is ln⁡(1+(10−1+0.5)/(1+0.5))≈1.992\ln(1 + (10 - 1 + 0.5) / (1 + 0.5)) \approx 1.992ln(1+(10−1+0.5)/(1+0.5))≈1.992, while api drops to ln⁡(1+(10−8+0.5)/(8+0.5))≈0.258\ln(1 + (10 - 8 + 0.5) / (8 + 0.5)) \approx 0.258ln(1+(10−8+0.5)/(8+0.5))≈0.258. Rarity amplifies the discriminative power of rotate.
  2. Document d5 receives 1/(60+4)=1/64≈0.0156251 / (60 + 4) = 1/64 \approx 0.0156251/(60+4)=1/64≈0.015625 from dense retrieval and 000 from BM25. It ranks behind d1, d4, and d2. With a top-3 cutoff, d5 is dropped before reranking.
  3. With four queries and the fourth query's first relevant passage at rank 3, Hit Rate@2 and Recall@2 drop to 3/4=0.7503/4 = 0.7503/4=0.750. MRR@3 becomes (1.0+1.0+1.0+1/3)/4≈0.833(1.0 + 1.0 + 1.0 + 1/3) / 4 \approx 0.833(1.0+1.0+1.0+1/3)/4≈0.833.
  4. At 5.25.25.2, centroid 10 (∣5.2−10.0∣=4.8|5.2 - 10.0| = 4.8∣5.2−10.0∣=4.8) is closer than centroid 0 (∣5.2−0.0∣=5.2|5.2 - 0.0| = 5.2∣5.2−0.0∣=5.2). List 1 is selected first, immediately finding key_rotation (∣5.2−5.1∣=0.1|5.2 - 5.1| = 0.1∣5.2−5.1∣=0.1) even with nprobe = 1.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A developer asks, "How do I rotate an API key?" BM25 ranks d4, "API key quota dashboard," above d2, "Generate a new credential before disabling the previous credential." What does this reveal?

Correct answer: BM25 rewards literal query-term overlap, so d4 matches api key while d2 expresses the answer as a paraphrase. The relevance judgment can still favor d2.

BM25 is a lexical ranker. It can prefer a passage with the phrase api key even when that passage addresses quota rather than rotation, while missing a relevant paraphrase that lacks the literal query terms. Judged queries and a dense lane catch that failure.

2.With IDF fixed and the length factor equal to 1, BM25 uses tf weight f * (k1 + 1) / (f + k1). For k1 = 1.2, what happens as api frequency rises from 1 to 20?

Correct answer: The weight rises from 1.000 to about 2.075 and approaches the limit 2.2, so repetition gives diminishing returns.

BM25 term frequency increases with more occurrences, but the denominator also grows, so each extra repetition adds less evidence. With k1 = 1.2, the asymptotic limit is k1 + 1 = 2.2, not an unbounded keyword-stuffing reward.

3.A query vector [1.0, 0.8, 0.0] is compared with d2 [0.9, 0.9, 0.0], a passage that says "new credential" rather than "API key." Why can dense cosine retrieval rank d2 near the top?

Correct answer: Cosine compares vector direction, and d2 points almost the same way as the query, so the paraphrase can score about 0.994 without literal word overlap.

This dense retriever compares independently encoded vectors using cosine, which measures their directional alignment. In the worked vectors, d2 is nearly aligned with the query, so it can be retrieved even though sparse postings for the literal query terms miss it.

4.A query vector is [1.0, 0.8, 0.0]. Candidate A is [1.0, 0.8, 0.0], and candidate B is [6.0, 0.0, 0.0]. If the embedding contract is directional similarity, what failure can raw dot product introduce?

Correct answer: Dot product can rank B first because of its larger norm, while cosine ranks A first because it points in the same direction as the query.

The dot product with B is 6.0, larger than A's 1.64, so magnitude can dominate. Cosine normalizes by vector length, making A the winner because it's exactly aligned with the query. The index metric must match the embedding model's intended scoring contract.

5.A sparse lane returns BM25 scores such as 1.609, while a dense lane returns cosine similarities such as 0.994. Why use reciprocal rank fusion instead of averaging those raw scores?

Correct answer: BM25 and cosine have different score scales, so RRF combines rank positions instead of letting one scoring scale dominate by accident.

BM25 and cosine aren't measurements on the same scale. RRF avoids raw-score calibration by summing reciprocal functions of rank positions across returned lists; if a lane doesn't return a document, that lane contributes no term for it.

6.A first-stage retriever returns only [d1, d4] for a paraphrased credential-rotation query. A cross-encoder reranker would score d2 highest if it received it, but d2 is absent from the candidate list. Why can't reranking recover d2?

Correct answer: A reranker only reorders the candidates it receives, so a missing answer passage can't be promoted by the second stage.

Reranking is a precision pass over a shortlist, not a new exhaustive retrieval step. Candidate recall must be measured before reranking quality because the reranker has no access to passages dropped upstream.

7.For three judged queries, the relevant sets are q1: {d1, d2}, q2: {d2}, and q3: {d4}. A run returns q1: [d1, d4, d2], q2: [d3, d4, d2], and q3: [d4, d1, d2]. Hit Rate @ 2 is the fraction of queries with any relevant passage in the first two results. MRR is the mean reciprocal rank of the first relevant passage. What are the two metrics?

Correct answer: Hit Rate @ 2 = 0.667 and MRR = 0.778

q1 has a relevant passage at rank 1, q2 has its first relevant passage at rank 3, and q3 has a relevant passage at rank 1. Hit Rate @ 2 is therefore 2/3 = 0.667. The reciprocal ranks are 1, 1/3, and 1, so MRR is (1 + 1/3 + 1) / 3 = 0.778.

8.After switching dense retrieval to an ANN index, some correct documentation pages vanish from top 1. The query and document embeddings are unchanged. Which audit isolates whether approximation damaged retrieval?

Correct answer: Compare ANN with exact search on fixed vectors; tune or replace ANN if exact finds what ANN misses.

Exact search over the same vectors is the baseline for ANN recall. When exact retrieval succeeds but ANN fails, the loss comes from approximation choices such as partitions, graph exploration, or quantization rather than from the embedding model's representation.

9.In a corpus, api appears in most passages while rotate appears in only a few. Passages A and B each contain both terms once, but A is much longer. With positive BM25 IDF, k1 > 0, and b > 0, which effect is expected?

Correct answer: The rotate match contributes more because it's rarer, and B receives less length-normalization penalty because it's shorter.

BM25 IDF assigns more weight to terms found in fewer passages, so rotate carries more lexical evidence than the common term api. With equal query-term counts and b > 0, the longer passage has a larger length-normalization term in the denominator, so B is penalized less than A.

9 questions remaining.

Next Step
Continue to Decoding Algorithms

Retrieval selects the evidence. The generator must then choose tokens from its output scores. Compare greedy decoding, sampling, and search while keeping that retrieved context fixed.

PreviousProbabilistic Generative Models
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Introduction to Information Retrieval.

Manning, C. D., Raghavan, P., Schutze, H. · 2008 · Cambridge University Press

https://nlp.stanford.edu/IR-book/

Okapi at TREC-3.

Robertson, S. E., et al. · 1994

https://trec.nist.gov/pubs/trec3/t3_proceedings.html

The Probabilistic Relevance Framework: BM25 and Beyond.

Robertson, S., & Zaragoza, H. · 2009 · Foundations and Trends in Information Retrieval

https://doi.org/10.1561/1500000019

BM25Similarity: Apache Lucene 10.3.1

Apache Lucene · 2025

https://lucene.apache.org/core/10_3_1/core/org/apache/lucene/search/similarities/BM25Similarity.html

Dense Passage Retrieval for Open-Domain Question Answering.

Karpukhin, V., et al. · 2020 · EMNLP 2020

https://arxiv.org/abs/2004.04906

Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods.

Cormack, G. V., Clarke, C. L. A., & Buettcher, S. · 2009 · SIGIR '09

https://dl.acm.org/doi/10.1145/1571941.1572114

Passage Re-ranking with BERT.

Nogueira, R. & Cho, K. · 2019 · arXiv preprint

https://arxiv.org/abs/1901.04085

BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models.

Thakur, N., et al. · 2021 · NeurIPS 2021 Datasets and Benchmarks

https://arxiv.org/abs/2104.08663

Billion-scale similarity search with GPUs.

Johnson, J., Douze, M., & Jégou, H. · 2017 · arXiv preprint

https://arxiv.org/abs/1702.08734

Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.

Malkov, Y. A., & Yashunin, D. A. · 2018 · IEEE Transactions on Pattern Analysis and Machine Intelligence

https://arxiv.org/abs/1603.09320

HNSW algorithm parameters

nmslib/hnswlib Contributors · 2026

https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md

Product Quantization for Nearest Neighbor Search.

Jégou, H., Douze, M., & Schmid, C. · 2011 · IEEE TPAMI 2011

https://dblp.org/rec/journals/pami/JegouDS11

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.