ml-models / Retrieval and recommendation / Retrieval-augmented generation advanced

Retrieval-augmented generation

A language model only knows what was in its training data, and it cannot say where an answer came from. Retrieval-augmented generation looks up passages at question time and puts them in the prompt. This page builds the pipeline on one small manual: chunking, lexical, vector and hybrid retrieval, re-ranking, packing a prompt under a budget, the RAG paper's two ways to combine documents, and how to evaluate retrieval and generation separately. Every number is computed and every PyTorch link is pinned.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Chunking
  5. Dense, lexical and hybrid retrieval
  6. Re-ranking
  7. Prompt assembly
  8. Combining documents: RAG-Sequence and RAG-Token
  9. Evaluating retrieval and generation separately
  10. Failure modes
  11. Worked example
  12. Three ways to chunk the same manual
  13. Lexical, vector and hybrid retrieval
  14. Re-ranking the top 5
  15. Retrieval metrics
  16. Packing the prompt
  17. RAG-Sequence against RAG-Token
  18. Scoring the answers
  19. Implementation
  20. Tradeoffs
  21. Connections
  22. Read first
  23. Read next
  24. References

Problem

A decoder-only model stores what it learned in pretraining in its weights. That knowledge stops at a cutoff date, never included your private documents, and comes back without a source, so a confident wrong answer looks the same as a right one. Retraining or fine-tuning every time a document changes is slow and expensive, and it still does not give citations.

Retrieval-augmented generation (RAG) splits the job in two. A retriever finds the few passages in a corpus most likely to answer the question; a generator reads the question together with those passages and writes the answer. Updating knowledge becomes updating an index, and every answer can point at the passages it used. The cost is a second system that can fail on its own: if retrieval misses the passage, the generator cannot use it.

Intuition

It is an open-book exam. A student who has to answer from memory guesses when unsure; a student allowed to bring the textbook does better, but only if they can find the right page quickly, and only if they read the page instead of writing what they already believed. RAG's quality is the product of those two skills. Searching well is a similarity search problem, and reading well is the generator's job; most of the engineering goes into the first.

The RAG pipeline, offline and per question Offline, documents are split into chunks, each chunk is encoded into a vector and a lexical index, and both are stored. Per question, the query is run against both indexes, the two rankings are fused, a re-ranker reorders the top candidates, the best chunks are packed into a prompt with the question, and the language model writes the answer. Retrieval metrics are measured after re-ranking against labelled relevant chunks; generation metrics are measured on the answer. offline, when documents change documents chunk size, overlap encode + index vectors and BM25 terms vector index + inverted index per question query retrieve dense + BM25 fuse RRF re-rank top candidates assemble prompt budget, order language model answer + citations retrieval metrics: recall@k, MRR answer metrics

Everything above the line is built once; everything below runs on each question. Measuring at the two dashed taps tells you which half failed.

Mechanics

Chunking

Retrieval returns units, and the unit is the chunk. Chunks must be small enough that a vector or a term count can represent one topic and that several fit in the prompt, and large enough to hold a complete answer with its context. The common recipes:

  • Fixed-size windows. Every n words or tokens. Simple and predictable, but a boundary can cut a sentence, or an answer, in half.
  • Overlapping windows. Each window starts n − o words after the previous one, so any span up to o words long lies wholly inside some chunk. The cost is a bigger index and duplicate text when neighbouring chunks are both retrieved.
  • Structure-aware splits. Pack whole sentences, paragraphs or sections up to a budget, so boundaries fall where the author put them. Headings and the document title are often prepended to each chunk so it still makes sense out of context.

The RAG paper reused the DPR index: Wikipedia split into disjoint 100-word passages, about 21 million of them. Chunk size is a hyperparameter to tune against retrieval metrics on your own questions, not a constant.

Dense, lexical and hybrid retrieval

A dense retriever is a two-tower model: a query encoder q(·) and a document encoder d(·) map text to vectors, and relevance is their inner product. The RAG paper uses DPR's BERT encoders and writes the retrieval distribution as

