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
LearnApplied LLM EngineeringHybrid Search: Dense + Sparse
🔍MediumRAG & Retrieval

Hybrid Search: Dense + Sparse

Upgrade a permission-safe RAG retriever with BM25, semantic scores, rank fusion, and recall gates for exact codes and paraphrased policy questions.

22 min read
Learning path
Step 72 of 196 in the full curriculum
Production RAG PipelinesReranking and Cross-Encoders for RAG

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Production RAG Pipelines gave policy-answerer-v1 a hard RAG rule: only permitted, current evidence may reach an answer. Its retrieve() function filters first, then counts overlapping tokens. That recovered a question from Luna, an EU support specialist, about stale service-account keys. It missed the paraphrase that chapter already named: "refresh an expired machine credential" when the policy says "stale service-account key rotation."

Pause on that miss. The exact code carries a rare lexical signal; the paraphrase carries meaning without the policy's words. Before naming a search method, predict which signal each lane can recover and which lane will fail.

You'll upgrade only that retrieval lane. Hybrid search runs sparse BM25 and dense semantic search over the same permitted corpus, then Reciprocal Rank Fusion (RRF) merges candidate IDs. You already ranked passages that way in Core Retrieval Algorithms. Here those two lanes sit behind the evidence boundary, with diagnostic code KROT-14 so an exact identifier has an unambiguous expected hit. That retrieval path is policy-answerer-v2. Authorization, freshness, citation, and abstention don't change.

One retriever can't cover both queries

Luna needs the same policy for two different searches:

QueryUseful signalRequired evidence
KROT-14Exact policy codeeu-key-rotation-v2-rule
refresh expired machine credentialMeaning close to "stale service-account key rotation"eu-key-rotation-v2-rule

A word-matching index has a decisive clue for the first query and no shared vocabulary for the second. A semantic encoder can represent the second query near the policy, but an unfamiliar internal identifier may carry little useful semantic signal. Neither failure says one method is bad. They solve different recall problems.

Both lanes have to search the same permitted corpus. Fusion improves recall; it doesn't widen access. Keep that ordering in view as the diagram and code add ranking detail: authorization decides what can be searched, while fusion decides how permitted candidates are ordered.

Diagram showing Luna query, Filter permitted, BM25 sparse, and Dense cosine.
Luna query, Filter permitted, BM25 sparse, and Dense cosine.

The filter is the evidence boundary from the previous lesson. Sparse and dense only see IDs that already passed it.

Why must the permission filter run before both retrieval lanes rather than after fusion?

Answer

A hidden chunk can be highly ranked by either lane. If text or identifiers from that chunk reach fusion, model context, caches, or request logs before filtering, the service has already crossed its evidence boundary.

Recreate the permitted candidate universe

The lab reuses the policy shape from the previous lesson. It adds diagnostic policy code KROT-14 so an exact-identifier query has an unambiguous expected result. Two tempting records remain in storage but must not be searchable for Luna: a superseded revision and a restricted admin-only rule.

Before running the filter, predict the searchable set. The current EU support rule should survive; the old revision should fail freshness, and the admin rule should fail authorization. That set becomes input to both lanes.

permitted-candidates.py
1from __future__ import annotations 2 3from dataclasses import dataclass 4from datetime import date 5from math import log, sqrt 6import re 7 8@dataclass(frozen=True) 9class PolicyChunk: 10 chunk_id: str 11 document_id: str 12 parent_id: str 13 version: str 14 region: str 15 acl_tag: str 16 effective_from: date 17 effective_to: date | None 18 text: str 19 20@dataclass(frozen=True) 21class Caller: 22 actor_id: str 23 region: str 24 acl_tags: frozenset[str] 25 26EVAL_DATE = date(2026, 5, 27) 27LUNA = Caller("luna-48291", "EU", frozenset({"support:eu"})) 28CHUNKS = [ 29 PolicyChunk( 30 "eu-key-rotation-v2-rule", 31 "eu-access", 32 "eu-access-v2", 33 "eu-access/2026-04-01", 34 "EU", 35 "support:eu", 36 date(2026, 4, 1), 37 None, 38 ( 39 "Rule KROT-14. Stale service-account keys qualify for automated " 40 "rotation within 14 days when a risk signal arrives within 48 hours." 41 ), 42 ), 43 PolicyChunk( 44 "eu-key-rotation-v1-rule", 45 "eu-access", 46 "eu-access-v1", 47 "eu-access/2025-02-01", 48 "EU", 49 "support:eu", 50 date(2025, 2, 1), 51 date(2026, 3, 31), 52 "Rule KROT-14. Stale service-account keys require manual rotation within 30 days.", 53 ), 54 PolicyChunk( 55 "restricted-admin-key-rotation", 56 "admin-override-terms", 57 "admin-override-terms", 58 "admin-override/2026-05-01", 59 "EU", 60 "security:admins", 61 date(2026, 5, 1), 62 None, 63 "ADMIN-KROT-1. Security admins may run emergency key rotation.", 64 ), 65 PolicyChunk( 66 "eu-session-timeout-v1-rule", 67 "eu-session", 68 "eu-session-v1", 69 "eu-session/2026-01-03", 70 "EU", 71 "support:eu", 72 date(2026, 1, 3), 73 None, 74 "Idle browser sessions expire after 30 days of inactivity.", 75 ), 76 PolicyChunk( 77 "eu-audit-rebuild-v1", 78 "eu-audit", 79 "eu-audit-rebuild-v1", 80 "eu-audit/2026-02-10", 81 "EU", 82 "support:eu", 83 date(2026, 2, 10), 84 None, 85 "Rule AUD-7. Missing audit-log shards after ingestion failure qualify for replay rebuild.", 86 ), 87] 88 89def is_current(chunk: PolicyChunk, on_date: date) -> bool: 90 return chunk.effective_from <= on_date and ( 91 chunk.effective_to is None or on_date <= chunk.effective_to 92 ) 93 94def permitted_chunks( 95 caller: Caller, 96 chunks: list[PolicyChunk], 97 on_date: date, 98) -> list[PolicyChunk]: 99 return [ 100 chunk 101 for chunk in chunks 102 if chunk.region == caller.region 103 and chunk.acl_tag in caller.acl_tags 104 and is_current(chunk, on_date) 105 ] 106 107permitted = permitted_chunks(LUNA, CHUNKS, EVAL_DATE) 108permitted_ids = [chunk.chunk_id for chunk in permitted] 109print("Permitted current ids:", permitted_ids) 110assert "eu-key-rotation-v2-rule" in permitted_ids 111assert "eu-key-rotation-v1-rule" not in permitted_ids 112assert "restricted-admin-key-rotation" not in permitted_ids
Output
1Permitted current ids: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule', 'eu-audit-rebuild-v1']

