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.
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
| Failure | Where | Symptom | Typical fix |
|---|---|---|---|
| Answer split across chunks | chunking | no single chunk contains it | overlap, structure-aware splits |
| Vocabulary mismatch | lexical retrieval | BM25 scores zero | dense or hybrid retrieval, query rewriting |
| Exact-string miss | dense retrieval | codes and names ranked low | hybrid retrieval |
| Relevant chunk ranked low | ranking | good recall@20, poor recall@3 | re-ranker |
| Lost in the middle, distraction | assembly | right chunk in prompt, wrong answer | fewer chunks, ordering, cutoffs |
| Unfaithful answer | generation | claims not in any chunk | instructions, citations, faithfulness checks |
| Stale or conflicting sources | corpus | old and new versions both retrieved | dates and versions in metadata, filtering |
| Prompt injection | corpus | a retrieved page contains instructions the model follows | treat 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.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
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.
| # | Question | Answering phrase | Word positions (from 0) |
|---|---|---|---|
| Q1 | how do I lock the headlamp | "hold the button for three seconds" | 26-31 |
| Q2 | how long does charging take | "a full charge takes two hours" | 50-55 |
| Q3 | what does the red light mean | "less than ten percent of the charge remains" | 74-81 |
| Q4 | is water damage covered by the warranty | "does not cover water damage" | 114-118 |
| Q5 | can 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.
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:
| Chunk | Words | Text |
|---|---|---|
| 0 | 26 | The 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. |
| 1 | 30 | Hold 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. |
| 2 | 26 | On 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. |
| 3 | 22 | The headlamp is rated IPX6, so it survives heavy rain but must not be submerged. Clean the lens with a dry cloth. |
| 4 | 17 | The 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.
| Question | Retriever | 0 | 1 | 2 | 3 | 4 | Rank of answer |
|---|---|---|---|---|---|---|---|
| Q1 | BM25 | 0.85 | 1.26 | 0.00 | 0.91 | 0.00 | 1 |
| Trigram | 0.253 | 0.095 | 0.022 | 0.277 | 0.000 | 3 | |
| Q2 | BM25 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | missed |
| Trigram | 0.056 | 0.233 | 0.090 | 0.000 | 0.022 | 1 | |
| Q3 | BM25 | 0.00 | 0.00 | 2.69 | 0.00 | 0.00 | 1 |
| Trigram | 0.046 | 0.024 | 0.244 | 0.111 | 0.000 | 1 | |
| Q4 | BM25 | 0.00 | 0.00 | 0.00 | 0.00 | 4.74 | 1 |
| Trigram | 0.015 | 0.094 | 0.072 | 0.054 | 0.538 | 1 | |
| Q5 | BM25 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | missed |
| Trigram | 0.000 | 0.082 | 0.000 | 0.000 | 0.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 chunk | RRF score | "lock" | "headlamp" | Re-rank score |
|---|---|---|---|---|
| 3 | 0.03252 | 0.000 | 0.875 | 0.875 |
| 1 (answer) | 0.03227 | 1.386 | 0.000 | 1.386 |
| 0 | 0.03200 | 0.400 | 0.875 | 1.276 |
| 2 | 0.01562 | 0.400 | 0.000 | 0.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
| Retriever | Q1 rank | Q2 rank | Q3 rank | Q4 rank | Q5 rank | Recall@3 | MRR |
|---|---|---|---|---|---|---|---|
| BM25 | 1 | missed | 1 | 1 | missed | 0.60 | 0.600 |
| Trigram vectors | 3 | 1 | 1 | 1 | missed | 0.80 | 0.667 |
| Hybrid (RRF) | 2 | 1 | 1 | 1 | missed | 0.80 | 0.700 |
| Hybrid + rerank | 1 | 1 | 1 | 1 | missed | 0.80 | 0.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) | Words | Re-rank score | Decision |
|---|---|---|---|
| 1 | 30 | 3.723 | kept |
| 2 | 26 | 2.152 | skipped, does not fit |
| 0 | 26 | 1.435 | skipped, does not fit |
| 4 | 17 | 0.311 | kept |
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:
| Answer | Exact match | Precision | Recall | Token F1 |
|---|---|---|---|---|
| "A full charge takes two hours." | 0 | 0.400 | 1.000 | 0.571 |
| "About 2 hours" | 0 | 0.333 | 0.500 | 0.400 |
| "Four hours." | 0 | 0.500 | 0.500 | 0.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
| Choice | Gains | Costs |
|---|---|---|
| Smaller chunks | precise matches, more chunks fit in the prompt | answers and context split; bigger index |
| Overlap | answers near boundaries survive | index grows by about size / (size − overlap); duplicate text in the prompt |
| Dense only | paraphrase and meaning | exact strings, domain shift; needs a trained encoder |
| BM25 only | exact strings, no training, transparent | vocabulary mismatch |
| Hybrid + RRF | robust, no score calibration | two indexes; k to choose |
| Cross-encoder re-ranker | much better top-of-list precision | one model pass per candidate per query; adds latency |
| Larger k in the prompt | higher recall reaches the generator | cost, latency, distraction, lost in the middle |
| RAG versus fine-tuning | fresh, citable, per-user knowledge without retraining | retrieval 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
- Two-tower models core
- Decoder-only LLMs core
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.