pη(z | x) ∝ exp(d(z)⊤ q(x))

Document vectors are computed once and stored in a vector index, so a query costs one encoder pass plus an approximate maximum-inner-product search; with unit-length vectors this is cosine similarity. Dense retrieval matches meaning ("charging" finds "charged"), but it can miss rare exact strings such as part numbers, names and error codes, and it only knows what its training pairs taught it.

A lexical retriever, usually BM25, scores exact term overlap weighted by rarity (see similarity search). It handles exact strings and needs no training, but it scores zero when the question and the passage use different words for the same thing. Hybrid retrieval runs both and merges the rankings. Their scores are on unrelated scales, so the simplest robust merge ignores scores and uses ranks, reciprocal rank fusion:

RRF(d) = Σrankings r 1 / (k + rankr(d)), k = 60

Re-ranking

First-stage retrievers encode query and document separately, which is what makes them fast and indexable. A re-ranker sees them together. A cross-encoder (monoBERT is the classic) feeds "[query] [SEP] [passage]" through one transformer and outputs a relevance score, so attention can compare every query token with every passage token. It is far more accurate and far too slow to run over the corpus, so it only reorders the first stage's top 20 to 100 candidates. ColBERT sits in between: it stores one vector per document token and scores with "MaxSim", the sum over query tokens of the best-matching document token similarity, which keeps documents indexable while matching at the token level.

Re-ranking cannot recover a passage the first stage missed. That is why first-stage recall is measured on its own: it is the ceiling for everything after it.

Prompt assembly

The retrieved chunks, the question and the instructions must fit in the context window, with room left for the answer. Assembly decides how many chunks, in what order, and how they are marked up: each chunk with an id and source so the model can cite it, a clear separation between instructions and retrieved text, and an instruction to say so when the passages do not contain the answer.

More is not always better. Liu et al. found that models used information at the start or end of a long context much better than information in the middle ("lost in the middle"), and irrelevant passages distract the generator. Common responses are a small k after a good re-ranker, a relevance-score cutoff, and placing the strongest chunks at the edges of the context.

Combining documents: RAG-Sequence and RAG-Token

Putting chunks in the prompt treats retrieval as a fixed preprocessing step. The RAG paper instead treats the retrieved document z as a latent variable and marginalizes over the top k. RAG-Sequence uses one document for the whole answer:

p(y | x) ≈ Σz ∈ top-k pη(z | x) Πi pθ(yi | x, z, y<i)

RAG-Token may draw each token from a different document:

p(y | x) ≈ Πi Σz ∈ top-k pη(z | x) pθ(yi | x, z, y<i)

Both are differentiable in the retrieval scores, so the loss on the answer trains the query encoder without any labels saying which document was relevant. The paper keeps the document encoder, and so the index, fixed and fine-tunes the query encoder and the BART generator. Fusion-in-Decoder takes a third route: encode each (question, passage) pair separately and let the decoder attend over all of them at once.

Evaluating retrieval and generation separately

An end-to-end score says the system is wrong but not where. Evaluate each half against its own labels:

  • Retrieval. For each question, label which chunks contain the answer. Recall@k is the fraction of questions with a relevant chunk in the top k; MRR averages 1/rank of the first relevant chunk (0 when none is found); nDCG handles graded relevance (see learning to rank).
  • Generation given good context. Feed the labelled relevant chunks directly and score the answer. If answers are wrong even then, the generator or prompt is the problem, not retrieval.
  • Answer quality. For short answers, exact match and token F1 after normalization (lowercase, strip punctuation and articles), as in SQuAD. For long answers, human or model judges, and two separate questions: is it correct, and is it faithful, meaning every claim is supported by the retrieved chunks.

Failure modes

