The one-line version: Contextual Retrieval cuts RAG's top-20 retrieval failure rate by up to 67%, but it isn't one trick — it's four, stacked, and the gains compound. Contextualize each chunk with a one-line LLM blurb before embedding; make that cheap with prompt caching; index the contextualized chunks twice (dense + BM25) and fuse with Reciprocal Rank Fusion; then rerank the fused top-N with a cross-encoder. This is the build, end to end.

If you're still deciding whether you need it, that's the contextual retrieval vs. naive RAG question. This piece assumes you've decided yes and want the recipe.

Step 1 — Contextualize the chunk#

The core failure Contextual Retrieval fixes: a chunk that read fine inside its document — "the error rate rose 3% that quarter" — is ambiguous once embedded alone. Whose error rate? Which quarter? So before embedding, you ask an LLM to situate the chunk in its source and prepend the answer:

CONTEXT_PROMPT = """<document>
{document}
</document>
Here is the chunk we want to situate within the whole document:
<chunk>
{chunk}
</chunk>
Give a short, succinct context (1-2 sentences) to situate this chunk within
the overall document, to improve search retrieval. Answer with the context only."""

def contextualize(document, chunk, client):
    msg = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=120,
        messages=[{"role": "user", "content": [
            {"type": "text", "text": document,
             "cache_control": {"type": "ephemeral"}},   # <-- cache the doc
            {"type": "text", "text": CONTEXT_PROMPT.format(document="", chunk=chunk)},
        ]}],
    )
    return msg.content[0].text + "\n\n" + chunk   # prepend context, then embed THIS

Contextual Embeddings alone dropped Anthropic's top-20 failure rate 35% (5.7% → 3.7%).

Step 2 — Cache the document so it's affordable#

Contextualizing every chunk means re-sending the whole document once per chunk — ruinous at full price. Prompt caching fixes it: mark the document block cache_control: ephemeral (as above), and each chunk's call reads the cached document at a fraction of input cost, paying full price only for the chunk and the short output. Anthropic quotes about $1.02 per million document tokens for the whole indexing pass. Without caching, skip this technique; with it, it's noise against your embedding bill.

Step 3 — Index the contextualized chunks twice#

Dense and lexical retrieval miss different things: embeddings capture meaning but fumble exact strings (error codes, function names, IDs); BM25 nails those but misses paraphrase. So build both over the same contextualized text:

ctx_chunks = [contextualize(doc, c, client) for c in chunks]
dense_index.add(embed(ctx_chunks))        # vector store
bm25_index = BM25(tokenize(ctx_chunks))   # e.g. rank-bm25 / Elasticsearch

Contextual Embeddings + Contextual BM25 together cut failures 49% (→2.9%).

Step 4 — Fuse with RRF, then rerank#

At query time, take the top results from each index and merge them with Reciprocal Rank Fusion — it combines lists by rank, not raw score, so you never have to reconcile a cosine similarity against a BM25 score:

def rrf(rankings, k=60):                # rankings: list of ranked doc-id lists
    scores = {}
    for ranked in rankings:
        for rank, doc_id in enumerate(ranked):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

fused = rrf([dense_index.search(q, 150), bm25_index.search(q, 150)])

Then hand the fused top ~150 to a cross-encoder reranker — Cohere Rerank or a self-hosted BGE-reranker-v2-m3 — which scores each candidate against the query directly and returns the top 20 you actually put in the prompt (the reranker choice is its own call). Reranking took Anthropic's cumulative reduction to 67% (→1.9%).

top20 = rerank(query=q, documents=[docs[i] for i in fused[:150]], top_n=20)

When to skip it#

Every stage costs indexing time or query latency, so spend it where it pays. Contextual Retrieval earns its keep on prose that leans on its surroundings — reports, docs, transcripts, contracts — where a pulled chunk loses the "which company, which quarter, which section" that made it answerable. If your chunks are already self-contained — short FAQ entries, structured records, product cards — the context blurb buys almost nothing, and naive dense-plus-BM25 is the right stopping point. Stack the four steps when your retrieval is failing on context loss; that's the failure this specific machine is built to kill.