Every ranker below receives permitted, not CHUNKS. That API boundary is what stops a new ranking algorithm from widening Luna's access.

The fixed EVAL_DATE keeps replay behavior stable. The chunk shape also preserves document_id and parent_id from the previous lesson, even though ranking changes while citation packing stays the same.

The next check makes the negative cases visible. Look for two IDs that remain stored but never become searchable.

evidence-boundary-regression.py
1blocked_ids = sorted( 2 chunk.chunk_id 3 for chunk in CHUNKS 4 if chunk.chunk_id not in permitted_ids 5) 6print("Searchable by Luna:", permitted_ids) 7print("Stored but blocked:", blocked_ids) 8assert blocked_ids == ["eu-key-rotation-v1-rule", "restricted-admin-key-rotation"]
Output
1Searchable by Luna: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule', 'eu-audit-rebuild-v1'] 2Stored but blocked: ['eu-key-rotation-v1-rule', 'restricted-admin-key-rotation']

This test keeps a deliberately attractive hidden policy in storage. Later ranking changes fail loudly if they accidentally widen the searchable set.

Hybrid request path where Luna's query hits an ACL and freshness filter first. Three permitted chunks fork into a BM25 lane for KROT-14 and a dense lane for the paraphrase, then Reciprocal Rank Fusion merges those permitted IDs. The superseded v1 rule and the admin-only rule leave the filter as a dead end and never enter either lane.
Luna's query hits the ACL and freshness filter first. The three permitted IDs fork into BM25 and dense search; the superseded v1 rule and the admin-only rule leave as a dead end and never reach fusion.

Build the sparse lane with BM25

Sparse retrieval represents a document by vocabulary terms. Most coordinates are zero because a short policy chunk uses only a small part of the vocabulary. You already scored that representation with BM25 in Core Retrieval Algorithms: a chunk ranks higher when it shares rare query terms, with a cap on repeated terms and a penalty for unusually long documents.[1]Reference 1The Probabilistic Relevance Framework: BM25 and Beyond.https://doi.org/10.1561/1500000019

The lab below uses the common Lucene-style IDF form with a leading 1 +, which is the practical formula in many search engines rather than the classic Okapi IDF alone. The previous RAG tokenizer split on non-letters, so KROT-14 became krot and 14. This analyzer keeps hyphenated rule codes as one token.

For a query term ttt and document ddd, the lab computes:

BM25⁡(q,d)=∑t∈qIDF⁡(t)f(t,d)(k1+1)f(t,d)+k1(1−b+b∣d∣/avgdl⁡)\operatorname{BM25}(q,d)=\sum_{t \in q}\operatorname{IDF}(t) \frac{f(t,d)(k_1+1)} {f(t,d)+k_1(1-b+b\lvert d\rvert/\operatorname{avgdl})}BM25(q,d)=t∈q∑​IDF(t)f(t,d)+k1​(1−b+b∣d∣/avgdl)f(t,d)(k1​+1)​

Here, f(t,d)f(t,d)f(t,d) is the term count in the chunk, ∣d∣\lvert d\rvert∣d∣ is chunk length in tokens, and avgdl is the corpus average. k1 controls term-frequency saturation; b controls length normalization. The exact identifier krot-14 occurs only in the relevant current chunk, so it receives strong lexical weight.

The analyzer also drops common function words. Without that stopword rule, a query containing only a shared word such as "a" could appear to retrieve an unrelated policy.

Before running the lane, predict the two outputs. KROT-14 should produce a strong hit because its token is rare; the paraphrase should return nothing because none of its words overlap the policy.

bm25-lane.py
1TOKEN_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") 2STOPWORDS = {"a", "an", "the", "after", "for", "of", "is", "within", "when"} 3 4def tokens(text: str) -> list[str]: 5 return [ 6 token 7 for token in TOKEN_RE.findall(text.lower()) 8 if token not in STOPWORDS 9 ] 10 11def bm25_rank( 12 query: str, 13 chunks: list[PolicyChunk], 14 top_k: int = 2, 15 k1: float = 1.2, 16 b: float = 0.75, 17) -> list[tuple[PolicyChunk, float]]: 18 if top_k <= 0: 19 raise ValueError("top_k must be positive") 20 if not chunks: 21 return [] 22 23 doc_tokens = {chunk.chunk_id: tokens(chunk.text) for chunk in chunks} 24 avgdl = sum(len(value) for value in doc_tokens.values()) / len(chunks) 25 query_terms = tokens(query) 26 ranked: list[tuple[PolicyChunk, float]] = [] 27 28 for chunk in chunks: 29 document = doc_tokens[chunk.chunk_id] 30 score = 0.0 31 for term in query_terms: 32 term_count = document.count(term) 33 if term_count == 0: 34 continue 35 containing_docs = sum(term in value for value in doc_tokens.values()) 36 idf = log(1 + (len(chunks) - containing_docs + 0.5) / (containing_docs + 0.5)) 37 numerator = term_count * (k1 + 1) 38 denominator = term_count + k1 * (1 - b + b * len(document) / avgdl) 39 score += idf * numerator / denominator 40 if score > 0: 41 ranked.append((chunk, score)) 42 43 return sorted(ranked, key=lambda item: (-item[1], item[0].chunk_id))[:top_k] 44 45EXACT = "KROT-14" 46PARAPHRASE = "refresh expired machine credential" 47bm25_exact = bm25_rank(EXACT, permitted) 48bm25_paraphrase = bm25_rank(PARAPHRASE, permitted) 49 50print("BM25 exact:", [chunk.chunk_id for chunk, _ in bm25_exact]) 51print("BM25 paraphrase:", [chunk.chunk_id for chunk, _ in bm25_paraphrase]) 52assert bm25_exact[0][0].chunk_id == "eu-key-rotation-v2-rule" 53assert bm25_paraphrase == []
Output
1BM25 exact: ['eu-key-rotation-v2-rule'] 2BM25 paraphrase: []