FailureWhereSymptomTypical fix
Answer split across chunkschunkingno single chunk contains itoverlap, structure-aware splits
Vocabulary mismatchlexical retrievalBM25 scores zerodense or hybrid retrieval, query rewriting
Exact-string missdense retrievalcodes and names ranked lowhybrid retrieval
Relevant chunk ranked lowrankinggood recall@20, poor recall@3re-ranker
Lost in the middle, distractionassemblyright chunk in prompt, wrong answerfewer chunks, ordering, cutoffs
Unfaithful answergenerationclaims not in any chunkinstructions, citations, faithfulness checks
Stale or conflicting sourcescorpusold and new versions both retrieveddates and versions in metadata, filtering
Prompt injectioncorpusa retrieved page contains instructions the model followstreat retrieved text as data, restrict tools and actions

Prompt injection deserves its own line: Greshake et al. showed that text planted in a web page or document a RAG system retrieves can steer the model, because the model sees retrieved text and instructions in the same context. Retrieval turns everyone who can write to your corpus into someone who can write to your prompt.

ComponentShape/ParamsMeaningSource
F.normalize (N, d) along dim −1 Scales each embedding to unit length, so an inner product is a cosine similarity. functional.py 6143-6177
torch.matmul query (B, d) @ index (d, N) → (B, N) Exact (brute-force) retrieval: every query against every chunk in one product. _torch_docs.py 7909-7991
torch.topk scores (B, N), k → values, indices (B, k) Picks the k best chunks per query without sorting all N. _torch_docs.py 11878-11922
torch.argsort re-ranker scores (C,), descending=True The new order of the candidate chunks after re-ranking. _torch_docs.py 10494-10533
F.cosine_similarity (B, d), (B, d) → (B,) Pairwise cosine without normalizing first; handy for scoring labelled (query, chunk) pairs. functional.py 5942-5971
F.log_softmax retrieval scores (K,) log pη(z | x) over the top-k documents. functional.py 2293-2324
torch.logsumexp (K, T) over dim 0 The marginal over documents in log space, for RAG-Sequence and RAG-Token. _torch_docs.py 6697-6729
Why can a re-ranker be much more accurate than the retriever it follows, yet never improve recall@100 when it reorders only the top 100?

A cross-encoder reads the query and passage together, so it can check that they are about the same thing instead of comparing two summaries made independently. But it only reorders the candidates it is given: a passage outside the first stage's top 100 is never scored. Reordering changes which relevant chunks reach the top few, which is what recall@3 and MRR measure, but the set in the top 100 stays the same.

Worked example

The corpus is one invented product manual of 121 words, for the "Trailhead 2" headlamp, and five questions, each with the phrase that answers it. The retrievers are stand-ins small enough to compute by hand, labelled for what they are: BM25 is real BM25 (k1 = 1.2, b = 0.75); the "vector" retriever uses character-trigram count vectors and cosine similarity, a crude substitute for a learned encoder that still matches "charging" with "charged"; the re-ranker is a hand-built scorer, not a trained cross-encoder. Questions drop a short stop-word list before scoring.

#QuestionAnswering phraseWord positions (from 0)
Q1how do I lock the headlamp"hold the button for three seconds"26-31
Q2how long does charging take"a full charge takes two hours"50-55
Q3what does the red light mean"less than ten percent of the charge remains"74-81
Q4is water damage covered by the warranty"does not cover water damage"114-118
Q5can I swim with it"must not be submerged"93-96

Three ways to chunk the same manual

Fixed windows of 24 words; the same windows with an overlap of 8 words; and whole sentences packed up to 30 words. Each answer is a band in the figure: an answer is retrievable only if one chunk covers its whole band.

Chunk boundaries against the five answer spans The manual's 121 words run left to right. Shaded vertical bands mark where each of the five answers lies. Fixed 24-word chunks put a boundary at word 96, inside the band for question 5, so no fixed chunk contains that answer, and the last fixed chunk holds a single word. Overlapping chunks, drawn on two staggered lines, and sentence chunks each contain every answer whole. Q1 Q2 Q3 Q4 Q5 fixed 24 0 1 2 3 4 4/5 whole overlap 8 0 1 2 3 4 5 6 7 5/5 whole sentences 0 1 2 3 4 5/5 whole

Fixed windows cut Q5's answer, "must not be submerged", at word 96. Overlap and sentence packing both keep it whole; sentence packing does it with fewer chunks (5 against 8).

