The LLM StackFrom Silicon to Agents
Part IX — Retrieval & RAG
32 min read·Updated ·▶ Run the code (Colab)

9.4 Chunking, Reranking & Hybrid Search

Retrieval-Augmented Generation (RAG) feels deceptively simple on paper: split your documents into pieces, embed them, and fetch the most similar pieces at query time. In practice, that description hides half a dozen hard sub-problems. Split too coarsely and you retrieve paragraphs that bury the relevant sentence in noise. Split too finely and no single chunk carries enough context to be useful. Use only dense retrieval and you miss documents where an exact product code or a rare proper noun is the critical signal. Use only keyword search and you miss paraphrases. Retrieve the top-k blindly and the final context window fills with redundant or marginally relevant chunks.

This chapter is about the practical levers that close the gap between a toy RAG prototype and a production-grade system. We cover the full retrieval pipeline: chunking strategies, hybrid search combining BM25 with dense and learned-sparse retrieval, reciprocal rank fusion, cross-encoder rerankers, query rewriting and HyDE, and metadata filtering. Each section explains the mechanism from scratch, shows real code, names the open-source library you would actually ship, and calls out the failure modes.

For the broader RAG system architecture see Retrieval-Augmented Generation Architectures. The embedding models that power the dense retrieval leg are covered in Embeddings & Representation Learning, and the ANN indexes that scale to millions of vectors are described in Vector Databases & Approximate Nearest Neighbor Search. Advanced topics like GraphRAG, contextual retrieval and long-context alternatives appear in Advanced RAG: GraphRAG, Agentic RAG & Long-Context vs RAG, and the multi-vector “late interaction” family (ColBERT, ColPali) — the architectural middle ground between the bi-encoders and cross-encoders discussed here — in Multimodal & Visual-Document Retrieval: ColPali & Late Interaction. The capstone applies this chapter’s chunker, BM25 leg and reranker to a real corpus in A Narrow Auto-Research Agent: ReAct, Tool-Use & Retrieval by Distillation.


1. Why Chunking Is the Hardest Part Nobody Talks About

Every embedding model has a maximum token length — typically 512 tokens for older bi-encoders, 4096–8192 for modern ones (e.g., text-embedding-3-large, nomic-embed-text-v1.5). But even if your model supports 8k tokens, shoving an entire chapter into a single vector is a mistake: the embedding averages the semantics of every sentence, so a query about one narrow fact will score poorly against a dense multi-topic chunk that happens to contain that fact.

The chunking decision controls a fundamental quality–recall trade-off:

  • Too large chunks — high recall (the fact is somewhere in there), but low precision (the LLM gets a lot of noise and may hallucinate or ignore the signal).
  • Too small chunks — high precision, but context loss: a sentence like “It increased by 12%” is meaningless without its referent.

Getting chunking wrong is probably the single most common cause of poor RAG performance in the wild. The optimal chunk size is domain-dependent, but the strategies below give you a principled path through the search space.

Too small It increased by 12%. referent lost -- 12% of what? precision HIGH context LOST single isolated sentence no surrounding context Sweet spot Q4 revenue hit a record. It increased by 12%. referent + key sentence, together both signals present (domain-dependent) precision and context balanced -- the target to tune toward Too large It increased by 12%. 1 signal line among ~5 filler lines signal buried in noise -- LLM may ignore or hallucinate recall HIGH precision LOW chunk size small large optimal band is domain-dependent precision recall / context sufficiency small large
Chunk size trades precision against recall/context, with a domain-dependent sweet spot. The same key sentence, "It increased by 12%", loses its referent when isolated in a too-small chunk, sits together with its referent in a well-sized chunk, and gets buried among unrelated filler lines in a too-large chunk. As chunk size grows, precision falls while recall/context sufficiency rises; the two curves cross in a band where both signals are usable, but the exact optimal size still depends on the domain.

2. Chunking Strategies

2.1 Fixed-Length Chunking with Overlap

The simplest approach: split every chunk_size tokens, with an overlap of tokens shared between adjacent chunks so that sentence boundaries do not cut off context.

# fixed_chunking.py — minimal, dependency-free fixed-length chunker
from __future__ import annotations
from typing import Iterator

def fixed_chunk(
    text: str,
    chunk_size: int = 512,
    overlap: int = 64,
    tokenizer=None,      # a callable str->list[int]; falls back to whitespace split
) -> Iterator[str]:
    """
    Yield overlapping token-level chunks of `text`.

    Args:
        text:        The raw document string.
        chunk_size:  Maximum tokens per chunk.
        overlap:     Number of tokens to repeat from the previous chunk.
        tokenizer:   Optional callable returning token ids. When None, we split
                     on whitespace as a proxy (fast for prototyping).

    Yields:
        Decoded string chunks.

    Example:
        >>> chunks = list(fixed_chunk("word " * 1000, chunk_size=10, overlap=2))
        >>> len(chunks)   # ceil((1000-10) / (10-2)) + 1 = 125 chunks
        125
    """
    if tokenizer is None:
        # Whitespace proxy: each "token" is a word
        tokens = text.split()
        decode = lambda ids: " ".join(ids)  # noqa: E731
    else:
        tokens = tokenizer(text)
        decode = tokenizer.decode  # type: ignore[attr-defined]

    step = chunk_size - overlap
    if step <= 0:
        raise ValueError("chunk_size must be strictly greater than overlap")

    start = 0
    while start < len(tokens):
        end = min(start + chunk_size, len(tokens))
        yield decode(tokens[start:end])
        if end == len(tokens):
            break
        start += step


# --- Quick demo ---
if __name__ == "__main__":
    doc = " ".join([f"word{i}" for i in range(200)])
    chunks = list(fixed_chunk(doc, chunk_size=50, overlap=10))
    print(f"Produced {len(chunks)} chunks from 200 tokens")
    print(f"First chunk length: {len(chunks[0].split())} words")
    print(f"Last chunk:         {chunks[-1][:60]}...")

The overlap parameter is crucial: without it, a sentence split across two chunks may lose its grammatical antecedent in both halves. Typical production values are chunk_size=256–512 tokens with overlap=32–64.

2.2 Semantic Chunking

Fixed-length chunking is agnostic to content structure. Semantic chunking exploits embedding similarity to find natural topic breaks.

The algorithm: 1. Split text into sentences (using a sentence tokenizer like NLTK or spaCy). 2. Embed each sentence. 3. Compute the cosine similarity between consecutive sentence embeddings. 4. Mark a chunk boundary wherever the similarity drops below a threshold (or is a local minimum).

# semantic_chunking.py — embed-based semantic chunker
from __future__ import annotations
import numpy as np
from typing import Callable


def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    """Cosine similarity between two 1-D vectors."""
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))


def semantic_chunk(
    sentences: list[str],
    embed_fn: Callable[[list[str]], np.ndarray],
    threshold: float = 0.75,
    min_sentences: int = 3,
) -> list[str]:
    """
    Group consecutive sentences into chunks based on embedding similarity.

    Args:
        sentences:     Pre-tokenized list of sentence strings.
        embed_fn:      Function mapping list[str] -> np.ndarray of shape (N, D).
        threshold:     Similarity threshold; boundaries placed where sim < threshold.
        min_sentences: Minimum sentences per chunk to avoid micro-chunks.

    Returns:
        List of joined chunk strings.

    The key insight: when cos-sim between sentence[i] and sentence[i+1]
    drops sharply, we've hit a topic boundary. This is much more robust
    than arbitrary token counts for structured prose.
    """
    if not sentences:
        return []

    # Embed all sentences in one batch (efficient for API calls)
    embeddings = embed_fn(sentences)  # shape (N, D)

    # Compute consecutive similarities
    sims = [
        cosine_similarity(embeddings[i], embeddings[i + 1])
        for i in range(len(sentences) - 1)
    ]

    # Find boundary positions (where similarity is low AND gap >= min_sentences)
    chunks: list[str] = []
    current: list[str] = []
    for i, sent in enumerate(sentences):
        current.append(sent)
        if i < len(sims):
            is_boundary = sims[i] < threshold and len(current) >= min_sentences
            if is_boundary:
                chunks.append(" ".join(current))
                current = []

    if current:  # flush remaining sentences
        chunks.append(" ".join(current))

    return chunks


# --- Illustrative usage (replace embed_fn with your actual model) ---
if __name__ == "__main__":
    import random

    # Mock embed function: random vectors — replace with real embeddings
    def mock_embed(texts: list[str]) -> np.ndarray:
        rng = np.random.default_rng(42)
        return rng.standard_normal((len(texts), 384)).astype(np.float32)

    sample = [f"Sentence {i} about {'topic A' if i < 5 else 'topic B'}."
              for i in range(10)]
    result = semantic_chunk(sample, mock_embed, threshold=0.5, min_sentences=2)
    print(f"Produced {len(result)} semantic chunks")

Semantic chunking produces more coherent chunks but is slower (requires an embedding call) and more sensitive to the threshold. A common tuning strategy: run the chunker on a held-out set, visualize the similarity curve, and set the threshold at the 15th percentile of observed similarities.

2.3 Structure-Aware Chunking

Real documents have structure: headings, paragraphs, bullet lists, code blocks. Respecting that structure almost always outperforms purely statistical methods.

# structural_chunking.py — Markdown-aware recursive splitter
from __future__ import annotations
import re


HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)