BM25 did its job. It recovered the policy from its code and openly failed when the user used no policy vocabulary. A real evaluation set needs both query types; otherwise the lexical lane can look perfect while customers miss evidence.

That failure points to representation, not permissions. The next sparse option changes which terms are indexed, while keeping the same evidence boundary.

BM25 isn't the only sparse option. SPLADE learns sparse expansion weights, so a chunk can gain indexable related terms while preserving sparse retrieval infrastructure.[2]Reference 2SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking.https://arxiv.org/abs/2107.05720 That can improve vocabulary mismatch cases, but it doesn't turn sparse retrieval into an authorization layer or guarantee better recall on security-policy queries. Evaluate a SPLADE candidate against the same permitted corpus, held-out required-evidence IDs, latency budget, and hidden-source exclusions before replacing BM25.

diagnose-sparse-miss.py
1required_text = next( 2 chunk.text for chunk in permitted if chunk.chunk_id == "eu-key-rotation-v2-rule" 3) 4exact_overlap = sorted(set(tokens(EXACT)) & set(tokens(required_text))) 5paraphrase_overlap = sorted(set(tokens(PARAPHRASE)) & set(tokens(required_text))) 6print("Exact overlap:", exact_overlap) 7print("Paraphrase overlap:", paraphrase_overlap) 8assert exact_overlap == ["krot-14"] 9assert paraphrase_overlap == []
Output
1Exact overlap: ['krot-14'] 2Paraphrase overlap: []

If BM25 returns no result for "refresh expired machine credential," should you lower an authorization or freshness filter?

Answer

No. The evidence boundary is not a recall knob. Add a semantic retrieval lane or improve the searchable representation, then test the change inside the same permission and version constraints.

Add a dense semantic lane

A dense retriever encodes queries and chunks as compact vectors, then retrieves chunks with high similarity. Dense Passage Retrieval (DPR), for example, uses separate encoders for questions and passages so passage representations can be indexed before requests arrive.[3]Reference 3Dense Passage Retrieval for Open-Domain Question Answering.https://arxiv.org/abs/2004.04906 You used the same cosine-ranking idea in Core Retrieval Algorithms. The new constraint is that those vectors are only allowed for permitted, current chunks.

Downloading and training an encoder would hide the retrieval mechanics in this lab. Instead, the next cell uses frozen three-dimensional vectors as test fixtures. Read them as outputs already produced by an embedding model:

DimensionMeaning in this fixture
1Key-rotation intent
2Session timeout intent
3Audit-log rebuild intent

This fixture is deliberately honest about one failure: the internal code KROT-14 has no semantic vector by itself. The paraphrase does.

The lab ranks those vectors with cosine similarity. Vectors pointing in a similar direction score closer to 1; their raw length doesn't decide the result.

Predict the dense lane before inspecting its output: the zero vector for KROT-14 should produce no hit, while the paraphrase should point most strongly at the key-rotation vector.

dense-lane.py
1Vector = tuple[float, float, float] 2 3DOCUMENT_VECTORS: dict[str, Vector] = { 4 "eu-key-rotation-v2-rule": (1.00, 0.00, 0.00), 5 "eu-session-timeout-v1-rule": (0.00, 1.00, 0.00), 6 "eu-audit-rebuild-v1": (0.00, 0.00, 1.00), 7} 8QUERY_VECTORS: dict[str, Vector] = { 9 EXACT: (0.00, 0.00, 0.00), 10 PARAPHRASE: (0.98, 0.05, 0.00), 11 "stale service-account key rotation within 14 days": (0.96, 0.15, 0.02), 12} 13 14def cosine(left: Vector, right: Vector) -> float: 15 left_norm = sqrt(sum(value * value for value in left)) 16 right_norm = sqrt(sum(value * value for value in right)) 17 if left_norm == 0 or right_norm == 0: 18 return 0.0 19 return sum(a * b for a, b in zip(left, right)) / (left_norm * right_norm) 20 21def dense_rank( 22 query: str, 23 chunks: list[PolicyChunk], 24 top_k: int = 2, 25) -> list[tuple[PolicyChunk, float]]: 26 query_vector = QUERY_VECTORS.get(query, (0.0, 0.0, 0.0)) 27 ranked = [ 28 (chunk, cosine(query_vector, DOCUMENT_VECTORS[chunk.chunk_id])) 29 for chunk in chunks 30 ] 31 return sorted( 32 [(chunk, score) for chunk, score in ranked if score > 0], 33 key=lambda item: (-item[1], item[0].chunk_id), 34 )[:top_k] 35 36dense_exact = dense_rank(EXACT, permitted) 37dense_paraphrase = dense_rank(PARAPHRASE, permitted) 38print("Dense exact:", [chunk.chunk_id for chunk, _ in dense_exact]) 39print("Dense paraphrase:", [chunk.chunk_id for chunk, _ in dense_paraphrase]) 40assert dense_exact == [] 41assert dense_paraphrase[0][0].chunk_id == "eu-key-rotation-v2-rule"
Output
1Dense exact: [] 2Dense paraphrase: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule']

