TAI BUI
Back to curriculum
BuildPython

Advanced RAG (Chunking, Reranking, Hybrid Search)

Basic RAG retrieves the top-k most similar chunks. That works for simple questions. It falls apart for multi-hop reasoning, ambiguous queries, and large corpora. Advanced RAG is the difference between a demo that works on 10 documents and a system that works on 10 million.

TIME: ~90 minutesPREREQUISITES: Phase 11, Lesson 06 (RAG)

Learning Objectives

  • Build a BM25 keyword index and fuse its ranking with vector search using Reciprocal Rank Fusion
  • Rerank a candidate set and explain why a cross-encoder is more accurate but slower than a bi-encoder
  • Apply HyDE query transformation and metadata filtering to improve retrieval on vague or time-sensitive questions
  • Use parent-child chunking to retrieve on small chunks while prompting with larger context
  • Measure a RAG system with Recall@k and a faithfulness check, and spot where the check fails

The Problem

You built a basic RAG pipeline in Lesson 06. It works for straightforward questions on a small corpus. Now try these:

Ambiguous query: "What was revenue last quarter?" Semantic search returns chunks about revenue strategy, revenue projections, and the CFO's thoughts on revenue growth. All semantically similar to the word "revenue." None containing the actual number. The correct chunk says "$47.2M in Q3 2025" but uses the word "earnings" instead of "revenue." The embedding model thinks "revenue strategy" is closer to the query than "Q3 earnings were $47.2M."

Multi-hop question: "Which team had the highest customer satisfaction score improvement?" This requires finding the satisfaction scores for each team, comparing them, and identifying the maximum. No single chunk contains the answer. The information is scattered across team reports.

Large corpus problem: You have 2 million chunks. The correct answer is in chunk #1,847,293. Your top-5 retrieval pulls chunks #14, #89,201, #1,200,000, #44, and #901,333. Close in embedding space, but none containing the answer. At this scale, approximate nearest neighbor search introduces enough error that relevant results get pushed out of the top-k.

Basic RAG fails because vector similarity is not the same as relevance. A chunk can be semantically similar to a query without being useful for answering it. Advanced RAG addresses this with four techniques: hybrid search (add keyword matching), reranking (score candidates more carefully), query transformation (fix the query before searching), and better chunking (retrieve at the right granularity).

Warm-up0/3
1. In a basic RAG pipeline, what happens to the user's question before the LLM writes its answer?Warm-up
Diagram of a RAG pipeline with a user question box, a documents box and an LLM box that produces an answer; the two dashed arrows into the LLM are marked with question marks.
Schematic. The question marks stand for the steps the question asks about.
2. Why does basic RAG split documents into chunks before embedding them?Warm-up
Diagram of a long document being split into four chunks C1 to C4, each of which is then embedded into a vector stored in an index; a question mark sits on the split step.
Schematic. The vector values are placeholders, not real numbers.
3. An embedding model maps each piece of text to a vector. What is usually true of two sentences with very similar meanings?Warm-up
Two example sentences about a cat and a kitten pass through an embedding model and become vectors v1 and v2; the cosine similarity between them is marked with a question mark.
Illustrative sentences, typed, not captured. Vector contents are placeholders.

The Concept

Hybrid Search: Semantic + Keyword

Semantic search (vector similarity) is good at understanding meaning. "How do I cancel my subscription?" matches "Steps to terminate your plan" even though they share no words. But it misses exact matches. "Error code E-4021" might not match a chunk containing "E-4021" if the embedding model treats it as noise.

Keyword search (BM25) is the opposite. It excels at exact matches. "E-4021" matches perfectly. But "cancel my subscription" returns zero results if the document says "terminate your plan."

Hybrid search runs both, then merges the results.

BM25 (Best Matching 25) is the standard keyword search algorithm. It has been the backbone of search engines since the 1990s. The formula:

BM25(q, d) = sum over terms t in q:
    IDF(t) * (tf(t,d) * (k1 + 1)) / (tf(t,d) + k1 * (1 - b + b * |d| / avgdl))

Where tf(t,d) is the term frequency of t in document d, IDF(t) is the inverse document frequency, |d| is the document length, avgdl is the average document length, k1 controls term frequency saturation (default 1.2), and b controls length normalization (default 0.75).