def split_markdown(
    text: str,
    max_tokens: int = 512,
    words_per_token: float = 0.75,  # English rule of thumb: ~1.3 tokens per word
) -> list[dict]:
    """
    Split a Markdown document respecting heading hierarchy.

    Each returned dict has:
      - 'text':     the chunk content
      - 'heading':  the nearest parent heading (for metadata)
      - 'level':    heading depth (1-6; 0 = preamble)

    Strategy:
      1. Split on headings first.
      2. If a section exceeds max_tokens, sub-split on paragraph breaks.
      3. Preserve heading as metadata for downstream metadata filtering.
    """
    # English text runs ~1.3 BPE tokens per whitespace word, so a 512-token
    # budget is ~384 words. (Multiply, do not divide: dividing would let each
    # chunk grow to ~680 words ≈ 900 tokens and silently overflow the encoder.)
    max_words = int(max_tokens * words_per_token)
    chunks: list[dict] = []

    # Find all heading positions
    boundaries = [
        (m.start(), m.group(1), m.group(2).strip()) for m in HEADING_RE.finditer(text)
    ]
    boundaries.append((len(text), "", ""))  # sentinel

    prev_end = 0
    current_heading = ""
    current_level = 0

    for i, (pos, hashes, heading_text) in enumerate(boundaries):
        section = text[prev_end:pos].strip()
        if section:
            words = section.split()
            if len(words) <= max_words:
                chunks.append({
                    "text": section,
                    "heading": current_heading,
                    "level": current_level,
                })
            else:
                # Sub-split on blank lines (paragraphs)
                paragraphs = re.split(r"\n\s*\n", section)
                for para in paragraphs:
                    para = para.strip()
                    if para:
                        chunks.append({
                            "text": para,
                            "heading": current_heading,
                            "level": current_level,
                        })

        current_heading = heading_text
        current_level = len(hashes)
        prev_end = pos

    return chunks

The libraries you would actually ship. Nobody writes the three chunkers above from scratch twice — you write them once to understand the trade-off, then use a maintained splitter. The two dominant ones are LangChain (langchain_text_splitters) and LlamaIndex (llama_index.core.node_parser), and both express exactly the strategies above:

# real_chunkers.py — the production equivalents of §2.1–2.3
# pip install langchain-text-splitters llama-index-core

# --- LangChain: recursive character splitting (the workhorse default) ---
from langchain_text_splitters import (
    RecursiveCharacterTextSplitter,
    MarkdownHeaderTextSplitter,
)

# Tries separators in order — paragraphs, then lines, then sentences, then
# words — so it only cuts mid-sentence when it has no other option.
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512, chunk_overlap=64,
    separators=["\n\n", "\n", ". ", " ", ""],
)
document = "\n\n".join(f"Paragraph {i}. " + "word " * 100 for i in range(20))
chunks = splitter.split_text(document)

# To count in *tokens* rather than characters, bind a real tokenizer:
#   RecursiveCharacterTextSplitter.from_huggingface_tokenizer(tok, chunk_size=512)
#   RecursiveCharacterTextSplitter.from_tiktoken_encoder(chunk_size=512)

# --- LangChain: structure-aware (the §2.3 equivalent) ---
# Splits on headings and attaches them as chunk metadata for later filtering.
md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)

# --- LlamaIndex: sentence-window and semantic (the §2.2 equivalent) ---
from llama_index.core.node_parser import SentenceSplitter, SemanticSplitterNodeParser
sentence_parser = SentenceSplitter(chunk_size=512, chunk_overlap=64)
# SemanticSplitterNodeParser(embed_model=..., breakpoint_percentile_threshold=95)
# implements §2.2's embedding-similarity boundary detection, with the threshold
# expressed as a percentile of observed similarity drops rather than an absolute.

Upstream of the splitter sits document parsing, which is where most real corpora actually break: PDFs, scanned pages, tables and slide decks have to become text (with structure preserved) before any of this applies. Docling (IBM, 2024) and unstructured are the two open defaults — both emit Markdown-with-headings, which feeds MarkdownHeaderTextSplitter directly. When the layout itself carries the meaning (financial tables, forms), consider skipping OCR entirely and retrieving over page images: see Multimodal & Visual-Document Retrieval.

2.4 Late Chunking

Late chunking, introduced by Günther et al. (2024) and popularized in the context of jina-embeddings-v2, inverts the usual order of operations. Instead of chunking first and then embedding, we:

  1. Pass the entire document (or a large passage) through the transformer encoder to produce contextualized token embeddings.
  2. Mean-pool over the token spans that correspond to each logical chunk.

This is valuable because the attention mechanism lets every token see its full document context before the pooling boundary is applied. A sentence like “It increased by 12%” gets an embedding that encodes what “it” refers to, because the self-attention already saw the preceding paragraph.

Document tokens t₁ t₂ t₃ ... tₙ Transformer Encoder (processes the FULL document — self-attention lets every token see all others) encode once -- full document context Contextualized token embeddings e₁ e₂ e₃ ... eₙ Span [1..k] selects e₁..eₖ Span [k+1..m] selects eₖ₊₁..eₘ Span [m+1..N] selects eₘ₊₁..eₙ Mean-pool Mean-pool Mean-pool chunk_1_emb chunk_2_emb chunk_3_emb Vector index stored with chunk text boundaries (start_char, end_char) LateChunk(text, embedding, start_char, end_char)
Late chunking encodes the entire document first, then pools per chunk. Unlike naive chunking, every token embedding already carries full-document context from self-attention before pooling boundaries are applied — so a pronoun like "it" gets an embedding that knows its referent. The Transformer Encoder runs exactly once; the three span/pool/chunk columns represent a partition of the same contextualized embedding sequence.

The constraint: the full document must fit within the model’s context window. For documents longer than ~8k tokens you can apply late chunking within sliding windows.

Late chunking has a generative cousin that solves the same decontextualisation problem from the other direction: contextual retrieval (Anthropic, 2024) asks an LLM to write a one-sentence situating preamble for each chunk (“This excerpt is from the Q3 2024 10-K, in the segment-revenue discussion”) and prepends it to the chunk text before embedding and before BM25 indexing. Late chunking is cheap (one encoder pass, no LLM) but needs a long-context encoder; contextual retrieval works with any encoder but costs one LLM call per chunk at index time. They compose. The mechanism, cost model and prompt are developed in Advanced RAG.

# late_chunking.py — illustrative late chunking with a HuggingFace model
from __future__ import annotations
import torch
from transformers import AutoTokenizer, AutoModel
from typing import NamedTuple


class LateChunk(NamedTuple):
    text: str
    embedding: torch.Tensor  # shape (D,)
    start_char: int
    end_char: int


def late_chunk_document(
    document: str,
    chunk_boundaries: list[tuple[int, int]],  # (start_char, end_char) pairs
    model_name: str = "jinaai/jina-embeddings-v2-base-en",
    device: str = "cpu",
) -> list[LateChunk]:
    """
    Apply late chunking: encode full document once, pool over chunk spans.

    Args:
        document:          Full document string.
        chunk_boundaries:  List of (start_char, end_char) defining each chunk.
                           These can be obtained from structural_chunking above,
                           or any other boundary detection method.
        model_name:        HuggingFace encoder model (must support long context).
        device:            'cpu' or 'cuda'.

    Returns:
        List of LateChunk named tuples with text + contextual embedding.
    """
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModel.from_pretrained(model_name).to(device).eval()

    # Tokenize entire document, keep char-to-token mapping
    encoding = tokenizer(
        document,
        return_tensors="pt",
        return_offsets_mapping=True,  # crucial: gives (char_start, char_end) per token
        truncation=True,              # truncate to model max length if needed
        max_length=8192,
    )
    offset_mapping = encoding.pop("offset_mapping")[0]  # (seq_len, 2)
    input_ids = encoding["input_ids"].to(device)
    attention_mask = encoding["attention_mask"].to(device)

    with torch.no_grad():
        outputs = model(input_ids=input_ids, attention_mask=attention_mask)
        # token_embeddings: (1, seq_len, hidden_size)
        token_embeddings = outputs.last_hidden_state[0]  # (seq_len, D)

    chunks: list[LateChunk] = []
    for start_char, end_char in chunk_boundaries:
        # Find which token indices correspond to this character span
        token_mask = (
            (offset_mapping[:, 0] >= start_char) &
            (offset_mapping[:, 1] <= end_char)
        )
        span_embeddings = token_embeddings[token_mask]  # (span_len, D)

        if span_embeddings.shape[0] == 0:
            continue  # skip empty spans (e.g., punctuation-only)

        # Mean pooling over the span's token embeddings
        chunk_embedding = span_embeddings.mean(dim=0)  # (D,)

        # L2-normalize for cosine similarity
        chunk_embedding = chunk_embedding / (chunk_embedding.norm() + 1e-9)

        chunks.append(LateChunk(
            text=document[start_char:end_char],
            embedding=chunk_embedding.cpu(),
            start_char=start_char,
            end_char=end_char,
        ))

    return chunks

3. Hybrid Search: BM25 + Dense Retrieval

Dense retrieval (see Embeddings & Representation Learning) excels at semantic similarity but struggles with exact-match recall. If a user asks about “CVE-2023-44487” or a specific product SKU, the embedding of that query may not land near the embedding of the document that contains that exact string — because the model generalizes. BM25, the classic TF-IDF variant, handles these cases natively.

BM25 score for query \(q\) against document \(d\):

\[ \text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)} \]

where \(f(t, d)\) is term frequency in \(d\), \(|d|\) is document length in words, \(\text{avgdl}\) is the average document length in the corpus, and \(k_1 \approx 1.2\text{–}2.0\), \(b \approx 0.75\) are tuning constants. The IDF is:

\[ \text{IDF}(t) = \log\!\left(\frac{N - n(t) + 0.5}{n(t) + 0.5} + 1\right) \]

with \(N\) the corpus size and \(n(t)\) the number of documents containing term \(t\).

Use a real BM25 engine. The BM25Index we build below rescores the entire corpus in Python on every query — \(O(N \cdot |q|)\) with a large constant — which is fine for teaching and fine up to a few thousand chunks, and hopeless beyond that. Real lexical retrieval uses an inverted index: for each query term, walk only the posting list of documents that actually contain it, so cost scales with the (usually small) number of matching documents rather than with \(N\). The open-source ladder:

# real_bm25.py — bm25s: sparse-matrix BM25, same math, ~100x faster
# pip install bm25s PyStemmer
import bm25s
import Stemmer