The fixture doesn't claim that every production encoder misses every identifier. It establishes a regression case: this chosen encoder representation doesn't recover the code-only query, so deleting the sparse lane would fail a known requirement.

Fuse candidates without mixing score scales

You already fused independent rank lists with Reciprocal Rank Fusion in Core Retrieval Algorithms. The same reason applies here: BM25 scores and cosine similarities don't share units. A BM25 value reflects term statistics in this index; a cosine value reflects vector alignment. Adding raw values can let whichever scale is numerically larger control the order.

RRF avoids that comparison. It contributes 1/(k+r)1 / (k + r)1/(k+r) for each rank rrr at which a chunk appears:

RRF⁡(d)=∑lane l1k+rank⁡l(d)\operatorname{RRF}(d)=\sum_{\text{lane } l}\frac{1}{k+\operatorname{rank}_l(d)}RRF(d)=lane l∑​k+rankl​(d)1​

Cormack, Clarke, and Buettcher fixed k=60 in a pilot and found it near-optimal, though the choice wasn't critical in that region.[4]Reference 4Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods.https://dl.acm.org/doi/10.1145/1571941.1572114 Treat it as a starting value, not a universal optimum. A chunk found by both lanes gains two contributions; a strong result found by one lane remains eligible.

For the shared-language query, eu-key-rotation-v2-rule ranks first in both lanes, so its fused score is 1 / 61 + 1 / 61 = 0.0328. The session-timeout distractor ranks second in both lanes, so its score is 1 / 62 + 1 / 62 = 0.0323. These values aren't probabilities. They're rank-based scores used to order the fused candidate set.

Now predict the failure case before running fusion code. A document that appears second in both lanes earns 2/62; a document that appears first in only one lane earns 1/61. The repeated mid-ranked hit can win, so the final context budget must be tested after fusion.

rrf-fusion.py
1RRF_K = 60 2 3def reciprocal_rank_fusion( 4 result_lists: list[list[tuple[PolicyChunk, float]]], 5 k: int = RRF_K, 6) -> list[tuple[PolicyChunk, float]]: 7 if k <= 0: 8 raise ValueError("k must be positive") 9 by_id: dict[str, PolicyChunk] = {} 10 scores: dict[str, float] = {} 11 for results in result_lists: 12 for rank, (chunk, _raw_score) in enumerate(results, start=1): 13 by_id[chunk.chunk_id] = chunk 14 scores[chunk.chunk_id] = scores.get(chunk.chunk_id, 0.0) + 1 / (k + rank) 15 return sorted( 16 [(by_id[chunk_id], score) for chunk_id, score in scores.items()], 17 key=lambda item: (-item[1], item[0].chunk_id), 18 ) 19 20def hybrid_rank( 21 query: str, 22 caller: Caller, 23 chunks: list[PolicyChunk], 24 top_k: int = 2, 25 candidate_pool: int | None = None, 26) -> list[tuple[PolicyChunk, float]]: 27 searchable = permitted_chunks(caller, chunks, EVAL_DATE) 28 # Fuse from a deeper per-lane pool than the final context budget. 29 pool = candidate_pool if candidate_pool is not None else max(50, top_k) 30 pool = min(pool, len(searchable)) if searchable else top_k 31 fused = reciprocal_rank_fusion( 32 [bm25_rank(query, searchable, pool), dense_rank(query, searchable, pool)] 33 ) 34 return fused[:top_k] 35 36SHARED_WORDS = "stale service-account key rotation within 14 days" 37for query in [EXACT, PARAPHRASE, SHARED_WORDS]: 38 hits = hybrid_rank(query, LUNA, CHUNKS) 39 print(query, "->", [chunk.chunk_id for chunk, _ in hits]) 40 41shared_fused = hybrid_rank(SHARED_WORDS, LUNA, CHUNKS) 42assert shared_fused[0][0].chunk_id == "eu-key-rotation-v2-rule" 43assert shared_fused[0][1] == 2 / 61
Output
1KROT-14 -> ['eu-key-rotation-v2-rule'] 2refresh expired machine credential -> ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule'] 3stale service-account key rotation within 14 days -> ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule']

RRF doesn't manufacture relevance. It makes the two candidate sources interoperable. If both lanes miss the right chunk, a fused list will still be wrong.

Don't set each lane's depth equal to the final context budget. If top_k is 2 and each lane only returns 2 hits, mid-ranked evidence never meets in fusion. Retrieve a deeper candidate pool per lane, fuse, then keep top_k.

A dual-listed distractor can still outrank a correct one-lane hit after fusion. Before reading the table, compare 2/62 with 1/61 and predict where the correct one-lane hit lands. Gate on Recall@k after fusion alongside per-lane diagnostics:

ChunkSparse rankDense rankRRF
key-rotation (correct)1absent1/611/611/61
session-timeout (distractor)222/622/622/62
audit-rebuild (noise)absent11/611/611/61

2/62>1/612/62 > 1/612/62>1/61, so the distractor wins. The correct chunk ties the dense-only noise at 1/611/611/61 and loses the identifier tie-break, falling to third.