In plain terms: BM25 scores documents higher when they contain query terms (especially rare ones), but with diminishing returns for repeated terms. A document with the word "revenue" 50 times is not 50x more relevant than one with it once.

Reciprocal Rank Fusion (RRF)

You have two ranked lists: one from vector search, one from BM25. How do you combine them? Reciprocal Rank Fusion is the standard approach.

RRF_score(d) = sum over rankings R:
    1 / (k + rank_R(d))

Where k is a constant (typically 60) that prevents the top-ranked result from dominating.

A document ranked #1 in vector search and #5 in BM25 gets: 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318

A document ranked #3 in vector search and #2 in BM25 gets: 1/(60+3) + 1/(60+2) = 0.0159 + 0.0161 = 0.0320

RRF naturally balances the two signals. A document that ranks highly in both lists gets the best score. A document that ranks #1 in one list but is absent from the other gets a moderate score. This is robust because it uses ranks, not raw scores, so differences in score distributions between the two systems do not matter.

Reranking

Retrieval (whether vector, keyword, or hybrid) is fast but imprecise. It uses bi-encoders: the query and each document are embedded independently, then compared. The embeddings are computed once and cached. This scales to millions of documents.

Reranking uses cross-encoders: the query and a candidate document are fed together into a model that outputs a relevance score. The model sees both texts simultaneously and can capture fine-grained interactions between them. A cross-encoder can understand that "What were Q3 earnings?" is highly relevant to a chunk containing "$47.2M in Q3" even if a bi-encoder missed the connection.

The trade-off: cross-encoders are 100-1000x slower than bi-encoders because they process the query-document pair jointly. You cannot pre-compute cross-encoder scores for a million documents. The solution: retrieve a larger candidate set (top-50 from hybrid search), then rerank with a cross-encoder to get the final top-5.

Mermaid diagram

Common reranking models (2026 lineup):

  • Cohere Rerank 3.5: managed API, multilingual, best recall gain on mixed corpora
  • Voyage rerank-2.5: managed API, lowest latency of the hosted options
  • Jina-Reranker-v2 Multilingual: open-weight, 100+ languages
  • bge-reranker-v2-m3: open-weight, strong baseline
  • cross-encoder/ms-marco-MiniLM-L-6-v2: open-weight, runs on CPU for prototyping
  • ColBERTv2 / Jina-ColBERT-v2: late-interaction multi-vector rerankers, with scoring that is O(tokens) rather than O(docs)

Query Transformation

Sometimes the problem is not retrieval but the query itself. "What was that thing about the new policy change?" is a terrible search query. It contains no specific terms. The embedding is vague. A retrieval system has nothing to match on.

Query rewriting: rephrase the user's query into a better search query. An LLM can do this:

User: "What was that thing about the new policy change?"
Rewritten: "Recent policy changes and updates"

HyDE (Hypothetical Document Embeddings): instead of searching with the query, generate a hypothetical answer, embed that, and search for similar real documents.

Query: "What is the refund policy for enterprise?"
Hypothetical answer: "Enterprise customers are eligible for a full refund
within 60 days of purchase. Refunds are pro-rated based on the remaining
subscription period and processed within 5-7 business days."

Embed the hypothetical answer and search for real documents similar to it. The intuition: the hypothetical answer lives closer in embedding space to the real answer than the original question does. Questions and answers have different linguistic structures. By generating a hypothetical answer, you bridge the gap between "question space" and "answer space" in the embedding.

HyDE adds one LLM call before retrieval. This increases latency by 500-2000ms. Worth it when retrieval quality is poor on raw queries.

Parent-Child Chunking

Standard chunking forces a trade-off: small chunks for precise retrieval, large chunks for sufficient context. Parent-child chunking sidesteps it by using two sizes.

Index small chunks (128 tokens) for retrieval. When a small chunk is retrieved, return its parent chunk (512 tokens) for the prompt. The small chunk matches the query precisely. The parent chunk provides enough context for the LLM to generate a good answer.

Mermaid diagram

The query "enterprise refund?" matches child chunk C2 precisely. But the prompt receives the full parent chunk P, which includes the surrounding context about processing time and submission process.

Metadata Filtering

Before running vector search, filter the corpus by metadata: date, source, category, author, language. This reduces the search space and prevents irrelevant results.

"What changed in the security policy last month?" should only search documents from the last 30 days in the security category. Without metadata filtering, you search the entire corpus and might retrieve a 2-year-old security document that happens to be semantically similar.