corpus = [
    "Reciprocal rank fusion combines ranked lists without score normalization.",
    "CVE-2023-44487 is the HTTP/2 Rapid Reset denial-of-service vulnerability.",
    "BM25 weights rare terms heavily through the IDF factor.",
]

stemmer = Stemmer.Stemmer("english")                      # optional but helps recall
corpus_tokens = bm25s.tokenize(corpus, stopwords="en", stemmer=stemmer)

retriever = bm25s.BM25(method="lucene")                   # Lucene's BM25 variant
retriever.index(corpus_tokens)                            # builds the sparse matrix

query_tokens = bm25s.tokenize("HTTP/2 rapid reset CVE", stopwords="en", stemmer=stemmer)
results, scores = retriever.retrieve(query_tokens, k=2)   # shapes: (1, k) each
for rank in range(results.shape[1]):
    print(f"{rank + 1}. score={scores[0, rank]:.3f}  {corpus[results[0, rank]]}")
  • bm25s — sparse-matrix BM25 in Python; the right default for a single-process index up to millions of short chunks. (rank_bm25 is the older, much slower equivalent.)
  • Pyserini — Python bindings over Lucene; the research-grade path, and what BEIR numbers are reproduced with.
  • Elasticsearch / OpenSearch — a real distributed inverted index when the corpus outgrows one machine; both ship native hybrid retrieval and a built-in RRF combiner, so you can push the fusion below in Section 3.1 into the search engine itself.
  • Most vector databases now do the sparse leg too: Qdrant, Weaviate and Milvus all accept a sparse vector alongside the dense one and expose server-side RRF fusion, which removes the need to keep two systems in sync.

3.1 Reciprocal Rank Fusion

Given a BM25 ranking and a dense ranking, how do you combine them? Cormack et al. (2009) introduced Reciprocal Rank Fusion (RRF), which is both elegant and robust:

\[ \text{RRF}(d) = \sum_{r \in \text{rankers}} \frac{1}{k + \text{rank}_r(d)} \]

where \(k = 60\) is the smoothing constant (documents that don’t appear in a ranking are simply omitted from that term). The document with the highest combined RRF score wins.

RRF does not require score normalization — it only uses the rank ordinal, which makes it immune to the scale mismatch between BM25 scores (roughly 0–20) and cosine similarities (roughly 0.5–1.0).