The rest of the example uses the 5 sentence chunks:

ChunkWordsText
026The Trailhead 2 headlamp has three modes: low, medium and high. Press the button once to turn it on and press it again to change modes.
130Hold the button for three seconds to lock it, so it cannot switch on in a bag. The battery is charged over USB-C, and a full charge takes two hours.
226On low the battery lasts forty hours; on high it lasts four hours. The light turns red when less than ten percent of the charge remains.
322The headlamp is rated IPX6, so it survives heavy rain but must not be submerged. Clean the lens with a dry cloth.
417The warranty covers defects for two years from purchase and does not cover water damage from submersion.

Lexical, vector and hybrid retrieval

The table gives each retriever's score for every chunk; a retriever returns only chunks with a positive score. The bold cell is the chunk that contains the answer.

QuestionRetriever01234Rank of answer
Q1 BM25 0.851.260.000.910.00 1
Trigram 0.2530.0950.0220.2770.000 3
Q2 BM25 0.000.000.000.000.00 missed
Trigram 0.0560.2330.0900.0000.022 1
Q3 BM25 0.000.002.690.000.00 1
Trigram 0.0460.0240.2440.1110.000 1
Q4 BM25 0.000.000.000.004.74 1
Trigram 0.0150.0940.0720.0540.538 1
Q5 BM25 0.000.000.000.000.00 missed
Trigram 0.0000.0820.0000.0000.000 missed

Q2, "how long does charging take", is vocabulary mismatch: the manual says "charged" and "charge", never "charging", so BM25 scores zero everywhere, while the trigram vectors share "#ch", "cha", "har", "arg" and rank chunk 1 first. Q5, "can I swim with it", defeats both: after stop words the question is just "swim", which shares no trigram with "submerged", and the only trigram retrieval, chunk 1, comes from "swim" sharing "#sw" and "swi" with "switch". Answering it needs an encoder trained on enough text to know that swimming means being underwater.

Fusing with RRF, k = 60: for Q1, chunk 3 is ranked 2 by BM25 and 1 by trigram vectors, scoring 1/62 + 1/61 = 0.03252; the answer chunk 1 is ranked 1 and 3, scoring 0.03227. Ranks 2 and 1 narrowly beat ranks 1 and 3, so the hybrid ranking puts the answer second: fusion rewards agreement, and here the trigram retriever's weak ranking pulls the answer down.

Re-ranking the top 5

The stand-in re-ranker scores a chunk by, for each query word, the trigram similarity of its best-matching word in the chunk, weighted by the query word's BM25 IDF, and sums. An exact match on a rare word ("lock" appears in one chunk) counts for much more than a partial match on a common one. It is a crude token-level matcher in the spirit of ColBERT's MaxSim, and it has the property that matters here: it looks at the query and chunk together, word by word.

Q1 chunkRRF score"lock""headlamp"Re-rank score
30.032520.0000.8750.875
1 (answer)0.032271.3860.0001.386
00.032000.4000.8751.276
20.015620.4000.0000.400

Hybrid order 3, 1, 0, 2 becomes 1, 0, 3, 2: the answer moves from rank 2 to rank 1. For the other questions the answer was already first or never retrieved, and re-ranking cannot fix Q5, whose answer chunk was never a candidate.

Retrieval metrics

RetrieverQ1 rankQ2 rankQ3 rankQ4 rankQ5 rankRecall@3MRR
BM251missed11missed0.600.600
Trigram vectors3111missed0.800.667
Hybrid (RRF)2111missed0.800.700
Hybrid + rerank1111missed0.800.800

MRR for the hybrid ranking is (1/2 + 1/1 + 1/1 + 1/1 + 0) / 5 = 0.700. Recall@3 stops at 0.8 for everything but BM25 because of Q5: no amount of fusion or re-ranking adds a chunk that no first-stage retriever returned.

Packing the prompt

For Q2 the re-ranked order is 1, 2, 0, 4. The budget is 70 words, of which 22 go to instructions and the question; chunks are added greedily in rank order and skipped when they do not fit.