Production RAG systems store metadata alongside each chunk: source document, creation date, category, author, version. Vector databases support pre-filtering by metadata before similarity search, which is critical for performance at scale.

Evaluation

You built a RAG system. How do you know if it works? Three metrics:

Retrieval relevance (Recall@k): for a set of test questions with known relevant documents, what percentage of relevant documents appear in the top-k results? If the answer to a question is in chunk #47, does chunk #47 appear in the top-5?

Faithfulness: is the generated answer grounded in the retrieved documents? If the retrieved chunks say "60-day refund window" and the model says "90-day refund window," that is a faithfulness failure. The model hallucinated despite having the correct context.

Answer correctness: does the generated answer match the expected answer? This is the end-to-end metric. It combines retrieval quality and generation quality.

A simple faithfulness check: take each claim in the generated answer and verify it appears (in substance) in the retrieved chunks. If the answer contains a fact not in any retrieved chunk, it is likely hallucinated.

Mermaid diagram

Check: The Concept0/27
1. What is BM25?Define
Terminal output of the lesson's BM25 search for the query 'What was revenue last quarter?', listing the top three chunks with their source label, a numeric score and the start of the chunk text.
Captured by running the lesson's main.py (Step 1), showing the first three of five rows.
2. What is hybrid search?Define
Terminal output of one hybrid_search call for the query 'What was revenue last quarter?', returning five ranked chunks with source labels, scores and the start of each chunk's text.
Captured by running the lesson's main.py (Step 3). Only the call and its returned list are shown.
3. What does Reciprocal Rank Fusion compute for each document?Define
Table with two documents. Doc A is ranked 1 in the vector list and 5 in the BM25 list; Doc B is ranked 3 and 2. The fused score column holds question marks.
Ranks taken from the lesson's example; the scores are left blank.
4. What is reranking?Define
Left: five candidate chunks from a first retrieval pass with source labels and scores. Right: five empty slots marked with question marks for the order after a second pass.
Left column captured from the lesson's main.py (Step 4, hybrid results before reranking).
5. What is a cross-encoder?Define
Two text cards, a query 'What were Q3 earnings?' and a chunk containing '$47.2M in Q3', next to a box naming the cross-encoder model from the lesson's code; a question mark marks how they relate and what the model takes in and returns is unknown.
Query and chunk text are the lesson's own example; model name is from the lesson's Use It code.
6. What is a bi-encoder in retrieval?Define
Left-to-right pipeline: Query, then a highlighted bi-encoder stage over the corpus, then Candidates, then a cross-encoder stage applied to the candidates.
Schematic, after the lesson's retrieval and reranking description.
7. What does HyDE (Hypothetical Document Embeddings) do before retrieval?Define
Pipeline: a user query about the enterprise refund policy enters a highlighted HyDE step that runs before retrieval, then a vector search over real documents; a question mark marks what the HyDE step produces.
Schematic. The query is the lesson's example.
8. What is parent-child chunking?Define
Diagram: a parent chunk P holding three child chunks C1, C2 and C3, with the query 'enterprise refund?' matching C2, and a separate Prompt-to-LLM box whose contents are a question mark.
Schematic after the lesson's diagram.
9. What is metadata filtering?Define
Table of five stored chunks with a date, a source and a category column, followed by the line 'Chunks that similarity search runs over: ?'.
Illustrative rows, typed, not captured.
10. What does faithfulness measure in a RAG system?Define
Four text cards from one RAG test run: a question about Q3 revenue, a retrieved chunk, a generated answer and the expected answer written for the test; below them the line 'faithfulness = ?'.
Illustrative texts based on the lesson's earnings chunk, typed, not captured.
11. In the BM25 formula from the Hybrid Search part of The Concept, what do k1 and b control?Explain
The BM25 scoring formula written over three lines, with the lines containing k1 and b highlighted, a legend for tf, IDF, document length and average length, and the text 'k1 controls: ?  b controls: ?'.
Formula as written in the lesson, re-laid out over lines.
12. In Reciprocal Rank Fusion, what does the constant k (typically 60) do when summing 1/(k + rank) across ranked lists?Explain
The Reciprocal Rank Fusion formula, a sum over rankings of 1 divided by k plus the document's rank, with the term containing k highlighted, a note that k is typically 60 and the text 'What does k do: ?'.
Formula as written in the lesson.
13. Why does Reciprocal Rank Fusion combine ranks instead of the raw scores from each retriever?Explain
Two ranked lists of document ids, one from vector search and one from BM25, feeding a Fuse box whose merged output is a question mark.
Illustrative document ids, typed, not captured.
14. Why can't a cross-encoder be used to score every chunk in a corpus of a million documents?Explain
A single query card on the left and a column of documents from doc 1 to doc 1,000,000 on the right, each with a cross-encoder score marked with a question mark.
Schematic. Document names are placeholders.
15. Why does the pipeline retrieve a top-50 candidate set and then rerank down to the top 5, instead of retrieving the top 5 directly?Explain
Two pipelines. A: query, retrieval of the top 5, final top 5. B: query, retrieval of the top 50, a reranker, final top 5.
Schematic of the two pipelines the stem compares.
16. Why does searching with a hypothetical answer often retrieve better than searching with the raw question?Explain
Three text cards: a question about the enterprise refund policy, a hypothetical answer written by an LLM, and a real indexed chunk about the enterprise refund window. Dashed arrows from the first two to the chunk are marked with question marks.
Question and hypothetical answer from the lesson; chunk wording follows the lesson's sample refund document.
17. Why does hybrid search combine vector and BM25 instead of relying on either one alone?Explain
Grid with two queries, 'Error code E-4021' and 'How do I cancel my subscription?', each with the document that holds the answer, and a 'finds it?' question-mark cell under vector search and under BM25.
Both examples are taken from the lesson's Hybrid Search section.
18. Before running vector search, how should you use metadata like date, source, and category?Explain
A chunk record with text, date, source and category fields, next to a pipeline of vector search then top-k results, and the text 'Where do date, source and category get used: ?'.
Schematic. Field values are described, not real.
19. Why does parent-child chunking beat a single flat chunk size?Explain
Two chunking layouts for one document: on the left four equal flat chunks, on the right two parent chunks each containing two smaller child chunks.
Schematic, not to scale.
20. Recall@k and faithfulness are reported separately. What does each one tell you?Explain
A five-stage pipeline Question, Retrieval, Chunks, LLM, Answer, with two separate metric boxes below it: 'Recall@k = ?' and 'faithfulness = ?'.
Schematic.
21. A support bot gets the query 'Error code E-4021', and vector search returns generic troubleshooting chunks. Which change fixes this most directly?Analyze
A support bot's user query 'Error code E-4021' and three generic troubleshooting chunks returned by vector search.
Illustrative chunk titles, typed, not captured.
22. Retrieved chunks say 'refunds within 60 days', but the model answers '90-day refund window'. Which metric flags this failure?Analyze
Two cards: the retrieved chunk text 'Refunds are available within 60 days.' and the model answer '90-day refund window', followed by 'Metric that flags this: ?'.
Chunk wording follows the stem, typed, not captured.
23. A user asks 'What changed in the security policy last month?' and the bot cites a 2-year-old security document that is semantically similar. What is missing?Analyze
A user question 'What changed in the security policy last month?', the bot's cited source described as a security policy document written 2 years ago with high semantic similarity, and the line 'What is missing: ?'.
Typed from the stem.
24. You search 10 million chunks, and users type exact error codes as well as loose paraphrases. Which pipeline fits?Design
A corpus box of 10,000,000 chunks next to two example user queries, an exact error code and a loose paraphrase, followed by 'Pipeline that fits: ?'.
Queries are examples from the lesson; the corpus size is from the stem.
25. A changelog corpus has 2 million chunks, and users ask 'what changed in billing last week?'. Which design handles it best?Design
A changelog corpus box of 2,000,000 chunks next to the user question 'what changed in billing last week?', followed by 'Design that handles it: ?'.
Typed from the stem.
26. Your answers lack surrounding steps. Small 128-token chunks match queries well, but larger chunks retrieve poorly. What design fits?Design
A document drawn as six step blocks with step 3 highlighted as the retrieved 128-token chunk, and text noting that the prompt holds only that chunk and the answers lack surrounding steps.
Schematic, typed, not captured. Block layout is illustrative.
27. You swapped in a new reranker and must decide whether to trust the gain. Which evaluation plan separates retrieval faults from generation faults?Design
A pipeline Query, Retrieval, a highlighted New reranker, LLM, Answer, with the stated goal of telling retrieval faults from generation faults and the line 'Numbers to report: ?'.
Schematic.