fusion-depth-pathology.py
1# Toy ranks (not the live BM25/dense indexes): correct evidence is sparse-only 2# rank 1; a dual-listed distractor is rank 2 in both lanes. 3correct = next(c for c in permitted if c.chunk_id == "eu-key-rotation-v2-rule") 4distractor = next(c for c in permitted if c.chunk_id == "eu-session-timeout-v1-rule") 5noise = next(c for c in permitted if c.chunk_id == "eu-audit-rebuild-v1") 6 7sparse_only_lists = [ 8 [(correct, 8.0), (distractor, 3.0)], # correct r1, distractor r2 9 [(noise, 0.9), (distractor, 0.85)], # dense: noise r1, distractor r2; correct absent 10] 11fused_full = reciprocal_rank_fusion(sparse_only_lists) 12fused_ids = [chunk.chunk_id for chunk, _ in fused_full] 13top2_ids = fused_ids[:2] 14 15# Dual-list distractor: 1/(60+2) + 1/(60+2) = 2/62 16# Sparse-only correct: 1/(60+1) = 1/61 17# Dense-only noise: 1/(60+1) = 1/61 18# 2/62 > 1/61, so the distractor wins fusion; correct ties noise and loses the id break. 19assert fused_ids[0] == "eu-session-timeout-v1-rule" 20assert abs(fused_full[0][1] - 2 / 62) < 1e-12 21assert abs(next(s for c, s in fused_full if c.chunk_id == correct.chunk_id) - 1 / 61) < 1e-12 22 23recall_after_fusion_at_1 = int(correct.chunk_id in fused_ids[:1]) 24recall_after_fusion_at_2 = int(correct.chunk_id in top2_ids) 25print("Fused order:", fused_ids) 26print("Recall@1 after fusion:", recall_after_fusion_at_1) 27print("Recall@2 after fusion:", recall_after_fusion_at_2) 28assert recall_after_fusion_at_1 == 0 29assert recall_after_fusion_at_2 == 0 # buried past dual-list distractor + dense-only noise 30assert correct.chunk_id in fused_ids # still present deeper in the fused pool
Output
1Fused order: ['eu-session-timeout-v1-rule', 'eu-audit-rebuild-v1', 'eu-key-rotation-v2-rule'] 2Recall@1 after fusion: 0 3Recall@2 after fusion: 0

If the generator only packs top_k=2, the correct policy is present in a sparse lane but absent from context. That is a fusion-depth failure, not a permission failure. Gate on Recall@k after fusion, raise fused top_k, deepen the weak lane, or pass the fuller fused candidate set to a later precision stage before claiming retrieval succeeded.

Reciprocal Rank Fusion pathology for k=60: sparse ranks key-rotation first and session-timeout second, dense ranks audit-rebuild first and session-timeout second, and the fused bars show session-timeout at 2/62 beating both one-lane winners at 1/61, so the correct policy falls to third.
Session-timeout appears at rank 2 in both lists, so it receives 2/62. The correct key-rotation rule is rank 1 in only one list, so it receives 1/61 and falls behind.

Why shouldn't you add a BM25 score directly to a cosine-similarity score?

Answer

They don't share units or a common numeric scale. A large BM25 value can dominate a cosine value even when that isn't the ranking behavior you want. RRF combines rank positions instead; normalized weighted fusion is another option when you tune it on labeled queries.

Should you tune weights instead?

RRF is a good first implementation because it doesn't require calibrating unrelated score scales. It isn't an automatic winner. Bruch, Gai, and Ingber found RRF sensitive to its parameters, and that a tuned convex combination of normalized lane scores outperformed it in their in-domain and out-of-domain tests. That comparison was sample-efficient: a small labeled query set was enough to tune the mix.[5]Reference 5An Analysis of Fusion Functions for Hybrid Retrieval.https://arxiv.org/abs/2210.11934

score⁡hybrid(d)=αscore⁡~dense(d)+(1−α)score⁡~sparse(d)\operatorname{score}_{\text{hybrid}}(d) =\alpha\widetilde{\operatorname{score}}_{\text{dense}}(d) +(1-\alpha)\widetilde{\operatorname{score}}_{\text{sparse}}(d)scorehybrid​(d)=αscoredense​(d)+(1−α)scoresparse​(d)

The tildes denote scores normalized within their lanes before fusion. That comparison is an evaluation task, not a reason to guess an alpha in production. Keep a fixed held-out split, version the encoder and index, report Recall@k and latency for every candidate, and retain RRF if a tuned method doesn't hold up out of sample.

Raw 0.7 * cosine + 0.3 * BM25 is invalid without lane normalization. The next cell shows why magnitudes dominate weights, then min-max normalizes each lane before the same convex mix:

Predict the raw outcome first: even a 0.3 BM25 weight can dominate a 0.7 cosine weight when BM25 is several times larger. Normalization changes the units, not the evidence boundary.

lane-normalized-weighted-fusion.py
1def min_max(scores: dict[str, float]) -> dict[str, float]: 2 values = list(scores.values()) 3 low, high = min(values), max(values) 4 if high == low: 5 return {key: 1.0 for key in scores} 6 return {key: (value - low) / (high - low) for key, value in scores.items()} 7 8def score_map( 9 ranked: list[tuple[PolicyChunk, float]], 10 chunk_ids: list[str], 11) -> dict[str, float]: 12 by_id = {chunk.chunk_id: score for chunk, score in ranked} 13 return {chunk_id: by_id.get(chunk_id, 0.0) for chunk_id in chunk_ids} 14 15lane_ids = [chunk.chunk_id for chunk in permitted] 16bm25_raw = score_map(bm25_rank(SHARED_WORDS, permitted, top_k=3), lane_ids) 17dense_raw = score_map(dense_rank(SHARED_WORDS, permitted, top_k=3), lane_ids) 18alpha = 0.7 19krot = "eu-key-rotation-v2-rule" 20 21raw_weighted = { 22 chunk_id: alpha * dense_raw[chunk_id] + (1 - alpha) * bm25_raw[chunk_id] 23 for chunk_id in bm25_raw 24} 25# Before normalization, the BM25 term is still larger than the dense term. 26assert (1 - alpha) * bm25_raw[krot] > alpha * dense_raw[krot] 27 28bm25_n = min_max(bm25_raw) 29dense_n = min_max(dense_raw) 30normalized_weighted = { 31 chunk_id: alpha * dense_n[chunk_id] + (1 - alpha) * bm25_n[chunk_id] 32 for chunk_id in bm25_raw 33} 34weighted_order = sorted( 35 normalized_weighted, 36 key=lambda chunk_id: (-normalized_weighted[chunk_id], chunk_id), 37) 38rrf_order = [ 39 chunk.chunk_id 40 for chunk, _ in reciprocal_rank_fusion( 41 [ 42 bm25_rank(SHARED_WORDS, permitted, top_k=3), 43 dense_rank(SHARED_WORDS, permitted, top_k=3), 44 ] 45 ) 46] 47print("Raw BM25:", {k: round(v, 3) for k, v in bm25_raw.items()}) 48print("Raw dense:", {k: round(v, 3) for k, v in dense_raw.items()}) 49print("Raw weighted (invalid units):", {k: round(v, 3) for k, v in raw_weighted.items()}) 50print("Lane-normalized weighted order:", weighted_order) 51print("RRF order:", rrf_order) 52assert weighted_order[0] == krot 53assert rrf_order[0] == krot
Output
1Raw BM25: {'eu-key-rotation-v2-rule': 3.767, 'eu-session-timeout-v1-rule': 0.547, 'eu-audit-rebuild-v1': 0.0} 2Raw dense: {'eu-key-rotation-v2-rule': 0.988, 'eu-session-timeout-v1-rule': 0.154, 'eu-audit-rebuild-v1': 0.021} 3Raw weighted (invalid units): {'eu-key-rotation-v2-rule': 1.822, 'eu-session-timeout-v1-rule': 0.272, 'eu-audit-rebuild-v1': 0.014} 4Lane-normalized weighted order: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule', 'eu-audit-rebuild-v1'] 5RRF order: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule', 'eu-audit-rebuild-v1']