Chunk (rank order)WordsRe-rank scoreDecision
1303.723kept
2262.152skipped, does not fit
0261.435skipped, does not fit
4170.311kept

The prompt holds chunks 1 and 4 in 69 of 70 words. The answer chunk is in, but so is chunk 4, about the warranty, which only fitted because it is short: its score is 0.311 against 3.723 for the top chunk. A minimum relevance score would have left that space empty, which is better than filling it with distraction.

To keep the best chunks out of the middle of a longer prompt, interleave them toward both ends: chunks ranked 1 to 5 are placed in the order 1, 3, 5, 4, 2, so ranks 1 and 2 sit at the edges and rank 5 in the centre.

RAG-Sequence against RAG-Token

Two retrieved documents with scores (2.0, 1.0) get p(z | x) = softmax = (0.731, 0.269). The answer has two tokens. Document 1 supports the first token strongly and the second weakly, pθ = (0.9, 0.2); document 2 the reverse, (0.1, 0.8).

  • RAG-Sequence: 0.731 × 0.9 × 0.2 + 0.269 × 0.1 × 0.8 = 0.1531.
  • RAG-Token: per-token marginals (0.6848, 0.3614), product 0.2475.

RAG-Token gives the answer 1.62 times the probability, because it can take each token from the document that supports it. When an answer needs facts from two passages, that flexibility helps; when one passage should be used consistently, RAG-Sequence's single choice is the better assumption.

Scoring the answers

The reference answer to Q2 is "two hours". Three generated answers, scored after lowercasing and removing punctuation and articles:

AnswerExact matchPrecisionRecallToken F1
"A full charge takes two hours."00.4001.0000.571
"About 2 hours"00.3330.5000.400
"Four hours."00.5000.5000.500

All three get exact match 0, including the two correct ones. Token F1 misleads here: the wrong answer "Four hours." scores 0.500, above the correct "About 2 hours" at 0.400, because "2" is not the token "two" and "hours" matches either way. Lexical answer metrics are cheap and useful for short factual answers across many questions, but they need normalization rules for your domain, and spot checks by a person or a judge model.

Implementation

import torch
import torch.nn.functional as F

def build_index(encode, chunks):                      # encode: list[str] -> (N, d)
    with torch.no_grad():
        return F.normalize(encode(chunks), dim=-1)

def dense_search(encode, index, queries, k=20):
    with torch.no_grad():
        q = F.normalize(encode(queries), dim=-1)      # (B, d)
    scores = q @ index.T                              # cosine, (B, N)
    return torch.topk(scores, k, dim=-1)              # values, indices: (B, k)

def rrf(rankings, k=60):                              # rankings: lists of chunk ids
    fused = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking, start=1):
            fused[chunk_id] = fused.get(chunk_id, 0.0) + 1 / (k + rank)
    return sorted(fused, key=fused.get, reverse=True)

def rerank(cross_encoder, query, chunks, candidates, keep=5):
    with torch.no_grad():
        scores = cross_encoder([(query, chunks[i]) for i in candidates])   # (C,)
    order = torch.argsort(scores, descending=True)[:keep]
    return [candidates[i] for i in order.tolist()], scores[order]

def assemble(question, chunks, ids, count_tokens, budget, min_score=None, scores=None):
    parts, used = [], count_tokens(question) + 200   # room for instructions
    for j, i in enumerate(ids):
        if min_score is not None and scores[j] < min_score:
            break
        block = f"[{i}] {chunks[i]}"
        if used + count_tokens(block) <= budget:
            parts.append(block)
            used += count_tokens(block)
    context = "\n\n".join(parts)
    return ("Answer using only the numbered passages and cite them as [n]. "
            "If they do not contain the answer, say so.\n\n"
            f"Passages:\n{context}\n\nQuestion: {question}\nAnswer:")

def rag_log_likelihoods(doc_scores, token_logp):     # (K,), (K, T)
    log_pz = F.log_softmax(doc_scores, dim=-1)
    sequence = torch.logsumexp(log_pz + token_logp.sum(-1), dim=0)
    token = torch.logsumexp(log_pz[:, None] + token_logp, dim=0).sum()
    return sequence, token