Build It

Step 1: BM25 Implementation

python
import math
from collections import Counter
 
class BM25:
    def __init__(self, k1=1.2, b=0.75):
        self.k1 = k1
        self.b = b
        self.docs = []
        self.doc_lengths = []
        self.avg_dl = 0
        self.doc_freqs = {}
        self.n_docs = 0
 
    def index(self, documents):
        self.docs = documents
        self.n_docs = len(documents)
        self.doc_lengths = []
        self.doc_freqs = {}
 
        for doc in documents:
            words = doc.lower().split()
            self.doc_lengths.append(len(words))
            unique_words = set(words)
            for word in unique_words:
                self.doc_freqs[word] = self.doc_freqs.get(word, 0) + 1
 
        self.avg_dl = sum(self.doc_lengths) / self.n_docs if self.n_docs else 1
 
    def score(self, query, doc_idx):
        query_words = query.lower().split()
        doc_words = self.docs[doc_idx].lower().split()
        doc_len = self.doc_lengths[doc_idx]
        word_counts = Counter(doc_words)
        score = 0.0
 
        for term in query_words:
            if term not in word_counts:
                continue
            tf = word_counts[term]
            df = self.doc_freqs.get(term, 0)
            idf = math.log((self.n_docs - df + 0.5) / (df + 0.5) + 1)
            numerator = tf * (self.k1 + 1)
            denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_dl)
            score += idf * numerator / denominator
 
        return score
 
    def search(self, query, top_k=10):
        scores = [(i, self.score(query, i)) for i in range(self.n_docs)]
        scores.sort(key=lambda x: x[1], reverse=True)
        return scores[:top_k]