Both calibrated methods agree on this toy query. Agreement on one toy query doesn't license an untuned alpha; it only demonstrates that lane scales must be made comparable before weighted fusion becomes a candidate against RRF.

Weighted fusion still only ranks IDs that already passed the filter. The next test makes that boundary fail loudly.

Attack the evidence boundary through fusion

The stored admin-only policy contains a unique code. If the permission boundary moved after retrieval, BM25 would have an easy hidden hit to surface. A hybrid implementation must return nothing for Luna's request for that code.

Predict the safe output before the attack test runs: an empty result for Luna is success, not a retrieval miss. The hidden code must never enter either lane.

hidden-source-attack.py
1NO_ACCESS = Caller("visitor-9000", "APAC", frozenset()) 2attack_hits = hybrid_rank("ADMIN-KROT-1", LUNA, CHUNKS) 3attack_ids = [chunk.chunk_id for chunk, _ in attack_hits] 4no_access_hits = hybrid_rank(EXACT, NO_ACCESS, CHUNKS) 5print("Visible candidates for hidden code:", attack_ids) 6print("Visible candidates without corpus access:", no_access_hits) 7assert "restricted-admin-key-rotation" not in attack_ids 8assert attack_ids == [] 9assert no_access_hits == []
Output
1Visible candidates for hidden code: [] 2Visible candidates without corpus access: []

Gate the retriever on recall and safety

In the previous lesson, answer quality depended on retrieving current permitted evidence. That means the retrieval upgrade needs its own release cases before you measure generated text.

Recall@2 answers a narrow question: for each supported query, did the correct permitted chunk appear in the first two candidates? It doesn't say whether the evidence order is perfect or whether the final answer is faithful. Those are later checks. Here, recall exposes whether the generator even gets a chance to see the right policy.

Before reading the matrix, predict its shape. BM25 should miss the paraphrase, dense should miss KROT-14, and hybrid should cover all three frozen positives while keeping the two excluded IDs out.

Recall matrix for three frozen queries across BM25, dense, and hybrid columns. BM25 misses the paraphrase, dense misses KROT-14, and hybrid hits all three. Below the matrix, restricted-admin-key-rotation and eu-key-rotation-v1-rule stay excluded.
Each column is a retriever. Hybrid is the only column with three hits. The two excluded IDs below are safety checks, not recall wins.
retrieval-release-gate.py
1@dataclass(frozen=True) 2class RetrievalCase: 3 name: str 4 query: str 5 expected_chunk_id: str 6 7CASES = [ 8 RetrievalCase("exact-code", EXACT, "eu-key-rotation-v2-rule"), 9 RetrievalCase("paraphrase", PARAPHRASE, "eu-key-rotation-v2-rule"), 10 RetrievalCase("shared-language", SHARED_WORDS, "eu-key-rotation-v2-rule"), 11] 12 13def recall_at_2(rank_fn) -> float: 14 recovered = 0 15 for case in CASES: 16 ids = [chunk.chunk_id for chunk, _ in rank_fn(case.query)] 17 recovered += case.expected_chunk_id in ids[:2] 18 return recovered / len(CASES) 19 20bm25_recall = recall_at_2(lambda query: bm25_rank(query, permitted)) 21dense_recall = recall_at_2(lambda query: dense_rank(query, permitted)) 22hybrid_recall = recall_at_2(lambda query: hybrid_rank(query, LUNA, CHUNKS)) 23 24restricted_attack = hybrid_rank("ADMIN-KROT-1", LUNA, CHUNKS) 25superseded_attack = hybrid_rank( 26 "KROT-14 key rotation manual 30 days", 27 LUNA, 28 CHUNKS, 29) 30restricted_ids = [chunk.chunk_id for chunk, _ in restricted_attack] 31superseded_ids = [chunk.chunk_id for chunk, _ in superseded_attack] 32safety_pass = ( 33 "restricted-admin-key-rotation" not in restricted_ids 34 and "eu-key-rotation-v1-rule" not in superseded_ids 35) 36 37print(f"BM25 Recall@2: {bm25_recall:.2f}") 38print(f"Dense Recall@2: {dense_recall:.2f}") 39print(f"Hybrid RRF Recall@2: {hybrid_recall:.2f}") 40print("Restricted attack ids:", restricted_ids) 41print("Superseded attack ids:", superseded_ids) 42print("Safety gate:", safety_pass) 43assert hybrid_recall == 1.0 44assert hybrid_recall > bm25_recall 45assert hybrid_recall > dense_recall 46assert restricted_ids == [] 47assert "eu-key-rotation-v1-rule" not in superseded_ids 48assert safety_pass
Output
1BM25 Recall@2: 0.67 2Dense Recall@2: 0.67 3Hybrid RRF Recall@2: 1.00 4Restricted attack ids: [] 5Superseded attack ids: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule'] 6Safety gate: True

