A guided path through this repo. Read a section, run its code, look at what comes back, then move on. Everything here is measured on the same benchmark, so you can check every claim yourself.
Who this is for. You can write Python and you have heard of embeddings. You do not need to know what BM25 or nDCG is; both are explained below.
How to use it. Do not read this straight through. Each part names one thing to run. Run it. The numbers on your machine should match the ones here, and if they do not, that is worth chasing.
pip install -r requirements.txt
./scripts/download_data.sh # BEIR SciFact, ~8MB
You need Ollama with bge-base-en-v1.5 (embeddings) and
gemma4:e4b (generation).
Part 1, What RAG is, and what breaks
The idea
A language model knows what was in its training data. It does not know your documents. RAG fixes that the dumbest way possible: find the relevant documents first, paste them into the prompt, then ask the question.
That is the whole thing. Four steps:
1. INDEX turn each document into a vector, store it
2. RETRIEVE turn the query into a vector, find the nearest documents
3. AUGMENT paste those documents into the prompt
4. GENERATE ask the model, which now has the context it needed
The mechanism: why vectors work at all
An embedding model maps text to a list of numbers, typically 768 of them, such that texts about similar things land near each other. "Cats sleep a lot" and "felines are frequently dormant" share almost no words but end up close together, which is exactly what keyword search cannot do.
"Near" means cosine similarity: the angle between two vectors, ignoring their length.
cos(a, b) = (a · b) / (|a| · |b|)
If you normalise every vector to length 1 up front, that collapses to just the
dot product a · b, which is one multiply-add per dimension. This is the single
most useful optimisation in the repo and it shows up in Part 2.
Run this
python concepts/01_naive_rag/naive_rag.py
Ask it something about cats. It works.
What it hides
It works because the corpus was chosen to make it work: 150 cat facts, one
self-contained fact per line. That single property means no chunking is needed,
retrieval is nearly trivial, and readlines() passes for an ingestion pipeline.
Six things this version has no answer for:
| Problem | Why it matters | Part |
|---|---|---|
| No chunking | Real documents are not one fact per line | 4 |
| No evaluation | You cannot tell an improvement from a regression | 2 |
| Dense-only | Embeddings are bad at exact terms, IDs, rare words | 5 |
| No abstention | The prompt says "do not make things up" with no mechanism | 7 |
| Re-embeds every run | 150 HTTP calls per experiment, so you stop experimenting | 2 |
| Recomputes norms | Work whose answer can never change | 2 |
Take away: most RAG tutorials are demonstrations on data chosen so the demonstration succeeds.
Part 2, Measurement, the part everyone skips
This is the most important part of the guide. Skip it and everything after is guesswork.
The idea
You are about to be offered a dozen techniques. Each takes a few hours. Without a number, you cannot tell which ones helped, and "it seems better" is how people end up shipping a pipeline that is worse than where they started.
Three metrics, and why you need all three
Say the correct document for a query is ranked 4th.
recall@k, was it in the top k? At k=3 no, at k=5 yes. This answers did the generator even get a chance? If recall@5 is 0.6, no amount of prompt engineering helps: 40% of the time the answer is not in the context window.
MRR, mean reciprocal rank, 1/rank. Here 1/4 = 0.25. This answers how
high did it land?
nDCG@k, like MRR but it accounts for multiple relevant documents and discounts lower positions smoothly. This is the standard in information retrieval and the one to quote if you want to compare against published numbers.
They fail differently, which is the point. In this repo, paraphrasing the queries barely moved recall@5 (0.987 → 0.960) while MRR fell hard (0.955 → 0.809). The right document was still being retrieved, just ranked lower. That is a ranking problem, not a recall problem, and the two have different fixes.
The trap that catches nearly everyone
You need labelled data: queries paired with their correct document. The obvious shortcut is to have an LLM read each chunk and write a question about it. The label is free, since you know which chunk it came from.
This inflates your scores, and it is not subtle.
Questions generated from a chunk inherit that chunk's rare vocabulary. Ask "what is the technical term for a cat's hairball" and the word bezoar is right there in both query and document. Cosine similarity is being handed the answer.
direct recall@1 = 0.927
paraphrased recall@1 = 0.680
leakage gap: 0.247
Same retriever, same corpus. A quarter of the apparent accuracy was vocabulary overlap. The paraphrased set asks "what do you call those clumps of fur cats throw up" instead, which is what a real user types.
Always generate both variants and report the gap. A single synthetic number tells you nothing.
Make iteration free
Embeddings are a pure function of (model, text). Hash the input, cache the
output, and re-indexing costs nothing.
before after
index, cold 2.631s 1.144s (batching)
index, re-run 2.631s 0.000s (cache)
100 queries 0.565s 0.003s (normalise once, then matmul)
The 190x on query looks like the headline. The 0.000s re-index is what actually matters, because Parts 4 through 8 re-run this pipeline dozens of times. At 2.6s per run you would stop experimenting.
Run this
python concepts/02_measure_first/01_fast_loop.py # the speedup
python concepts/02_measure_first/03_measure_baseline.py # the leakage gap
Exercise
Write ten queries by hand for your own documents and label the correct answer for each. Ten is enough to catch a catastrophic regression. Most teams have zero.
Part 3, Check that your pipeline is correct
The idea
You now have a number. Is it the right number? A bug in your normalisation or your nDCG would produce plausible-looking scores and silently corrupt every experiment after it.
There is a cheap way to find out. Public benchmarks exist where your exact embedding model has a published score. Run one. If you land near it, your whole pipeline is probably correct.
this repo: nDCG@10 = 0.745
published: nDCG@10 ≈ 0.741
That check is worth an afternoon and I would not build on an unvalidated pipeline again.
While you are here, question the defaults
BGE models ship with an instruction to prefix queries with "Represent this sentence for searching relevant passages:". Every tutorial repeats it. On this corpus it cost 0.014 nDCG.
Probably because SciFact queries are declarative claims rather than questions, and the instruction was tuned for question-shaped input. The delta is small enough that I would want a significance test before making a strong claim, and I have not run one.
Take away: conventions are defaults, not laws. Ablate them.
Run this
python concepts/03_real_benchmark/01_beir_baseline.py
Part 4, Chunking, and when not to
The idea
Real documents are longer than the encoder's input window. bge-base accepts
512 tokens. Longer input is not rejected, it is silently truncated: you get
a vector back, it looks fine, and it was built from a prefix of your text.
So you split documents into chunks. The strategies, in increasing respect for the text:
- fixed, N words with overlap. Ignores structure, will cut a sentence in half
- sentence, pack whole sentences to a budget
- recursive, try paragraph breaks, then sentences, then words. This is what LangChain's default splitter does and it is a sensible default
Overlap exists because a boundary can orphan context. "This effect was not observed in the control group" is useless if the previous chunk said which effect.
The part nobody tells you
Chunking is not free, and it is not always right. Before splitting anything, measure your document lengths against your model's window.
SciFact: 5183 documents
over the 512-token limit: 145 (2.8%)
tokens never embedded: 1.4% of corpus
Only 2.8% overflow. So chunking has almost nothing to rescue here, and I predicted before running it that all configs would lose. All six did.
whole 0.7588
recursive 200/40 0.7488 -0.0100
fixed 200/40 0.7477 -0.0111
sentence 100/1 0.7401 -0.0187
recursive 100/20 0.7384 -0.0204
Two things in that table worth more than the headline. Smaller chunks are strictly worse, because every split discards context that made the abstract matchable. And at size 200 the three strategies span 0.0026 while the penalty for chunking at all is 0.011. People argue endlessly about which splitter to use. Here that argument was worth a fifth of the decision nobody argues about.
The problem chunking creates
Your index now returns chunks, but your labels judge documents. Something has to pool one into the other, and that choice changes your score:
max, a document scores as its best chunk. The right defaultsum, favours long documents, they have more chunks to accumulate scoremean, dilutes one perfect passage with the rest
And overfetch: to rank k documents you must fetch more than k chunks, because several top chunks routinely come from the same document. Fetch exactly k and you silently cap recall@k.
Run this
python concepts/04_chunking/01_truncation_check.py # the diagnostic
python concepts/04_chunking/02_chunking_sweep.py report
Exercise
Run the truncation check on your own corpus first. If under 5% of documents overflow and they are single-topic, do not chunk.
Part 5, Keyword search, and combining it with vectors
The idea
Embeddings are bad at exact terms. A product SKU, an error code, a gene name, a rare proper noun: a 768-dimensional vector trained on general text has no particular reason to preserve them.
BM25 is the opposite. It is a 1994 algorithm with no learned parameters that scores documents by how often the query's terms appear, weighted by how rare each term is.
tf(t,d) · (k1 + 1)
idf(t) · ------------------------------------------------
tf(t,d) + k1 · (1 - b + b · len(d) / avg_len)
Three ideas:
- idf, a term appearing in 5 of 5,000 documents is far more informative than one in 4,000. This is precisely the signal embeddings lose
- k1 = 1.2, saturation. The 10th occurrence of a word adds much less than the 2nd. Without this, keyword spam wins
- b = 0.75, partial length normalisation, because long documents contain more of everything
It scores 0.665 here against dense retrieval's 0.759, while indexing in 0.21s and answering in 0.7ms with no GPU.
Combining them: measure the ceiling first
Hybrid search only pays if the two methods fail on different queries. So check before you build:
both 233 77.7%
dense only 34 11.3%
bm25 only 9 3.0% <- the entire headroom
neither 24 8.0%
Nine queries out of 300. Hybrid could gain at most 0.030 recall@10 here.
Look at what those nine contain: "CTCF anchor sites", "de novo assembly... contigs", "statins". Rare technical terms, exactly the predicted failure mode. The theory and the data agreed.
Fusion, and why the default lost
You cannot just add the scores. Cosine lives in [-1,1] and clusters near 0.8; BM25 is unbounded. Two ways out:
RRF throws scores away and uses rank only: score(d) = Σ 1/(k + rank). No
tuning, no normalisation, universally recommended.
Weighted fusion normalises each retriever's scores per query, then blends with a weight you choose.
dense only 0.7588
RRF k=60 0.7347 -0.0241
RRF k=10 0.7499 -0.0089
weighted 0.7/0.3 0.7689 +0.0101
RRF lost in all four variants I tried. It treats a weaker retriever's rank-1 as equally credible as a stronger one's, so BM25's wrong answers got promoted over dense's right ones.
RRF is good advice when your retrievers are comparable in strength. Dense (0.759) clearly dominates BM25 (0.665) here, so equal weighting drags it down.
Run this
python concepts/05_keyword_search/01_bm25_from_scratch.py
python concepts/06_hybrid_search/01_complementarity.py
python concepts/06_hybrid_search/02_hybrid_eval.py
Part 6, Reranking, and the rule that took three tries
The idea
Everything so far is a bi-encoder: query and document are embedded separately, then compared with a dot product. That is what lets you precompute the corpus once. It is also the limitation, because all the interaction between query and document has to survive being compressed into one vector each.
A cross-encoder puts query and document through the transformer together, so attention can compare individual terms. Much more accurate, and impossible to precompute: one forward pass per pair. So it can only be a second stage over a shortlist.
The standard advice is: retrieve 50 cheaply, rerank them precisely.
What happened
It made things worse. Both models, every depth, monotonically.
dense (no rerank) 0.759
dense top-10 → rerank 0.747
dense top-50 → rerank 0.726
dense top-100 → rerank 0.717
That monotonic decline is a diagnostic worth remembering: a reranker that helps gets better with depth, because more candidates raise the ceiling it can reach. One that is worse than your ordering gets steadily worse, because every extra candidate is another chance to promote something wrong.
Three hypotheses
I checked for a bug first. Gold documents scored +0.575 against +0.055 for everything else, so the reranker discriminates fine. It just moves the gold document up as often as down.
Guess 1: rerankers repair weak orderings. Tested against multi-query, whose ordering is weak. Predicted a gain, got −0.011. Wrong.
Guess 2: rerankers help when decorrelated from the first stage. Measured rank correlation. BM25 correlated most with the reranker and was the only stage that gained. Wrong, and backwards.
Guess 3, which held. Reranking overwrites your ordering. You end up with the reranker's ranking quality, bounded by what the candidate set contains:
first stage stage nDCG cand recall reranked nDCG
BM25 0.6518 0.8833 0.6738
dense 0.7007 0.9600 0.6844
multi-query 0.7018 0.9600 0.6909
input spread 0.0501
output spread 0.0171
Three first stages spanning 0.050 collapse to 0.017. The two with identical candidate recall land at nearly identical scores.
Two rules you can act on
- Rerank only if the reranker ranks better than your first stage on your data. Since it replaces your ordering, that is the whole question. BM25 at 0.665 gained +0.041. Dense at 0.759 lost 0.033. Same reranker.
- Under reranking, optimise your first stage for recall, not ordering. Its ordering is about to be discarded. This inverts the usual instinct.
Run this
python concepts/07_reranking/01_rerank_eval.py
python concepts/07_reranking/04_convergence.py
Part 7, Query rewriting, and the generation half
Fixing the query instead of the index
Part 2 proved the failure mode is real: paraphrasing the queries dropped recall@1 from 0.927 to 0.680 with the corpus untouched. So rewrite the query.
- multi-query, generate paraphrases, retrieve each, fuse
- HyDE, have the model write a hypothetical answer and embed that instead
- step-back, ask a broader question, retrieve for both
- decomposition, split a compound question into parts
multi_query +0.0011 nDCG, but +0.040 recall
hyde -0.0481
step_back -0.0660
decompose -0.0948
The ordering is not random. Techniques that add to the original query survive; techniques that replace it do not.
HyDE assumes queries and documents live in different regions of embedding space. SciFact claims are already written like abstract sentences, so there is no gap to close and only the hallucination remains. For "1/2000 in UK have abnormal PrP positivity" it invented "4 ± 1 per million", off by a factor of 250.
Multi-query is worth understanding: it raised recall by 0.040 while nDCG stayed flat. It finds more and ranks no better. Recall and precision moved independently, which is why you track both.
The generation half
Retrieval metrics cannot see failures here. Build a three-condition test:
| Context | Correct behaviour |
|---|---|
| the right document | answer, with a citation |
| top-ranked documents, right one removed | say "not enough information" |
| nothing | say "not enough information" |
gold 0.40 (want a verdict)
distractor 0.83 (want NOT_ENOUGH)
empty 1.00 (want NOT_ENOUGH)
Every guide warns that models answer confidently from irrelevant context. This one does the opposite: it refuses correctly on distractors, and also refuses on 60% of cases where it holds the right document.
Reframing the task and relaxing the prompt each bought 0.07 and then stopped, so the generator is the ceiling, not the retrieval or the wording. A system with perfect retrieval and this model still fails on more than half of SciFact.
Both over-answering and over-refusing are failures. They need opposite fixes, and only one of them gets written about.
Run this
python concepts/08_query_rewriting/01_transform_eval.py report
python concepts/11_grounded_generation/02_claim_verification.py 10
Part 8, Advanced: multi-vector, self-correction, and scale
Late interaction
One vector per token instead of per document. Each query token finds its best match anywhere in the document, and those maxima are summed.
MaxSim(q,d) = Σ over query tokens qi: max over doc tokens dj: qi · dj
It wins: 0.8911 against 0.8804. It also needs a 241x larger index and 28ms per query instead of under 1ms.
Put that next to hybrid search, which bought the same +0.01 for a 0.21s BM25 build. Identical accuracy gain, three orders of magnitude apart in cost. A leaderboard sorted by nDCG puts them adjacent and tells you nothing.
Report cost alongside every delta.
Self-correction
Two mechanisms, opposite outcomes on the same model.
Self-RAG routing asks whether a query needs retrieval at all. 16 for 16.
CRAG grading asks whether a retrieved document is actually relevant:
flag rate | retrieval SUCCEEDED 0.875
flag rate | retrieval FAILED 0.875
separation +0.000
balanced accuracy 0.500
It flags 87.5% of documents regardless of whether retrieval worked. Read alone, "0.88 failure detection" looks excellent. It is an artifact of saying bad to almost everything.
A conditional metric is meaningless without its base rate. This repo produced two such numbers, here and in the faithfulness score of Part 7, and both dissolved once the other half was measured.
Why one works and the other does not: routing is surface classification, which a small model reads easily. Grading needs the same expert inference the generator ceiling already blocked. Spend LLM calls where the task is classification, not where it is expertise.
Scale
| Corpus | What you need |
|---|---|
| 10²-10⁴ | numpy brute force. Most products live here |
| 10⁴-10⁶ | HNSW or pgvector. You accept approximate recall for the first time |
| 10⁶-10⁸ | Qdrant/Vespa, int8 or PQ quantization |
Exact numpy search handled 5,183 documents in under a millisecond and scales to a few hundred thousand vectors in tens of milliseconds. People adopt a vector database one to two orders of magnitude before they need one.
Common misconceptions, collected
| Belief | What I measured |
|---|---|
| Chunking is a standard preprocessing step | All 6 configs lost on a corpus that did not need it |
| RRF is the safe fusion default | Lost in all 4 variants; weighted won |
| Adding a reranker improves things | −0.033 on dense, +0.041 on BM25. Same reranker |
| HyDE fixes query/document mismatch | −0.048 when there is no mismatch to fix |
| Models hallucinate from bad context | This one over-refused instead |
| LLM graders can filter bad retrievals | Balanced accuracy 0.500 |
| You need a vector database | 5,183 docs search in under 1ms with numpy |
None of these mean the technique is bad. They mean it is conditional, and the condition is almost always whether your current pipeline is already good at the thing the technique fixes.
Where to go next
- FLOW.md, the same material as a decision procedure, once you know the techniques and need to choose between them
- NOTES.md, what broke and what I got wrong, including both dead ends in Part 6
- SYSTEMS.md, the full taxonomy, including systems this repo has not measured
The one thing to take away. The techniques are not the skill. Each is a few hours of work and most are already in a library. The skill is refusing to add one until you have measured the gap it is supposed to close. On this benchmark that discipline was the difference between +0.041 and −0.048 from the same family of ideas.