Step 2: Reciprocal Rank Fusion

python
def reciprocal_rank_fusion(ranked_lists, k=60):
    scores = {}
    for ranked_list in ranked_lists:
        for rank, (doc_id, _) in enumerate(ranked_list):
            if doc_id not in scores:
                scores[doc_id] = 0.0
            scores[doc_id] += 1.0 / (k + rank + 1)
    fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return fused

Step 3: Hybrid Search Pipeline

python
def hybrid_search(query, chunks, vector_embeddings, vocab, idf, bm25_index, top_k=5, fusion_k=60):
    query_emb = tfidf_embed(query, vocab, idf)
    vector_results = search(query_emb, vector_embeddings, top_k=top_k * 3)
    bm25_results = bm25_index.search(query, top_k=top_k * 3)
    fused = reciprocal_rank_fusion([vector_results, bm25_results], k=fusion_k)
    return fused[:top_k]

Step 4: Simple Reranker

In production, you would use a cross-encoder model. Here we build a reranker that scores query-document relevance using word overlap, term importance, and phrase matching.

python
def rerank(query, candidates, chunks):
    query_words = set(query.lower().split())
    stop_words = {"the", "a", "an", "is", "are", "was", "were", "what", "how",
                  "why", "when", "where", "do", "does", "for", "of", "in", "to",
                  "and", "or", "on", "at", "by", "it", "its", "this", "that",
                  "with", "from", "be", "has", "have", "had", "not", "but"}
    query_terms = query_words - stop_words
 
    scored = []
    for doc_id, initial_score in candidates:
        chunk = chunks[doc_id].lower()
        chunk_words = set(chunk.split())
 
        term_overlap = len(query_terms & chunk_words)
 
        query_bigrams = set()
        q_list = [w for w in query.lower().split() if w not in stop_words]
        for i in range(len(q_list) - 1):
            query_bigrams.add(q_list[i] + " " + q_list[i + 1])
        bigram_matches = sum(1 for bg in query_bigrams if bg in chunk)
 
        position_boost = 0
        for term in query_terms:
            pos = chunk.find(term)
            if pos != -1 and pos < len(chunk) // 3:
                position_boost += 0.5
 
        rerank_score = (
            term_overlap * 1.0
            + bigram_matches * 2.0
            + position_boost
            + initial_score * 5.0
        )
        scored.append((doc_id, rerank_score))
 
    scored.sort(key=lambda x: x[1], reverse=True)
    return scored

Step 5: HyDE (Hypothetical Document Embeddings)