These three fixtures demonstrate complementary failures; they don't prove an offline lift for a production corpus. A release decision needs a held-out set drawn from real support requests, including exact codes, paraphrases, unsupported questions, languages served by the product, and attempts to request hidden policies.

The same evaluation must retain workload and system context: corpus and index versions, encoder and tokenizer, lane depths, fusion settings, latency percentiles, and the correctness or support check used for answers. Recall numbers without those details are hard to reproduce or compare.

GateWhat to freezeFailure meaning
Permitted Recall@kQuery and required current chunk IDCorrect evidence never reaches context selection
Restricted-source exclusionQueries that strongly match hidden chunksRetriever boundary is unsafe
Superseded-source exclusionQueries matching old policy wordingFreshness filter regressed
Abstention casesQuestions with no permitted supporting evidenceRetrieval or answer layer overreaches

Hybrid reaches Recall@2 of 1.00 on these three labeled queries. Can you say hybrid search is better for all security-policy queries?

Answer

No. You can say it passes the three frozen cases, including two complementary single-lane failures, while preserving the safety boundary. Broader claims require a representative held-out query set and latency measurements.

Trace each lane before adding reranking

When a final answer is wrong, you need to tell apart three failures:

FailureTrace evidenceNext repair
Retrieval missExpected chunk absent from sparse, dense, and fused candidatesImprove indexing, encoder, query handling, or fusion
Fusion ordering issueExpected chunk exists in a lane but falls below context budgetTune fusion on held-out labels
Later precision issueCorrect chunk is in fused candidates but distractors rank above itAdd and evaluate the reranking stage in the next lesson

Store IDs, ranks, model and index versions, fusion settings, and timing in a trace. Don't log policy text in a broad diagnostic event.

When the trace below appears, predict the repair before inspecting the final prose: an empty sparse list with the right dense hit is a lane-specific miss, while an extra permitted distractor points toward later precision work.

hybrid-retrieval-trace.py
1def trace_hybrid_request( 2 query: str, 3 query_kind: str, 4 caller: Caller, 5) -> dict[str, object]: 6 searchable = permitted_chunks(caller, CHUNKS, EVAL_DATE) 7 sparse = bm25_rank(query, searchable) 8 dense = dense_rank(query, searchable) 9 fused = reciprocal_rank_fusion([sparse, dense]) 10 return { 11 "versions": { 12 "retriever": "policy-retriever-v2", 13 "index": "policy-index/2026-05-27", 14 "sparse": "bm25-tokenizer-v1", 15 "dense": "fixture-embeddings-v1", 16 "fusion": f"rrf-k{RRF_K}", 17 }, 18 "query_kind": query_kind, 19 "sparse_ids": [chunk.chunk_id for chunk, _ in sparse], 20 "dense_ids": [chunk.chunk_id for chunk, _ in dense], 21 "fused_ids": [chunk.chunk_id for chunk, _ in fused[:2]], 22 "timings_ms": {"authorize": 2, "bm25": 4, "dense": 11, "fusion": 1}, 23 } 24 25trace = trace_hybrid_request(PARAPHRASE, "paraphrase-regression", LUNA) 26stores_raw_policy_text = any( 27 chunk.text in str(trace) 28 for chunk in CHUNKS 29) 30print("Versions:", trace["versions"]) 31print("Sparse ids:", trace["sparse_ids"]) 32print("Dense ids:", trace["dense_ids"]) 33print("Fused ids:", trace["fused_ids"]) 34print("Trace stores raw policy text:", stores_raw_policy_text) 35assert trace["fused_ids"][0] == "eu-key-rotation-v2-rule" 36assert "eu-session-timeout-v1-rule" in trace["fused_ids"] 37assert "restricted-admin-key-rotation" not in str(trace) 38assert not stores_raw_policy_text
Output
1Versions: {'retriever': 'policy-retriever-v2', 'index': 'policy-index/2026-05-27', 'sparse': 'bm25-tokenizer-v1', 'dense': 'fixture-embeddings-v1', 'fusion': 'rrf-k60'} 2Sparse ids: [] 3Dense ids: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule'] 4Fused ids: ['eu-key-rotation-v2-rule', 'eu-session-timeout-v1-rule'] 5Trace stores raw policy text: False

The correct evidence is present, but the semantic lane also kept a session-timeout distractor. That's the boundary between retrieval and reranking: retrieval satisfies candidate recall; a reranker decides whether a distractor should remain near context. The trace can also preserve the latency budget created in the production RAG lesson. Two retrieval lanes add work, so the release check should report that cost explicitly.

The next check treats missing timing as a failure of observability, even when retrieval is correct. A route can't pass a latency gate when one stage never reports its elapsed time.

If a context budget is being wasted by several near-duplicate candidates, Maximal Marginal Relevance (MMR) is one selection strategy: choose a relevant result while penalizing candidates too similar to what has already been selected.[6]Reference 6The Use of MMR, Diversity-Based Reranking for Reordering Documents and Producing Summaries.https://dl.acm.org/doi/10.1145/290941.291025 MMR handles diversity in an existing permitted candidate set. It doesn't retrieve a missing policy and doesn't replace a cross-encoder that must compare query relevance precisely.

retrieval-latency-gate.py
1RETRIEVAL_BUDGET_MS = {"authorize": 10, "bm25": 12, "dense": 40, "fusion": 8} 2 3def exceeded_retrieval_budgets(timings: dict[str, int]) -> list[str]: 4 return [ 5 stage 6 for stage, budget in RETRIEVAL_BUDGET_MS.items() 7 if stage not in timings or timings[stage] > budget 8 ] 9 10healthy = trace["timings_ms"] 11missing_dense = { 12 stage: elapsed 13 for stage, elapsed in healthy.items() 14 if stage != "dense" 15} 16print("Healthy exceeded:", exceeded_retrieval_budgets(healthy)) 17print("Missing timing exceeded:", exceeded_retrieval_budgets(missing_dense)) 18assert exceeded_retrieval_budgets(healthy) == [] 19assert exceeded_retrieval_budgets(missing_dense) == ["dense"]
Output
1Healthy exceeded: [] 2Missing timing exceeded: ['dense']