D1: BM25 rank 2 + Dense rank 1 -> fused rank 1 BM25 (lexical) 1 D2 18.4 exact-token win: e.g. a CVE id / SKU 2 D1 9.7 3 D3 6.2 4 D4 3.1 5 D5 1.4 Dense (semantic) 1 D1 0.93 paraphrase win: no shared surface tokens 2 D3 0.87 3 D2 0.81 4 D5 0.72 5 D4 0.65 discard scores, keep only rank 18.4 0.93 1 1/(60+1) = 1/61 2 1/(60+2) = 1/62 3 4 5 (same 1/(60+r) pattern for ranks 3-5) Fused (RRF) 1 D1 0.0325 (BM25 #2 + Dense #1) 2 D2 0.0323 3 D3 0.0320 4 D4 0.0310 (tie) 5 D5 0.0310 (tie)
RRF fuses BM25 and dense rankings by rank ordinal, not by raw score, so the two scales never have to be compared. BM25 wins on exact-token matches (rare IDs, SKUs); dense retrieval wins on paraphrase. Both lists discard their raw scores and collapse onto the same 1-5 rank ladder, weighted 1/(60+rank); D1, second by BM25 but first by dense, sums to the highest fused score and rises to rank 1.
# hybrid_search.py — BM25 + dense retrieval with RRF fusion
from __future__ import annotations
from dataclasses import dataclass, field
from collections import defaultdict
from typing import Callable
import math
import numpy as np


@dataclass
class Document:
    id: str
    text: str
    embedding: np.ndarray | None = field(default=None, repr=False)


# ─── BM25 implementation ────────────────────────────────────────────────────

class BM25Index:
    """Minimal BM25 index for a list of Documents."""

    def __init__(self, docs: list[Document], k1: float = 1.5, b: float = 0.75):
        self.docs = docs
        self.k1, self.b = k1, b
        self._build(docs)

    def _build(self, docs: list[Document]) -> None:
        # Term frequency per document
        self.tf: list[dict[str, int]] = []
        self.doc_len: list[int] = []
        self.df: dict[str, int] = defaultdict(int)

        for doc in docs:
            tokens = doc.text.lower().split()
            freq: dict[str, int] = defaultdict(int)
            for tok in tokens:
                freq[tok] += 1
            self.tf.append(dict(freq))
            self.doc_len.append(len(tokens))
            for tok in freq:
                self.df[tok] += 1

        self.N = len(docs)
        self.avgdl = sum(self.doc_len) / max(self.N, 1)

    def _idf(self, term: str) -> float:
        n = self.df.get(term, 0)
        return math.log((self.N - n + 0.5) / (n + 0.5) + 1.0)

    def score(self, query: str, doc_idx: int) -> float:
        tokens = query.lower().split()
        dl = self.doc_len[doc_idx]
        score = 0.0
        for tok in tokens:
            f = self.tf[doc_idx].get(tok, 0)
            idf = self._idf(tok)
            denom = f + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
            score += idf * (f * (self.k1 + 1)) / denom
        return score

    def search(self, query: str, top_k: int = 10) -> list[tuple[int, float]]:
        """Return (doc_idx, bm25_score) sorted descending."""
        scores = [(i, self.score(query, i)) for i in range(self.N)]
        scores.sort(key=lambda x: -x[1])
        return scores[:top_k]


# ─── Dense retrieval ─────────────────────────────────────────────────────────

def dense_search(
    query_embedding: np.ndarray,
    doc_embeddings: np.ndarray,  # (N, D)
    top_k: int = 10,
) -> list[tuple[int, float]]:
    """Return (doc_idx, cosine_sim) sorted descending."""
    sims = doc_embeddings @ query_embedding  # dot product = cosine sim if normalized
    idx = np.argsort(-sims)[:top_k]
    return [(int(i), float(sims[i])) for i in idx]


# ─── Reciprocal Rank Fusion ──────────────────────────────────────────────────

def reciprocal_rank_fusion(
    rankings: list[list[tuple[int, float]]],
    k: int = 60,
) -> list[tuple[int, float]]:
    """
    Fuse multiple ranked lists via RRF.

    Args:
        rankings: Each element is a list of (doc_idx, score) sorted by score desc.
                  The actual scores are ignored; only ranks matter.
        k:        RRF smoothing constant (default 60, from Cormack et al. 2009).

    Returns:
        Fused list of (doc_idx, rrf_score) sorted descending.
    """
    rrf_scores: dict[int, float] = defaultdict(float)
    for ranked_list in rankings:
        for rank, (doc_idx, _score) in enumerate(ranked_list, start=1):
            rrf_scores[doc_idx] += 1.0 / (k + rank)

    fused = sorted(rrf_scores.items(), key=lambda x: -x[1])
    return fused


# ─── Putting it together ──────────────────────────────────────────────────────

def hybrid_search(
    query: str,
    query_embedding: np.ndarray,
    bm25_index: BM25Index,
    doc_embeddings: np.ndarray,
    top_k: int = 10,
    rrf_k: int = 60,
) -> list[tuple[Document, float]]:
    """
    Hybrid BM25 + dense search with RRF fusion.

    Returns top_k (Document, rrf_score) pairs sorted by descending fused rank.
    """
    bm25_results = bm25_index.search(query, top_k=top_k * 2)
    dense_results = dense_search(query_embedding, doc_embeddings, top_k=top_k * 2)

    fused = reciprocal_rank_fusion([bm25_results, dense_results], k=rrf_k)
    docs = bm25_index.docs
    return [(docs[idx], score) for idx, score in fused[:top_k]]

Worked example: RRF magnitudes

Suppose we have 5 documents. BM25 ranks them [D2, D1, D3, D4, D5] and dense retrieval ranks them [D1, D3, D2, D5, D4].

With \(k=60\):

Doc BM25 rank Dense rank BM25 term Dense term RRF score
D1 2 1 1/62 1/61 0.0323
D2 1 3 1/61 1/63 0.0321
D3 3 2 1/63 1/62 0.0319
D4 4 5 1/64 1/65 0.0311
D5 5 4 1/65 1/64 0.0311

D1 wins in the fused ranking despite being second in BM25, because its dense rank of 1 contributes a large term. The \(k=60\) constant prevents any single top-1 ranking from dominating completely; increasing \(k\) makes the fusion more conservative and score-stable.

3.2 Learned Sparse Retrieval (SPLADE)

BM25 and dense retrieval fail in opposite directions, and there is a third architecture that tries to get both properties at once. Learned sparse retrieval keeps the representation sparse — a vector over the tokenizer’s vocabulary, so it can live in an inverted index — but learns the weights with a transformer instead of counting term frequencies.

SPLADE (Formal et al., SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking, SIGIR 2021) is the canonical instance. Run the text through a masked-language-model head, which produces a logit \(w_{ij}\) for every vocabulary term \(j\) at every input position \(i\). Then saturate and max-pool over positions:

\[ w_j \;=\; \max_{i \in \text{seq}} \; \log\!\left(1 + \operatorname{ReLU}(w_{ij})\right) \]

The \(\log(1+\cdot)\) is a saturation function borrowed from BM25’s term-frequency damping, and the ReLU forces non-negativity so that most of the ~30k vocabulary entries are exactly zero. Sparsity is not a happy accident — it is trained in, with a FLOPS regularizer that penalizes the expected cost of the posting lists, tuned so a typical passage keeps on the order of a hundred non-zero terms.

The payoff is term expansion: a passage about “myocardial infarction” gets non-zero weight on heart and attack even though those strings never appear, so a lexical index can now match a paraphrase. Scoring is still a sparse dot product, so it runs on the same inverted-index machinery as BM25 — and because the output dimensions are vocabulary items, every score is directly inspectable, which BM25 shares and dense embeddings do not.

sentence-transformers v5 (2025) added a first-class SparseEncoder class for exactly this family:

# splade_retrieval.py — learned sparse retrieval with sentence-transformers v5+
# pip install "sentence-transformers>=5.0"
from sentence_transformers import SparseEncoder

model = SparseEncoder("naver/splade-v3")          # MLM head + SpladePooling

corpus = [
    "CVE-2023-44487 is the HTTP/2 Rapid Reset denial-of-service vulnerability.",
    "Reciprocal rank fusion combines ranked lists without score normalization.",
]
doc_emb = model.encode_document(corpus)           # sparse tensors, |V|-dimensional
query_emb = model.encode_query("http2 rapid reset attack")

scores = model.similarity(query_emb, doc_emb)     # sparse dot product
print(scores)

# Inspect *why* a document scored: the non-zero dimensions are vocabulary terms.
print(model.decode(doc_emb[0], top_k=8))          # e.g. [('http', 2.1), ('reset', 1.9), ...]

Where does it sit in the pipeline? SPLADE is a first-stage retriever, a drop-in replacement for (or third leg alongside) BM25 — you fuse its ranking with the dense ranking using the same RRF from Section 3.1, then rerank with a cross-encoder. Its costs are real: encoding requires a transformer forward pass at both index and query time, and the expansion terms lengthen posting lists, so query latency is meaningfully above BM25’s. The honest 2026 summary is that a hybrid of BM25 + a strong dense encoder remains the pragmatic default, and learned sparse is the upgrade you reach for when exact-match recall matters and your queries are paraphrase-heavy — or when you need the interpretability of a lexical index without giving up semantic matching.


4. Cross-Encoder Rerankers

The retrieval stage (BM25 or dense) must be fast — often processing millions of documents in tens of milliseconds. Speed requires a bi-encoder architecture: query and document are embedded independently, and similarity is a cheap dot product. The downside: the query and document tokens never “see” each other during encoding, so nuanced relevance signals (especially multi-hop or contrastive relevance) can be missed.

A cross-encoder receives the concatenation [query; document] as a single sequence and produces a scalar relevance score. Because every query token attends to every document token, the model can reason about fine-grained relevance at the cost of \(O(N)\) forward passes (one per candidate). This makes cross-encoders unsuitable for first-stage retrieval but ideal for reranking a small shortlist of, say, 20–100 candidates.

These are the two ends of an axis, and there is a well-defined middle: late interaction (ColBERT) keeps one vector per token rather than one per document, precomputes all document token vectors offline like a bi-encoder, and scores with a MaxSim operator that recovers much of the cross-encoder’s token-level matching. It costs roughly one to two orders of magnitude more index storage in exchange for cross-encoder-like quality at first-stage speed. We develop it in full — including the visual-document variant ColPali — in Multimodal & Visual-Document Retrieval: ColPali & Late Interaction.

The two-stage pipeline:

Query Hybrid Retrieval BM25 + dense returns top-100 candidates 100 fast cheap recall 100 candidates Cross-Encoder Reranker scores all 100 [query; passage] pairs returns top-5 every query token attends to every document token 5 slower ~1 pass each top-5 only LLM Context Window top-5 chunks 5 Stage 1: fast, cheap, broad recall (100 candidates) Stage 2: slower, expensive, precise scoring (top-5) Cost/precision trade-off: cheap bi-encoder recall then expensive cross-encoder precision eliminates the need for cross-encoding against the entire corpus
Two-stage retrieve-then-rerank narrows 100 candidates to 5 with a cost-conscious funnel. The fast, cheap first stage (bi-encoder hybrid BM25 + dense) casts a wide net for recall; the slower, expensive second stage (cross-encoder, one pass per candidate) runs only on the shortlist to achieve high precision. Only the 5 winning chunks reach the LLM context window, keeping prompt costs low while the cross-encoder's joint query-passage attention ensures relevance quality.
# cross_encoder_rerank.py — cross-encoder reranking with sentence-transformers
from __future__ import annotations
from sentence_transformers import CrossEncoder
import numpy as np


def load_reranker(
    model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
) -> CrossEncoder:
    """
    Load a cross-encoder ONCE, at process startup.

    Common 2026 choices, cheapest first:
      - 'cross-encoder/ms-marco-MiniLM-L-6-v2'   English, 6 layers, ~ms latency
      - 'BAAI/bge-reranker-v2-m3'                multilingual, 568M, strong default
      - 'mixedbread-ai/mxbai-rerank-base-v2'     multilingual, instruction-aware
      - 'Qwen/Qwen3-Reranker-0.6B'               LLM-initialized, prompt-conditioned,
                                                 long context (needs its own API;
                                                 see the note after this listing)
    """
    return CrossEncoder(model_name, max_length=512)


def rerank(
    query: str,
    candidates: list[str],
    model: CrossEncoder,
    top_k: int = 5,
    batch_size: int = 32,
) -> list[tuple[str, float]]:
    """
    Rerank a list of candidate passages using a preloaded cross-encoder.

    Args:
        query:      The user query string.
        candidates: List of passage strings to rerank.
        model:      A CrossEncoder from load_reranker(). Passing the *model*
                    rather than a model name is deliberate: constructing a
                    CrossEncoder pulls weights from disk and moves them to the
                    GPU, which costs hundreds of milliseconds to seconds — far
                    more than the reranking itself. Do it once per process,
                    never once per query.
        top_k:      Number of top passages to return after reranking.
        batch_size: Batch size for model inference.

    Returns:
        List of (passage_text, relevance_score) sorted by descending score.

    Notes:
        - Scores are logits (not probabilities) from the final classification
          head, unless the model card specifies a sigmoid activation. They are
          comparable *within* one query's candidate list and meaningless across
          queries or across models — never threshold on a raw logit without
          calibrating on your own data first.
        - Higher score = more relevant.
    """
    if not candidates:
        return []

    # Build (query, passage) pairs — one per candidate
    pairs = [(query, passage) for passage in candidates]

    # Score all pairs; model handles batching internally
    scores: np.ndarray = model.predict(pairs, batch_size=batch_size)

    # Sort by descending score
    ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
    return ranked[:top_k]


# --- Illustrative latency numbers ---
# On a single A100:
#   cross-encoder/ms-marco-MiniLM-L-6-v2, 100 candidates, avg passage 200 tokens:
#   ~60ms per query batch
# On CPU (Intel Xeon):
#   ~400ms for the same 100 candidates

Practitioner tip: two APIs, one job

The CrossEncoder interface above covers every classical BERT-style reranker (ms-marco-*, bge-reranker-*, jina-reranker-*). The 2025–2026 generation of LLM-initialized rerankers — Qwen3-Reranker, mxbai-rerank-v2 — is architecturally different: relevance is read out of a causal LM as the logit of a “yes”/”no” token given a prompt containing an instruction, the query and the document. That buys prompt conditioning (“rank by whether the passage contains a numeric answer”) and long context, and costs a full decoder forward pass per pair. Load these with AutoModelForCausalLM per their model card, or let a wrapper hide the difference: the rerankers library (AnswerDotAI) gives one Reranker(...).rank(query, docs) call over cross-encoders, ColBERT, LLM rerankers and hosted APIs alike, and FlagEmbedding (BAAI) ships the reference loaders for the bge-reranker family. In production, serve the reranker off the request path entirely — HuggingFace Text Embeddings Inference hosts reranker models behind a /rerank endpoint with continuous batching.

4.1 Training Your Own Reranker

If you have domain-specific relevance labels (from click logs, human annotations, or distillation from a more powerful model), you can fine-tune a cross-encoder with a binary cross-entropy loss on (query, positive, negative) triples, or a listwise ranking loss like LambdaLoss/ListNet.

Since sentence-transformers v4 (2025), cross-encoder training uses a HuggingFace-Trainer-shaped API — CrossEncoderTrainer over a datasets.Dataset, with the loss supplied as an object. (The old model.fit(train_dataloader=...) call still exists as a compatibility shim, but new code should use the trainer: it inherits mixed precision, gradient accumulation, checkpointing and wandb logging from transformers for free.)

# reranker_finetune.py — domain-adapting a cross-encoder (sentence-transformers v4+)
# pip install "sentence-transformers>=4.0" datasets
from datasets import Dataset
from sentence_transformers.cross_encoder import (
    CrossEncoder,
    CrossEncoderTrainer,
    CrossEncoderTrainingArguments,
)
from sentence_transformers.cross_encoder.losses import BinaryCrossEntropyLoss

# Columns are positional: (text_a, text_b, label). Names are free-form.
train_dataset = Dataset.from_dict({
    "query": ["What is RRF?", "What is RRF?"],
    "passage": [
        "Reciprocal rank fusion combines ranked lists using 1/(k+rank).",
        "The capital of France is Paris.",
    ],
    "label": [1.0, 0.0],   # 1 = relevant, 0 = hard negative
})

# num_labels=1 → a single relevance logit (regression/BCE head), not classification.
model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", num_labels=1)
loss = BinaryCrossEntropyLoss(model)

args = CrossEncoderTrainingArguments(
    output_dir="./my-domain-reranker",
    num_train_epochs=1,          # 1 epoch is usually enough for domain adaptation
    per_device_train_batch_size=16,
    learning_rate=2e-5,
    warmup_ratio=0.1,
    fp16=True,                   # bf16=True on Ampere+ / H100
)

trainer = CrossEncoderTrainer(model=model, args=args,
                              train_dataset=train_dataset, loss=loss)
trainer.train()
model.save_pretrained("./my-domain-reranker/final")

The data matters more than the loss. Pointwise BCE on random negatives teaches the model almost nothing, because random negatives are trivially separable. Mine hard negatives: run your existing first-stage retriever, take documents ranked 10–100 that are not labelled relevant, and use those as the zeros — they are exactly the confusions the reranker exists to fix. The usual ratio is 4–8 hard negatives per positive. If you have graded relevance or a teacher model’s scores, prefer a listwise loss (LambdaLoss, ListNetLoss) over BCE: it optimizes the ordering of a candidate list, which is what nDCG actually measures, rather than each pair’s absolute label. Distilling a large reranker’s scores into a MiniLM-sized student with MarginMSELoss is the standard way to get most of the quality at a fraction of the latency — see Distillation, Model Compression & Knowledge Transfer.


5. Query Rewriting and HyDE

The query the user types is rarely optimal for retrieval. It may be short, colloquial, or implicit (assuming context from earlier conversation turns). Two complementary techniques address this.

5.1 Query Rewriting and Expansion

Multi-query expansion generates several paraphrases of the original query, retrieves for each, and unions the result sets (deduplicating by document ID). This dramatically improves recall for queries that can be stated multiple ways.

# query_rewriting.py — LLM-powered multi-query expansion
from __future__ import annotations
import json
import re


REWRITE_PROMPT = """\
You are a retrieval expert. Given a user question, produce 3 alternative phrasings
that together cover the semantic space of the question. Return a JSON list of strings.

User question: {question}

Alternative phrasings (JSON list):"""


def expand_query(
    question: str,
    llm_call: callable,  # fn(prompt: str) -> str
    n_variants: int = 3,
) -> list[str]:
    """
    Use an LLM to generate query variants for multi-query retrieval.

    Args:
        question:   Original user question.
        llm_call:   Callable that takes a prompt and returns a completion string.
        n_variants: Number of alternative phrasings requested (soft limit).

    Returns:
        List of query strings including the original.
    """
    prompt = REWRITE_PROMPT.format(question=question)
    response = llm_call(prompt)

    # Extract JSON list from LLM output (handle markdown code fences)
    json_match = re.search(r"\[.*?\]", response, re.DOTALL)
    if json_match:
        try:
            variants = json.loads(json_match.group())
            if isinstance(variants, list):
                return [question] + variants[:n_variants]
        except json.JSONDecodeError:
            pass

    return [question]  # fallback: return original query only

5.2 HyDE — Hypothetical Document Embeddings

HyDE (Gao et al., 2022) takes a different approach: instead of embedding the (short, vague) query, ask the LLM to generate a hypothetical document that would answer the query, then embed that document and retrieve using its embedding.

The intuition: a hypothetical answer document and a real answer document inhabit closer regions of embedding space than the query and the answer document do, because they share vocabulary, entity mentions, and syntactic structure.

HyDE: query->document matching becomes document->document matching embedding space (schematic) real answer docs (formal / technical vocabulary) raw query (short, colloquial) query <-> doc gap: far LLM hyp <-> doc: close embed the hypothesis (not the query); retrieve the real docs in its neighborhood. the hypothesis text is used only for its embedding -- never fed to the generator.
HyDE turns query->document matching into document->document matching. The raw query sits far from the real answer documents in embedding space, but an LLM-generated hypothetical answer shares enough vocabulary and structure to land inside their neighborhood, so retrieval embeds and searches with the hypothesis instead of the query. The hypothesis itself is never shown to the generator -- only its embedding is used to find the real, grounded documents.
# hyde.py — Hypothetical Document Embeddings for improved dense retrieval
from __future__ import annotations
import numpy as np


HYDE_PROMPT = """\
Write a short, factual paragraph (2-4 sentences) that directly answers the
following question. Do not say "I don't know" — write the best answer you can,
even if uncertain.

Question: {question}

Answer:"""


def hyde_embed(
    question: str,
    llm_call: callable,           # fn(prompt: str) -> str
    embed_fn: callable,           # fn(list[str]) -> np.ndarray
    n_hypotheses: int = 1,
) -> np.ndarray:
    """
    Generate HyDE embedding for a question.

    When n_hypotheses > 1, generate multiple hypothetical documents and
    average their embeddings. This reduces variance from stochastic generation.

    Args:
        question:      User query.
        llm_call:      LLM completion function.
        embed_fn:      Embedding function mapping list[str] -> (N, D) array.
        n_hypotheses:  Number of hypothetical documents to generate.

    Returns:
        Averaged embedding vector of shape (D,), L2-normalized.
    """
    hypotheses = []
    prompt = HYDE_PROMPT.format(question=question)
    for _ in range(n_hypotheses):
        hypothetical_doc = llm_call(prompt)
        hypotheses.append(hypothetical_doc.strip())

    # Embed all hypotheses and average
    embeddings = embed_fn(hypotheses)  # (n_hypotheses, D)
    mean_embedding = embeddings.mean(axis=0)

    # L2-normalize so downstream cosine search still works
    norm = np.linalg.norm(mean_embedding)
    return mean_embedding / (norm + 1e-9)


# --- When to use HyDE vs raw query embedding ---
#
# Use HyDE when:
#  - Your corpus uses formal or technical language that differs from query style
#  - Queries are short and ambiguous (e.g., "transformer memory management")
#  - You can afford 1-2 extra LLM calls per query (latency budget ~200ms)
#
# Avoid HyDE when:
#  - Queries are already long and specific
#  - LLM hallucinations in the hypothesis could mislead retrieval
#  - Low-latency SLA (< 50ms) prevents extra LLM calls

HyDE hallucination risk

The LLM’s hypothetical document will contain plausible-sounding but possibly incorrect facts. This is fine for retrieval (you only use the embedding, not the text), but be careful not to accidentally include the hypothesis in the LLM context. Always retrieve from the real corpus using the HyDE embedding, then pass the retrieved real documents to the generator.


6. Metadata Filtering

Purely semantic retrieval treats all documents as equally eligible. In practice, many queries have hard constraints: “only show me documents from Q4 2024”, “only from the ‘legal’ category”, “only for product model XYZ-500”.

Metadata filtering applies these constraints before or during the ANN search, drastically reducing the candidate pool and improving precision.

6.1 Pre-filtering vs Post-filtering

Pre-filter (filter before ANN search): restrict the index to only documents matching the metadata predicate, then run ANN on that subset. This is exact but slow if the filter is selective (you need an efficient inverted index on metadata fields, not just vectors).

Post-filter (retrieve top-k, then filter): fast, but you may need to retrieve a large k to guarantee that filtered results cover the top-k meaningful hits. Risk: if the filter is very selective, you waste most of your retrieval budget.

Modern vector databases (Qdrant, Pinecone, Weaviate, Milvus) support hybrid pre/post-filtering with HNSW graph filterable attributes. For implementation details see Vector Databases & Approximate Nearest Neighbor Search.

# metadata_filtering.py — metadata-aware retrieval with Qdrant
from __future__ import annotations
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
import numpy as np


def filtered_search(
    client: QdrantClient,
    collection_name: str,
    query_vector: np.ndarray,
    filters: dict,              # simplified filter spec (see below)
    top_k: int = 10,
) -> list[dict]:
    """
    Perform vector search with metadata pre-filtering in Qdrant.

    Args:
        client:          Qdrant client connected to your instance.
        collection_name: Name of the Qdrant collection.
        query_vector:    Query embedding, shape (D,).
        filters:         Dict with optional keys:
                         - 'category': str (exact match)
                         - 'date_after': int (Unix timestamp)
                         - 'date_before': int (Unix timestamp)
        top_k:           Number of results to return.

    Returns:
        List of payload dicts for matched documents.

    Qdrant supports filtering directly on HNSW traversal, making pre-filtering
    efficient even on large corpora (millions of documents).
    """
    conditions = []

    if "category" in filters:
        conditions.append(
            FieldCondition(key="category", match=MatchValue(value=filters["category"]))
        )

    if "date_after" in filters or "date_before" in filters:
        conditions.append(
            FieldCondition(
                key="timestamp",
                range=Range(
                    gte=filters.get("date_after"),
                    lte=filters.get("date_before"),
                ),
            )
        )

    qdrant_filter = Filter(must=conditions) if conditions else None

    # `query_points` is the current unified Query API (qdrant-client >= 1.10);
    # the older `client.search(...)` is deprecated but still functional. The
    # same endpoint also serves hybrid retrieval: pass `prefetch=[...]` with a
    # dense and a sparse sub-query plus `query=FusionQuery(fusion=Fusion.RRF)`
    # to run Section 3.1's fusion server-side.
    response = client.query_points(
        collection_name=collection_name,
        query=query_vector.tolist(),
        query_filter=qdrant_filter,
        limit=top_k,
        with_payload=True,
    )

    return [hit.payload for hit in response.points]

6.2 Parent-Child Document Retrieval

A useful pattern: index fine-grained child chunks (e.g., single sentences or 128-token windows) for high-precision retrieval, but when a child chunk is retrieved, return its parent document (e.g., the full paragraph or section) as the LLM context. This gives the LLM the surrounding context it needs while keeping the retrieval signal sharp.

# parent_child_retrieval.py — parent document retrieval pattern
from __future__ import annotations
from dataclasses import dataclass
from typing import Any


@dataclass
class ChildChunk:
    id: str
    parent_id: str   # foreign key to parent document
    text: str        # small chunk used for embedding/retrieval
    embedding: Any   # np.ndarray


@dataclass
class ParentDocument:
    id: str
    text: str        # larger context returned to the LLM
    metadata: dict


def parent_child_retrieve(
    query_embedding,
    child_index,           # your vector index over ChildChunk objects
    parent_store: dict,    # parent_id -> ParentDocument
    top_k: int = 5,
) -> list[ParentDocument]:
    """
    Retrieve top-k child chunks, then return their parent documents.

    Deduplicates parents so the same parent is not returned twice even if
    multiple of its children ranked highly.
    """
    # Retrieve top-k*3 children to ensure we have enough unique parents
    child_hits = child_index.search(query_embedding, top_k=top_k * 3)

    seen_parent_ids: set[str] = set()
    parents: list[ParentDocument] = []

    for child in child_hits:
        pid = child.parent_id
        if pid not in seen_parent_ids:
            seen_parent_ids.add(pid)
            if pid in parent_store:
                parents.append(parent_store[pid])
        if len(parents) >= top_k:
            break

    return parents

Both frameworks ship this pattern: LangChain’s ParentDocumentRetriever pairs a vector store of child chunks with a docstore of parents, and LlamaIndex offers two variants — SentenceWindowNodeParser (retrieve a sentence, return a window of \(\pm k\) sentences around it) and AutoMergingRetriever (retrieve leaf chunks over a hierarchy, and when enough siblings hit, transparently substitute the parent). The AutoMergingRetriever behaviour is the one worth stealing if you build your own: returning a parent only when several of its children ranked highly is a much better signal than promoting on a single hit.

One thing this pattern does not fix is redundancy. Promoting parents deduplicates by document ID, but five distinct chunks from five near-identical documents will still all survive into the prompt, spending context budget on the same fact. The standard remedy is a diversity-aware selection step — Maximal Marginal Relevance, which greedily trades query relevance against novelty relative to what has already been selected — applied after reranking and before prompt assembly. MMR is derived and implemented in Retrieval-Augmented Generation Architectures.


7. Putting It All Together: A Production RAG Pipeline

Here is a complete, annotated pipeline that combines all the techniques discussed above, with configurable stages.

# rag_pipeline.py — production-grade RAG pipeline
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
import numpy as np
import logging

logger = logging.getLogger(__name__)


@dataclass
class RAGConfig:
    # Chunking
    chunk_size: int = 512
    chunk_overlap: int = 64
    chunking_strategy: str = "fixed"  # "fixed" | "semantic" | "structural"

    # Retrieval
    bm25_top_k: int = 40          # BM25 candidates before fusion
    dense_top_k: int = 40         # Dense candidates before fusion
    rrf_k: int = 60                # RRF smoothing constant

    # Query expansion
    use_hyde: bool = False          # enable HyDE
    n_query_variants: int = 1       # 1 = no expansion

    # Reranking
    use_reranker: bool = True
    reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
    rerank_candidates: int = 50     # fused hits actually scored by the cross-encoder
    reranker_top_k: int = 5         # final chunks passed to LLM

    # Metadata filter
    metadata_filter: dict = field(default_factory=dict)


@dataclass
class RAGResult:
    query: str
    retrieved_chunks: list[str]
    rrf_scores: list[float]
    reranker_scores: list[float]
    hyde_hypothesis: str | None = None


def run_rag_pipeline(
    query: str,
    bm25_index,           # BM25Index from Section 3
    dense_index,          # vector store with .search(embedding, top_k) -> list[tuple[int, float]]
    embed_fn: Callable,   # list[str] -> np.ndarray of shape (N, D)
    llm_fn: Callable,     # str -> str (for HyDE / query expansion)
    reranker,             # a preloaded CrossEncoder from load_reranker(), or None
    config: RAGConfig,
) -> RAGResult:
    """
    Full RAG retrieval pipeline: query expansion → hybrid retrieval → rerank.
    """

    # ── Step 1: Query representation ──────────────────────────────────────────
    hyde_hypothesis = None
    if config.use_hyde:
        query_embedding = hyde_embed(query, llm_fn, embed_fn)
        hyde_hypothesis = "(HyDE embedding used)"
        logger.info("Using HyDE embedding for query: %s", query[:60])
    else:
        query_embedding = embed_fn([query])[0]

    # ── Step 2: Hybrid retrieval ───────────────────────────────────────────────
    bm25_results = bm25_index.search(query, top_k=config.bm25_top_k)
    dense_results_raw = dense_index.search(query_embedding, top_k=config.dense_top_k)
    # Normalize dense results to (idx, score) format (indices must match the
    # corpus indices used by bm25_index.docs, same contract as dense_search())
    dense_results = [(int(idx), float(score)) for idx, score in dense_results_raw]

    fused = reciprocal_rank_fusion(
        [bm25_results, dense_results], k=config.rrf_k
    )
    # Map back to document texts
    all_docs = bm25_index.docs
    fused_chunks = [(all_docs[idx].text, score) for idx, score in fused]
    rrf_scores = [s for _, s in fused_chunks]
    candidate_texts = [t for t, _ in fused_chunks]

    logger.info(
        "Hybrid retrieval: %d BM25 + %d dense → %d fused candidates",
        config.bm25_top_k, config.dense_top_k, len(fused_chunks),
    )

    # ── Step 3: Reranking ──────────────────────────────────────────────────────
    if config.use_reranker and reranker is not None:
        # Note we hand `rerank` the already-loaded model. Only the top
        # `rerank_candidates` fused hits are scored: cross-encoder cost is
        # linear in the candidate count, so this cap is the pipeline's main
        # latency knob (see the Exercise 5 arithmetic).
        reranked = rerank(
            query, candidate_texts[:config.rerank_candidates],
            model=reranker,
            top_k=config.reranker_top_k,
        )
        final_chunks = [t for t, _ in reranked]
        reranker_scores = [float(s) for _, s in reranked]
    else:
        final_chunks = candidate_texts[:config.reranker_top_k]
        reranker_scores = rrf_scores[:config.reranker_top_k]

    return RAGResult(
        query=query,
        retrieved_chunks=final_chunks,
        rrf_scores=rrf_scores[:config.reranker_top_k],
        reranker_scores=reranker_scores,
        hyde_hypothesis=hyde_hypothesis,
    )

7.1 Choosing Configuration for Your Domain

The table below summarizes empirical guidance for configuring the pipeline. These are starting points, not guarantees — always evaluate on your domain.

Domain characteristic Recommended setting
Precise entity lookup (medical codes, SKUs, legal citations) BM25 weight high; use hybrid with RRF; add metadata filter on document type
Conversational QA over prose Dense retrieval dominant; enable HyDE; semantic chunking
Long documents with clear section structure Structural chunking + parent-child retrieval
Multilingual corpus Multilingual encoder and reranker (e.g. BAAI/bge-m3 + BAAI/bge-reranker-v2-m3, or the Qwen3-Embedding/Qwen3-Reranker pair); note BM25 needs per-language tokenization and stemming to be worth anything
Paraphrase-heavy queries over technical text Add a learned-sparse leg (SPLADE) as a third ranker in the RRF fusion (Section 3.2)
Low-latency SLA (< 100ms) Skip HyDE; use MiniLM reranker or skip reranking; pre-filter metadata
High-stakes accuracy requirement Enable all stages: HyDE + hybrid + full reranker; re-evaluate every 30 days

8. Diagnostics and Iteration

A RAG pipeline has many knobs, and it is easy to tune one component in isolation while unknowingly degrading another. The rigorous approach is to evaluate each stage separately with stage-specific metrics, then measure the end-to-end quality.

# rag_diagnostics.py — evaluate each pipeline stage independently
from __future__ import annotations
from typing import NamedTuple


class RetrievalMetrics(NamedTuple):
    recall_at_k: float        # fraction of queries where relevant doc is in top-k
    mrr: float                # mean reciprocal rank
    ndcg_at_k: float          # normalized discounted cumulative gain at k
    latency_p50_ms: float
    latency_p99_ms: float


def compute_recall_at_k(
    relevant_ids: list[set[str]],    # per-query sets of relevant document ids
    retrieved_ids: list[list[str]],  # per-query ranked retrieved document ids
    k: int,
) -> float:
    """
    Recall@k: for each query, was at least one relevant document in top-k?
    Averaged over all queries.
    """
    assert len(relevant_ids) == len(retrieved_ids), "Must align by query"
    hits = sum(
        1 for rel, ret in zip(relevant_ids, retrieved_ids)
        if rel & set(ret[:k])
    )
    return hits / len(relevant_ids)


def compute_mrr(
    relevant_ids: list[set[str]],
    retrieved_ids: list[list[str]],
) -> float:
    """
    Mean Reciprocal Rank: reciprocal of rank of first relevant document.
    """
    rrs = []
    for rel, ret in zip(relevant_ids, retrieved_ids):
        for rank, doc_id in enumerate(ret, start=1):
            if doc_id in rel:
                rrs.append(1.0 / rank)
                break
        else:
            rrs.append(0.0)
    return sum(rrs) / len(rrs)


def compute_ndcg_at_k(
    relevance: list[dict[str, float]],  # per-query {doc_id: graded relevance}
    retrieved_ids: list[list[str]],
    k: int = 10,
) -> float:
    """
    nDCG@k — the metric to report when relevance is *graded* rather than binary.

    DCG@k = sum_{i=1..k} (2^rel_i - 1) / log2(i + 1)

    The gain is exponential in the grade (so a "perfect" hit is worth much more
    than two "partial" ones) and the discount is logarithmic in the rank (so
    moving a good document from position 5 to position 1 matters, but position
    50 to 45 barely does). Dividing by the IDCG — the DCG of the ideal ordering
    — normalizes to [0, 1] so scores are comparable across queries with
    different numbers of relevant documents.

    This is the metric BEIR reports (nDCG@10), and the one to optimize if your
    reranker will be trained with a listwise loss.
    """
    import math

    def dcg(grades: list[float]) -> float:
        return sum((2.0 ** g - 1.0) / math.log2(i + 2) for i, g in enumerate(grades))

    scores = []
    for rel, ret in zip(relevance, retrieved_ids):
        gains = [rel.get(doc_id, 0.0) for doc_id in ret[:k]]
        ideal = sorted(rel.values(), reverse=True)[:k]
        idcg = dcg(ideal)
        scores.append(dcg(gains) / idcg if idcg > 0 else 0.0)
    return sum(scores) / max(len(scores), 1)


# Typical baseline numbers to aim for on a reasonably clean corpus:
#   Recall@10:  > 0.85 (retrieval stage)
#   MRR:        > 0.70
#   After rerank, Recall@5: > 0.80

Do not hand-roll these for anything that leaves your laptop. ir_measures and pytrec_eval wrap the reference TREC implementations, which handle the tie-breaking and unjudged-document conventions that make published numbers comparable; the BEIR harness (and the retrieval slice of MTEB) will run your retriever over 18+ standard datasets and report nDCG@10 in the same format every paper uses. Above the retrieval layer, RAGAS scores the generation half reference-free (faithfulness, answer relevancy, context precision/recall) — but read Section 8’s ordering literally: a faithfulness score is uninterpretable until you know the relevant chunk was retrieved at all.

Build the eval set before you tune anything. Fifty to a hundred (query, gold-chunk-id) pairs is enough to rank configurations, and you can bootstrap them cheaply: sample chunks from your own corpus, ask an LLM to write the question that chunk uniquely answers, then filter by having a human confirm the question is answerable from that chunk alone. Without this, every knob in RAGConfig is tuned by vibes.

Interview Corner

Q: You have a RAG system where retrieval recall is high but generation quality is poor — the LLM often ignores the retrieved context or produces hallucinations. What might be wrong, and how would you debug it?

A: Several failure modes can cause this. First, check chunk quality: if chunks are too large, the relevant sentence may be buried in noise; if too small, they may lack the context the LLM needs to interpret them. Second, check for retrieval-generation mismatch: the retrieved chunks might be superficially relevant (high embedding similarity) but not actually answer the question — a cross-encoder reranker that scores precise relevance often fixes this. Third, inspect the prompt format: if the context is pasted at the end of a very long prompt, the LLM may exhibit lost-in-the-middle behavior (Liu et al., 2023) and downweight it; try placing the most relevant chunks at the top or bottom of the context window. Fourth, consider whether a faithfulness metric (e.g., RAGAS) shows the answer is entailed by the retrieved text — if not, the retrieval is fetching the wrong documents regardless of cosine score. Finally, for factual queries, adding metadata filters to restrict freshness or source authority can dramatically improve generation quality by reducing noisy candidates.


Key Takeaways

  • Chunking strategy is the single highest-leverage RAG decision. Fixed chunking is fast but naive; semantic and structural chunking better preserve coherence. Late chunking gives context-aware embeddings at the cost of requiring the full document to fit in the encoder.
  • Hybrid search (BM25 + dense) consistently outperforms either alone. BM25 handles exact-match queries (rare terms, product codes, names); dense retrieval handles paraphrase and semantic queries. Reciprocal Rank Fusion (RRF) is a robust, parameter-light way to combine the two ranked lists. Learned sparse retrieval (SPLADE) is the third architecture: transformer-predicted term weights over the vocabulary, expanded with related terms, still served from an inverted index — fuse it in as a third ranker when queries are paraphrase-heavy but exact match still matters.
  • RRF only uses rank ordinals, not raw scores, making it immune to scale mismatches between BM25 and cosine similarities. The smoothing constant \(k=60\) prevents any single top-ranked document from dominating.
  • Cross-encoder rerankers improve precision dramatically at the cost of latency. Run retrieval with a large top-k (40–100), rerank with a cross-encoder, and pass only the top-5 to the LLM. The two-stage pipeline amortizes the encoder cost over a small set.
  • HyDE shifts the retrieval problem from query-to-document to document-to-document matching, which is easier for bi-encoders. Use it when your query vocabulary diverges from document vocabulary, but never include the hypothetical document in the LLM prompt — only use its embedding.
  • Metadata filtering is essential for production systems with heterogeneous corpora. Pre-filtering in the vector index (via Qdrant, Weaviate, etc.) is generally more efficient than post-filtering for selective predicates.
  • Parent-child retrieval gives you the best of both worlds: fine-grained retrieval signal from small child chunks and rich LLM context from large parent documents.
  • Measure each stage independently (Recall@k, MRR, nDCG@10) before optimizing end-to-end RAGAS or LLM judge scores. A component that looks good in isolation may be bottlenecked by the stage before it.
  • Write the mechanism once, then use the libraries. LangChain/LlamaIndex splitters for chunking, Docling or unstructured for parsing, bm25s/Pyserini/OpenSearch for the lexical leg, sentence-transformers (or rerankers/FlagEmbedding) for bi- and cross-encoders, TEI to serve them off the request path, and ir_measures/BEIR/RAGAS to score the result.

State of the Art & Resources (2026)

Hybrid retrieval (BM25 + dense) with cross-encoder reranking is now the production standard for RAG, with late chunking and LLM-based query rewriting closing the remaining gap between prototype and production quality. As of 2026 the reranker frontier has shifted from small MS-MARCO MiniLM cross-encoders toward instruction-tuned, LLM-initialized rerankers — Qwen3-Reranker (0.6B/4B/8B, up to 32k context) and mxbai-rerank-v2 now top the multilingual and code-retrieval boards — though a MiniLM cross-encoder remains the right default when latency dominates. Evaluation benchmarks like BEIR and the continuously updated MTEB leaderboard, together with frameworks like RAGAS, have made systematic pipeline comparison routine.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • huggingface/sentence-transformers — canonical Python library for bi-encoder embeddings and cross-encoder rerankers (the project moved from the UKPLab org to the Hugging Face org); includes cross-encoder/ms-marco-MiniLM-L-6-v2 and dozens of production-ready models.
  • jina-ai/late-chunking — reference implementation and evaluation code for the late chunking method.
  • xhluca/bm25s — ultrafast BM25 in pure Python backed by sparse matrices; orders of magnitude faster than rank-bm25 for large corpora. Reach for castorini/pyserini (Lucene) or an OpenSearch cluster when the lexical index outgrows one process.
  • AnswerDotAI/rerankers — one small API over cross-encoders, ColBERT, LLM rerankers and hosted rerank endpoints, so you can swap reranker families without rewriting the pipeline; FlagOpen/FlagEmbedding ships the reference BGE embedders and bge-reranker loaders.
  • naver/splade — reference implementation of learned sparse retrieval; the SparseEncoder class added in sentence-transformers v5 (2025) makes SPLADE-style models usable with the same API as bi-encoders.
  • DS4SD/docling and Unstructured-IO/unstructured — the document-parsing layer above chunking: PDFs, scans, tables and slides into structure-preserving Markdown.
  • RUC-NLPIR/FlashRAG — modular RAG research toolkit with 36 benchmark datasets and 23 RAG algorithms; excellent for ablating chunking/retrieval/reranking choices.

Go deeper

  • explodinggradients/ragas — the standard framework for reference-free RAG evaluation (faithfulness, answer relevancy, context precision); integrates with LangChain and LlamaIndex.

Further Reading

  • Robertson & Zaragoza, “The Probabilistic Relevance Framework: BM25 and Beyond” (2009) — the canonical reference for BM25 derivation and tuning.
  • Cormack, Clarke & Buettcher, “Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods” (SIGIR 2009) — original RRF paper with empirical comparisons.
  • Nogueira & Cho, “Passage Re-ranking with BERT” (2019) — introduced the cross-encoder reranking paradigm for neural IR; the MS MARCO models trace to this work.
  • Gao et al., “Precise Zero-Shot Dense Retrieval without Relevance Labels” (ACL 2022) — the HyDE paper, with ablations showing when it helps and when it hurts.
  • Günther et al., “Jina Embeddings 2: 8192-Token General-Purpose Text Embeddings for Long Documents” (2023) — introduces late chunking and benchmarks it against standard chunking.
  • Liu et al., “Lost in the Middle: How Language Models Use Long Contexts” (2023) — empirical evidence that LLMs underweight information placed in the middle of long contexts; directly motivates context ordering in RAG prompts.
  • Guu et al., “REALM: Retrieval-Augmented Language Model Pre-Training” (ICML 2020) — early end-to-end trainable RAG architecture that motivates the field.
  • Ma et al., “Query Rewriting in Retrieval-Augmented Large Language Models” (EMNLP 2023) — systematic study of query rewriting strategies including multi-query expansion.
  • Formal et al., “SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking” (SIGIR 2021), and “From Distillation to Hard Negative Sampling: Making Sparse Neural IR Models More Effective” (SIGIR 2022) — the learned-sparse line, including the hard-negative and distillation recipes that made it competitive.
  • Khattab & Zaharia, “ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT” (SIGIR 2020) — the late-interaction middle ground between bi- and cross-encoders.
  • Sentence Transformers library (huggingface/sentence-transformers) — the go-to Python package for cross-encoder models and bi-encoder fine-tuning; v4 introduced CrossEncoderTrainer, v5 added SparseEncoder for SPLADE-style models.

Exercises

1. A colleague argues that overlap between adjacent fixed-length chunks is wasteful — it duplicates tokens, inflates the index, and (with the whitespace tokenizer in fixed_chunk) makes the same words appear in two chunks. Give a concrete example of a retrieval failure that overlap prevents, and explain why the chapter recommends a small overlap (32–64 tokens) rather than either zero overlap or a very large one.

Solution

Consider a document where the sentence “Revenue rose because it increased by 12% in Q4” straddles a chunk boundary. With zero overlap, the split might put “Revenue rose because” at the end of chunk A and “it increased by 12% in Q4” at the start of chunk B. Now neither chunk is self-contained: chunk B contains the number 12% but its grammatical antecedent (“Revenue”) lives only in chunk A. A query like “how much did revenue grow in Q4?” may retrieve chunk B on the strength of “12%” and “Q4”, but the LLM sees a dangling “it” with no referent, exactly the context-loss failure the chapter warns about in Section 1.

A small overlap repeats the last 32–64 tokens of chunk A at the start of chunk B, so the antecedent “Revenue” is carried across the boundary and at least one chunk contains the complete thought.

Why not a large overlap? Overlap is pure redundancy. With step = chunk_size - overlap, as overlap approaches chunk_size the step shrinks toward zero and the number of chunks (and therefore index size, embedding cost, and duplicate hits in the candidate list) explodes. In the limit overlap >= chunk_size the code raises ValueError because step <= 0. A 32–64 token overlap on a 256–512 token chunk is roughly a 10–25% redundancy tax — enough to protect boundary sentences, cheap enough to ignore.

2. Using the fixed_chunk function from Section 2.1 with the whitespace tokenizer, you chunk a document of exactly 200 tokens with chunk_size = 50 and overlap = 10.

(a) How many chunks are produced? (b) How long (in tokens) is the last chunk? © How many total tokens are emitted across all chunks, and how many of those are redundant (duplicated) relative to the 200 original tokens?

Solution

The step is \(\text{step} = \text{chunk\_size} - \text{overlap} = 50 - 10 = 40\). Trace the loop (start begins at 0, end = min(start+50, 200)):

Chunk start end length
1 0 50 50
2 40 90 50
3 80 130 50
4 120 170 50
5 160 200 40

After chunk 5, end == len(tokens) so the loop breaks.

(a) 5 chunks. This matches the closed form \(\lceil (N - \text{chunk\_size}) / \text{step} \rceil + 1 = \lceil (200-50)/40 \rceil + 1 = \lceil 3.75 \rceil + 1 = 4 + 1 = 5\).

(b) The last chunk covers tokens [160, 200), i.e. \(200 - 160 = 40\) tokens.

© Total emitted \(= 50 + 50 + 50 + 50 + 40 = 240\) tokens. The originals number 200, so \(240 - 200 = 40\) tokens are redundant. Sanity check: there are 4 internal boundaries, each repeating overlap = 10 tokens, and \(4 \times 10 = 40\). The redundancy tax here is \(40/200 = 20\%\).

3. In the BM25 IDF formula from Section 3, $\(\text{IDF}(t) = \log\!\left(\frac{N - n(t) + 0.5}{n(t) + 0.5} + 1\right),\)$ a corpus has \(N = 1000\) documents. Compute the IDF of a rare term that appears in \(n(t) = 1\) document and of a common term that appears in \(n(t) = 500\) documents. What does the ratio tell you about why BM25 is the right tool for the “CVE-2023-44487 / product SKU” queries the chapter highlights?

Solution

Rare term (\(n = 1\)): $\(\text{IDF} = \log\!\left(\frac{1000 - 1 + 0.5}{1 + 0.5} + 1\right) = \log\!\left(\frac{999.5}{1.5} + 1\right) = \log(666.33 + 1) = \log(667.33) \approx 6.50.\)$

Common term (\(n = 500\)): $\(\text{IDF} = \log\!\left(\frac{1000 - 500 + 0.5}{500 + 0.5} + 1\right) = \log\!\left(\frac{500.5}{500.5} + 1\right) = \log(1 + 1) = \log 2 \approx 0.69.\)$

(Using natural log, as in the chapter’s math.log.)

The rare term carries about \(6.50 / 0.69 \approx 9.4\times\) the IDF weight of the common term. A term like CVE-2023-44487 appears in essentially one document, so it lands at the high end of this IDF scale and dominates the BM25 score of exactly the document that contains it. This is precisely the exact-match signal a dense bi-encoder tends to smear away by generalization, which is why the chapter pairs BM25 with dense retrieval in a hybrid: BM25 supplies decisive weight to rare, discriminative tokens.

4. You have 4 documents. BM25 ranks them \([A, B, C, D]\) and dense retrieval ranks them \([C, A, D, B]\). Using Reciprocal Rank Fusion with \(k = 60\), compute the RRF score of each document and give the final fused ranking. Does any document win the fusion despite being ranked first by neither retriever?

Solution

RRF score is \(\sum_r \frac{1}{k + \text{rank}_r(d)}\) with \(k = 60\). The relevant reciprocals are \(\frac{1}{61} = 0.016393\), \(\frac{1}{62} = 0.016129\), \(\frac{1}{63} = 0.015873\), \(\frac{1}{64} = 0.015625\).

Doc BM25 rank Dense rank BM25 term Dense term RRF score
A 1 2 1/61 1/62 0.032522
C 3 1 1/63 1/61 0.032266
B 2 4 1/62 1/64 0.031754
D 4 3 1/64 1/63 0.031498

Final fused ranking: \([A, C, B, D]\).

No — in this example the fusion winner \(A\) is exactly BM25’s rank-1 document (dense retrieval put \(C\) first). The instructive point is subtler: \(A\) wins the fusion even though dense retrieval ranked \(C\) above it, because \(A\) is consistently near the top of both lists (ranks 1 and 2), whereas \(C\) paid for its dense rank-1 with a weaker BM25 rank of 3. The two documents that were ranked first by neither retriever, \(B\) and \(D\), finish last. This is the hallmark of RRF: it rewards agreement across rankers and, because it uses only rank ordinals, it needs no normalization between BM25’s 0–20 scores and cosine’s 0.5–1.0 range.

5. The chapter says cross-encoders are “unsuitable for first-stage retrieval but ideal for reranking.” Using the illustrative figure of ~60 ms to score 100 candidates on an A100 (Section 4), estimate the per-query cost of using the cross-encoder directly as a first-stage retriever over a 1,000,000-document corpus, and contrast it with reranking a 100-candidate shortlist. Explain in one or two sentences the architectural reason the bi-encoder does not pay this cost.

Solution

From the figure, 100 candidates cost ~60 ms, i.e. roughly \(60/100 = 0.6\) ms per (query, document) forward pass.

Cross-encoder as first-stage retriever must score every document, because it produces no reusable document representation — each score needs a fresh joint forward pass over [query; document]. For \(10^6\) documents: $\(10^6 \times 0.6\ \text{ms} = 6 \times 10^5\ \text{ms} = 600\ \text{seconds} \approx 10\ \text{minutes per query}.\)$ That is completely infeasible for interactive retrieval.

Cross-encoder as reranker touches only the shortlist: \(100 \times 0.6\ \text{ms} \approx 60\) ms per query — four orders of magnitude cheaper, and small enough to sit behind a fast first stage.

The architectural reason: a bi-encoder embeds the query and each document independently, so all \(10^6\) document vectors are computed once at index time and stored. At query time only the single query embedding is computed, and matching is a cheap dot product (further accelerated by an ANN index) — an \(O(1)\) model forward pass per query instead of \(O(N)\). The cross-encoder’s power (every query token attends to every document token) is exactly what forbids this precomputation, because the representation depends jointly on both inputs.

6. Implementation. The domain table in Section 7.1 recommends giving BM25 “high weight” for precise entity lookups, but reciprocal_rank_fusion treats every ranker equally. Implement a weighted_reciprocal_rank_fusion(rankings, weights, k=60) that multiplies each ranker’s contribution by a per-ranker weight, keeping the same return contract (a list of (doc_idx, rrf_score) sorted descending). Then, using the data from Exercise 4 with weights = [2.0, 1.0], compute the new fused scores, describe how upweighting BM25 shifts them relative to the equal-weight result of Exercise 4, and determine the BM25 weight at which document \(B\) (BM25 rank 2) would finally overtake \(C\).

Solution

The change is a single multiplicative factor w applied to each 1/(k+rank) term, mirroring the structure of the original function in Section 3.1:

from collections import defaultdict

def weighted_reciprocal_rank_fusion(
    rankings: list[list[tuple[int, float]]],
    weights: list[float],
    k: int = 60,
) -> list[tuple[int, float]]:
    """
    Weighted RRF: each ranker's 1/(k+rank) contribution is scaled by weights[i].
    Setting all weights to 1.0 recovers the standard RRF of Section 3.1.
    """
    if len(weights) != len(rankings):
        raise ValueError("Need exactly one weight per ranking")

    rrf_scores: dict[int, float] = defaultdict(float)
    for ranked_list, w in zip(rankings, weights):
        for rank, (doc_idx, _score) in enumerate(ranked_list, start=1):
            rrf_scores[doc_idx] += w / (k + rank)

    return sorted(rrf_scores.items(), key=lambda x: -x[1])

Applying it to Exercise 4’s data — BM25 ranks \([A, B, C, D]\), dense ranks \([C, A, D, B]\) — encode each list as (doc_idx, score) pairs (scores are ignored, only order matters). With \(k = 60\) and weights \([2.0, 1.0]\):

  • \(A\): \(2.0 \cdot \frac{1}{61} + 1.0 \cdot \frac{1}{62} = 0.032787 + 0.016129 = 0.048916\)
  • \(B\): \(2.0 \cdot \frac{1}{62} + 1.0 \cdot \frac{1}{64} = 0.032258 + 0.015625 = 0.047883\)
  • \(C\): \(2.0 \cdot \frac{1}{63} + 1.0 \cdot \frac{1}{61} = 0.031746 + 0.016393 = 0.048139\)
  • \(D\): \(2.0 \cdot \frac{1}{64} + 1.0 \cdot \frac{1}{63} = 0.031250 + 0.015873 = 0.047123\)

Fused ranking: \([A, C, B, D]\) — the same order as the equal-weight fusion of Exercise 4, but the margins have shifted decisively toward BM25’s preferences. \(A\)’s lead over \(C\) widens from \(0.000256\) in Exercise 4 to \(0.048916 - 0.048139 = 0.000777\) here (roughly triple), and \(B\) (BM25 rank 2, now double-weighted) closes most of its gap to \(C\): the \(C - B\) margin shrinks from \(0.000512\) in Exercise 4 to \(0.048139 - 0.047883 = 0.000256\).

At weight \(2.0\), \(B\) does not yet overtake \(C\) — the ordering is unchanged. To find the tipping point, require \(w\cdot\frac{1}{62} + \frac{1}{64} > w\cdot\frac{1}{63} + \frac{1}{61}\), i.e. \(w\left(\frac{1}{62}-\frac{1}{63}\right) > \frac{1}{61}-\frac{1}{64}\), which gives \(w > \frac{3/3904}{1/3906} \approx 3.0\). Only once BM25 is trusted more than about \(3\times\) the dense ranker does \(B\) climb above \(C\) — the intended effect for entity-lookup domains where the exact-match retriever should be trusted more. As a sanity check, calling the function with weights = [1.0, 1.0] reproduces the Exercise 4 scores exactly.