python
def hyde_generate_hypothesis(query):
    templates = {
        "what": "The answer to '{query}' is as follows: Based on our documentation, {topic} involves specific policies and procedures that define how the process works.",
        "how": "To address '{query}': The process involves several steps. First, you need to initiate the request. Then, the system processes it according to the defined rules.",
        "default": "Regarding '{query}': Our records indicate specific details and policies related to this topic that provide a comprehensive answer."
    }
    query_lower = query.lower()
    if query_lower.startswith("what"):
        template = templates["what"]
    elif query_lower.startswith("how"):
        template = templates["how"]
    else:
        template = templates["default"]
 
    topic_words = [w for w in query.lower().split()
                   if w not in {"what", "is", "the", "how", "do", "does", "a", "an",
                                "for", "of", "to", "in", "on", "at", "by", "and", "or"}]
    topic = " ".join(topic_words) if topic_words else "this topic"
 
    return template.format(query=query, topic=topic)
 
 
def hyde_search(query, chunks, vector_embeddings, vocab, idf, top_k=5):
    hypothesis = hyde_generate_hypothesis(query)
    hypothesis_emb = tfidf_embed(hypothesis, vocab, idf)
    results = search(hypothesis_emb, vector_embeddings, top_k)
    return results, hypothesis

Step 6: Parent-Child Chunking

python
def create_parent_child_chunks(text, parent_size=200, child_size=50):
    words = text.split()
    parents = []
    children = []
    child_to_parent = {}
 
    parent_idx = 0
    start = 0
    while start < len(words):
        parent_end = min(start + parent_size, len(words))
        parent_text = " ".join(words[start:parent_end])
        parents.append(parent_text)
 
        child_start = start
        while child_start < parent_end:
            child_end = min(child_start + child_size, parent_end)
            child_text = " ".join(words[child_start:child_end])
            child_idx = len(children)
            children.append(child_text)
            child_to_parent[child_idx] = parent_idx
            child_start += child_size
 
        parent_idx += 1
        start += parent_size
 
    return parents, children, child_to_parent

Step 7: Faithfulness Evaluation

python
def evaluate_faithfulness(answer, retrieved_chunks):
    answer_sentences = [s.strip() for s in answer.split(".") if len(s.strip()) > 10]
    if not answer_sentences:
        return 1.0, []
 
    grounded = 0
    ungrounded = []
    context = " ".join(retrieved_chunks).lower()
 
    for sentence in answer_sentences:
        words = set(sentence.lower().split())
        stop_words = {"the", "a", "an", "is", "are", "was", "were", "and", "or",
                      "to", "of", "in", "for", "on", "at", "by", "it", "this", "that"}
        content_words = words - stop_words
        if not content_words:
            grounded += 1
            continue
 
        matched = sum(1 for w in content_words if w in context)
        ratio = matched / len(content_words) if content_words else 0
 
        if ratio >= 0.5:
            grounded += 1
        else:
            ungrounded.append(sentence)
 
    score = grounded / len(answer_sentences) if answer_sentences else 1.0
    return score, ungrounded
 
 
def evaluate_retrieval_recall(queries_with_relevant, retrieval_fn, k=5):
    total_recall = 0.0
    results = []
 
    for query, relevant_indices in queries_with_relevant:
        retrieved = retrieval_fn(query, k)
        retrieved_indices = set(idx for idx, _ in retrieved)
        relevant_set = set(relevant_indices)
        hits = len(retrieved_indices & relevant_set)
        recall = hits / len(relevant_set) if relevant_set else 1.0
        total_recall += recall
        results.append({
            "query": query,
            "recall": recall,
            "hits": hits,
            "total_relevant": len(relevant_set)
        })
 
    avg_recall = total_recall / len(queries_with_relevant) if queries_with_relevant else 0
    return avg_recall, results