The paraphrase trace still carries eu-session-timeout-v1-rule next to the right policy. Recall succeeded; precision is the next job.

Complete the lesson

Mastery Check

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

1.A policy search must recover both KROT-14 and the paraphrase refresh expired machine credential. BM25 finds only the code, while the chosen dense encoder finds only the paraphrase. Which design meets both requirements without weakening evidence controls?

Correct answer: Run BM25 and dense search over the same permitted current chunks, then fuse their ranked candidate IDs.

The two lanes cover complementary misses: BM25 preserves the rare identifier signal, while dense retrieval supplies semantic recall for the vocabulary mismatch. Both must receive the already filtered corpus because fusion changes ranking, not authorization or freshness.

2.A developer retrieves and fuses across all stored policies, then removes restricted and superseded chunks before prompt construction. Which review finding is decisive?

Correct answer: Reject the design because hidden content can influence ranks, identifiers, caches, or traces before the late filter runs.

Evidence becomes observable before generation. Once a blocked chunk participates in either lane or fusion, its identity or derived signals can escape through diagnostics and intermediate state. Authorization and freshness must constrain both lanes before scoring.

3.The BM25 analyzer preserves krot-14 as one token. Only the current permitted key-rotation rule contains it. Why should that rule lead the lexical ranking?

Correct answer: Its matching rare token contributes positive IDF and term-frequency weight, while nonmatching permitted chunks contribute none.

BM25 scores lexical evidence. Preserving the identifier lets its unique occurrence earn a rare-term contribution. Freshness was enforced before ranking, and cosine geometry belongs to the separate dense lane.

4.With RRF k = 60, a correct policy appears at sparse rank 1 and is absent from dense results. A distractor appears at rank 2 in both lanes. Which fused outcome follows?

Correct answer: The distractor wins with 2/62 over 1/61, so the correct one-lane hit may fall outside a small fused budget.

RRF sums one reciprocal-rank contribution per lane. Dual-list evidence can therefore outrank a stronger single-lane hit. This is a fusion-ordering and depth failure, which must be measured after fusion rather than hidden by per-lane recall.

5.A team sets each lane's candidate depth equal to final top_k = 2. The required chunk ranks fourth in both lanes and would gain from two RRF contributions. What change addresses the failure?

Correct answer: Retrieve deeper per-lane pools, fuse those ranks, then apply the final context or reranking budget.

Fusion can score only candidates supplied by a lane. Cutting each list to the final prompt budget removes mid-ranked evidence before it can accumulate support across lanes. Candidate depth and final context size are separate controls.

6.A weighted fusion proposal uses 0.7 * cosine + 0.3 * BM25 on raw scores and chooses 0.7 on the same queries used for its final report. Which evaluation plan is sound?

Correct answer: Normalize or calibrate lane scores, tune the weight on development labels, and compare against RRF on fixed held-out recall and latency.

BM25 and cosine magnitudes have different meanings, so coefficients don't make raw values comparable. Tuning and final evaluation also need separate data. RRF remains a valid baseline until normalized weighted fusion wins out of sample within serving constraints.

7.On three frozen cases, BM25 Recall@2 is 0.67, dense Recall@2 is 0.67, and hybrid Recall@2 is 1.00. Restricted and superseded source checks also pass. What claim can the release report defend?

Correct answer: Hybrid repaired the frozen misses and preserved tested exclusions; broader release claims require representative held-out slices and timing.

The fixture proves only its labeled regressions and explicit safety checks. Production evidence must cover realistic query types, unsupported and adversarial cases, languages, post-fusion recall, and latency. Retrieval recall also says nothing about answer faithfulness.

8.A paraphrase trace has no sparse hits, dense and fused lists both contain the required rule followed by a session-timeout distractor, and no blocked IDs appear. Where should the next repair focus?

Correct answer: Improve later precision or reranking because retrieval recovered the target but kept a nearby irrelevant candidate.

The target reached fused candidates, so candidate recall succeeded. The extra session-timeout result is a top-of-list precision problem. Lane-specific IDs in the trace distinguish that case from a retrieval miss or an evidence-boundary failure.

9.A fused candidate set contains the required policy plus four near-duplicate excerpts, while another query's required policy is absent from every lane. Which use of MMR respects its actual role?

Correct answer: Apply MMR to diversify already permitted candidates, but repair retrieval for the query missing its required policy.

MMR can spend a limited context budget on less redundant members of an existing candidate set. It cannot invent an absent candidate, authorize a hidden source, calibrate fusion scales, or substitute novelty for precise query relevance.

9 questions remaining.

Next Step
Continue to Reranking and Cross-Encoders for RAG

Hybrid retrieval can now recover `KROT-14` and the credential paraphrase into a measured permitted set, but the paraphrase trace still keeps `eu-session-timeout-v1-rule` beside the right policy. Next you'll add a slower precision stage that reorders that shortlist before it enters generator context.

PreviousProduction RAG Pipelines
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

The Probabilistic Relevance Framework: BM25 and Beyond.

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

https://doi.org/10.1561/1500000019

SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking.

Formal, T., et al. · 2021 · SIGIR 2021

https://arxiv.org/abs/2107.05720

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

An Analysis of Fusion Functions for Hybrid Retrieval.

Bruch, S., Gai, S., & Ingber, A. · 2023 · ACM Transactions on Information Systems

https://arxiv.org/abs/2210.11934

The Use of MMR, Diversity-Based Reranking for Reordering Documents and Producing Summaries.

Carbonell, J., & Goldstein, J. · 1998 · SIGIR 1998

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

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.