Brute-force q @ index.T is exact, and its cost grows linearly with the corpus; once that is too slow, use an approximate index such as HNSW or IVF-PQ from the vector indexes page, and measure recall against the exact search. Use the same encoder, normalization and text preprocessing for queries and documents, and re-embed the whole corpus when the encoder changes: vectors from two models are not comparable. Store each chunk's source, position and version with its vector so answers can cite and filter. Build the evaluation set early, a few hundred real questions with labelled relevant chunks, and tune chunk size, k, the fusion and the re-ranker against recall@k and MRR before touching the prompt.

Tradeoffs

ChoiceGainsCosts
Smaller chunksprecise matches, more chunks fit in the promptanswers and context split; bigger index
Overlapanswers near boundaries surviveindex grows by about size / (size − overlap); duplicate text in the prompt
Dense onlyparaphrase and meaningexact strings, domain shift; needs a trained encoder
BM25 onlyexact strings, no training, transparentvocabulary mismatch
Hybrid + RRFrobust, no score calibrationtwo indexes; k to choose
Cross-encoder re-rankermuch better top-of-list precisionone model pass per candidate per query; adds latency
Larger k in the prompthigher recall reaches the generatorcost, latency, distraction, lost in the middle
RAG versus fine-tuningfresh, citable, per-user knowledge without retrainingretrieval can fail; a second system to run. Fine-tuning teaches style and skills, not reliable facts
Answers are often wrong. Retrieval recall@5 is 0.95, and feeding the labelled relevant chunks directly gives mostly right answers. Where do you look?

Between retrieval and generation: prompt assembly. Recall says a relevant chunk is usually in the top 5, and the generator answers well when given it, so check what actually reaches the prompt: whether the chunk is cut by the budget, buried among distractors in the middle, or outranked by a near-duplicate or outdated chunk. Re-ranking, a score cutoff and ordering are the levers.

Why does RRF use ranks instead of adding the BM25 and cosine scores?

The scores live on unrelated scales: BM25 is unbounded and grows with query length and term rarity, while cosine is between −1 and 1 and bunched together for a given encoder. Adding them lets one retriever dominate depending on the query. Ranks are on the same scale for every retriever, and the constant k = 60 flattens the curve so that a single first place does not outweigh consistently good ranks elsewhere.

Connections

Read first

Read next

No page builds on this one yet.

The dense retriever is a two-tower model served from a vector index; BM25 and hybrid fusion are developed on similarity search, and recall, MRR and nDCG on learning to rank. The generator is a decoder-only model, and the alternative way to add knowledge is fine-tuning. RAG's marginal over documents is a softmax-weighted mixture, computed in log space with the log-softmax and logsumexp used on the loss functions page.

References

  • Lewis et al. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS 2020. arXiv:2005.11401
  • Karpukhin et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP 2020. arXiv:2004.04906
  • Guu et al. REALM: Retrieval-Augmented Language Model Pre-Training. ICML 2020. arXiv:2002.08909
  • Izacard and Grave. Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering. EACL 2021. arXiv:2007.01282
  • Nogueira and Cho. Passage Re-ranking with BERT. 2019. arXiv:1901.04085
  • Khattab and Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. SIGIR 2020. arXiv:2004.12832
  • Cormack, Clarke and Büttcher. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. doi:10.1145/1571941.1572114
  • Robertson and Zaragoza. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in IR 2009. doi:10.1561/1500000019
  • Rajpurkar et al. SQuAD: 100,000+ Questions for Machine Comprehension of Text. EMNLP 2016. arXiv:1606.05250
  • Liu et al. Lost in the Middle: How Language Models Use Long Contexts. TACL 2024. arXiv:2307.03172
  • Greshake et al. Not What You've Signed Up For: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection. 2023. arXiv:2302.12173
  • PyTorch v2.15.0: F.normalize, torch.matmul, torch.topk, torch.argsort, F.cosine_similarity, F.log_softmax, torch.logsumexp.