Check: Build It0/8
1. reciprocal_rank_fusion is called with k=60 on two lists. List A ranks doc 7 first and doc 3 second. List B ranks doc 3 first and doc 9 second. Which doc comes out on top?Apply
A call to reciprocal_rank_fusion with k=60 and two ranked lists: list A has Doc 7 then Doc 3, list B has Doc 3 then Doc 9. The top fused document is a question mark.
Typed from the stem.
2. BM25.score('cancel subscription', i) runs on a document that reads 'steps to terminate your plan'. What does it return?Apply
A call bm25.score('cancel subscription', i) and the text of document i, 'steps to terminate your plan', with the return value shown as a question mark.
Typed from the stem.
3. hybrid_search is called with top_k=5 and its default settings. How many candidates does each retriever return before fusion, and what does the function return?Apply
A call hybrid_search(query, ..., top_k=5) feeding a vector search and a BM25 search, each returning an unknown number of results, then a fusion step and a final return of an unknown number of results.
Schematic. Counts are left as question marks.
4. create_parent_child_chunks runs on a 450-word text with parent_size=200 and child_size=50. How many parents and children come back?Apply
A call to create_parent_child_chunks with parent_size=200 and child_size=50 and a bar for a 450-word text with ticks at 0, 200, 400 and 450; the number of parents and children is a question mark.
Typed from the stem; the word scale is drawn to proportion.
5. evaluate_retrieval_recall runs one query whose relevant chunk indices are [3, 7]. The retrieval function returns indices 3, 10, 11, 12 and 13. What recall does it record for this query?Apply
Two rows of chunk index chips: the relevant chunks 3 and 7, and the five retrieved chunks 3, 10, 11, 12 and 13 in the order returned; the recall value is a question mark.
Typed from the stem.
6. In Step 7, evaluate_faithfulness gives the answer with '90-day refund window' a score of 0.67 even though the context says 60 days. Why does the invented number slip through?Analyze
The refund-policy context line saying 60-day, and the lesson's Step 7 terminal output for an answer that says 90-day, processed instantly and a $50 processing fee: faithfulness 0.67 with only the fee listed as ungrounded.
Captured by running the lesson's main.py (Step 7), output shortened.
7. In the Step 5 toy, 'How much money did the company make?' still returns the refund chunk first, the same as direct search. Why does HyDE not help here?Analyze
Two ranked top-3 lists for the query 'How much money did the company make?': direct search returns refund, api, uptime and the HyDE search returns refund, uptime, api, with their similarity scores.
Captured by running the lesson's main.py (Step 5).
8. On the six sample documents, vector search, BM25, hybrid and the reranker all put the right source first for all five queries. What is the sound conclusion?Analyze
Table of five test queries against Vector, BM25, Hybrid and Rerank, with HIT in every cell, and a note that the corpus is six sample documents.
Captured by running the lesson's main.py (Step 8).

Use It

With a real cross-encoder for reranking:

python
from sentence_transformers import CrossEncoder
 
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
 
def rerank_with_cross_encoder(query, candidates, chunks, top_k=5):
    pairs = [(query, chunks[doc_id]) for doc_id, _ in candidates]
    scores = reranker.predict(pairs)
    scored = list(zip([doc_id for doc_id, _ in candidates], scores))
    scored.sort(key=lambda x: x[1], reverse=True)
    return scored[:top_k]

With Cohere's managed reranker:

python
import cohere
 
co = cohere.Client()
 
def rerank_with_cohere(query, candidates, chunks, top_k=5):
    docs = [chunks[doc_id] for doc_id, _ in candidates]
    response = co.rerank(
        model="rerank-english-v3.0",
        query=query,
        documents=docs,
        top_n=top_k
    )
    return [(candidates[r.index][0], r.relevance_score) for r in response.results]

For HyDE with a real LLM:

python
import anthropic
 
client = anthropic.Anthropic()
 
def hyde_with_llm(query):
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=256,
        messages=[{
            "role": "user",
            "content": f"Write a short paragraph that would be a good answer to this question. Do not say you don't know. Just write what the answer would look like.\n\nQuestion: {query}"
        }]
    )
    return response.content[0].text

For production hybrid search with Weaviate:

python
import weaviate
 
client = weaviate.connect_to_local()
 
collection = client.collections.get("Documents")
response = collection.query.hybrid(
    query="enterprise refund policy",
    alpha=0.5,
    limit=10
)

The alpha parameter controls the balance: 0.0 = pure keyword (BM25), 1.0 = pure vector, 0.5 = equal weight. Most production systems use alpha between 0.3 and 0.7.

Check: Use It0/2
1. collection.query.hybrid(query='enterprise refund policy', alpha=0.0, limit=10) runs in Weaviate. How does it rank results?Apply
A Weaviate collection.query.hybrid call with query 'enterprise refund policy', alpha=0.0 and limit=10, above two boxes for vector similarity and BM25 keyword score whose weights are question marks.
Code from the stem; the lesson's Use It section has the same call with a different alpha.
2. rerank_with_cross_encoder receives 50 candidates and top_k=5. What happens inside it?Apply
A call to rerank_with_cross_encoder with 50 candidates shown as chips c1 to c10 plus a note up to c50, a box for what happens inside the function with the number of model passes a question mark, and a return box with an unknown number of results.
Schematic. Only the function's signature and input size come from the stem.

Ship It

This lesson produces:

  • outputs/prompt-advanced-rag-debugger.md -- a prompt for diagnosing and fixing RAG quality issues
  • outputs/skill-advanced-rag.md -- a skill for building production-grade RAG with hybrid search and reranking
Author's Note

I'd run main.py and read the Step 8 table before trusting any of the claims above. On the six sample documents, vector search, BM25, hybrid, and the reranker all put the right source first for every one of the five queries. The toy corpus is too small and too clean to separate them, so Exercise 1 will not show hybrid winning here. You need a bigger corpus or harder queries to see the gap.

The pieces that do show something are the ones where the toy is weakest. Step 5 uses a template for HyDE instead of an LLM, and on "How much money did the company make?" it still returns the refund chunk first, same as direct search. A real hypothesis that says "revenue" is what bridges that vocabulary gap, so that step is a place to swap in the LLM version from Use It. The reranker is hand-written word overlap, bigram matches, and a position boost, with the fused score weighted by 5.0. I picked those weights by eye, so treat them as a placeholder for a cross-encoder.

The faithfulness check is also a word-overlap heuristic. On the bad answer in Step 7 it flags the invented processing fee but scores 0.67, because "90-day refund window" shares enough words with the real context to pass. That is the failure I'd watch for with this kind of check, and it is why I'd move to an LLM judge or an NLI model before relying on faithfulness numbers.

Exercises

  1. Compare BM25 vs vector search vs hybrid search on the sample documents. For each of the 5 test queries, record which approach returns the most relevant chunk in position #1. Hybrid search should win on at least 3 out of 5.

  2. Implement a metadata filter. Add a "category" field to each document (security, billing, api, product). Before running vector search, filter chunks to only the relevant category. Test with "What encryption is used?" and verify it only searches security-category chunks.

  3. Build a full HyDE pipeline using the simple generate function from Lesson 06. Compare retrieval quality (top-3 relevance) between direct query search and HyDE search on all 5 test queries. HyDE should improve results for vague queries.

  4. Implement the parent-child chunking strategy on the sample documents. Use child_size=30 and parent_size=100. Search with child chunks but return parent chunks in the prompt. Compare the generated answers to standard chunking with chunk_size=50.

  5. Create an evaluation dataset: 10 questions with known answer chunks. Measure Recall@3, Recall@5, and Recall@10 for (a) vector search only, (b) BM25 only, (c) hybrid search, (d) hybrid + reranking. Plot the results and identify where reranking helps most.

Key Terms

TermWhat people sayWhat it actually means
BM25"Keyword search"A probabilistic ranking algorithm that scores documents by term frequency, inverse document frequency, and document length normalization
Hybrid search"Best of both worlds"Running semantic (vector) and keyword (BM25) search in parallel, then merging results with rank fusion
Reciprocal Rank Fusion"Merge ranked lists"Combining multiple ranked lists by summing 1/(k + rank) for each document across all lists
Reranking"Second pass scoring"Using a more expensive cross-encoder model to re-score a candidate set from initial retrieval
Cross-encoder"Joint query-document model"A model that takes a query and document as a single input, producing a relevance score; more accurate than bi-encoders but too slow for full corpus search
Bi-encoder"Independent embedding model"A model that embeds queries and documents independently; fast because embeddings are precomputed, but less accurate than cross-encoders
HyDE"Search with a fake answer"Generate a hypothetical answer to the query, embed it, and search for real documents similar to it
Parent-child chunking"Small search, big context"Index small chunks for precise retrieval but return the larger parent chunk to provide sufficient context
Metadata filtering"Narrow before searching"Filtering documents by attributes (date, source, category) before running vector search to reduce the search space
Faithfulness"Did it stay grounded"Whether the generated answer is supported by the retrieved documents, as opposed to hallucinated from the model's training data

Further Reading