← Previous: Week 2, LLMs for Builders · Next: Week 3, Tool Use & Function Calling →
How to give a language model the right facts at the right time: indexing, chunking, BM25, dense and hybrid search, reranking, ColBERT, agentic retrieval, and the ways RAG fails.
CreditThis coursework is adapted from, and inspired by, Stanford CS329Z: Engineering AI Agents (Diyi Yang, Michael Ryan, John Yang), Week 2, "Building Blocks: Retrieval-Augmented Generation (RAG)" (Wednesday, September 30). The topic list and the three required readings come from the course's public schedule [29]. I checked the topic order against the public lecture slides [30]. The notes, lab, quiz and assignment are original writing, and this pack does not reproduce the slides. It is not an official Stanford document. I added extra sources of my own. Each is marked "added" in the references.
Last lecture covered the model. A model knows only what was in its training data, and it can only see what fits in its context. Both limits are a problem for an agent. The facts it needs may be new, private, or too many to paste in.
Retrieval-augmented generation, or RAG, is the standard fix. Before the model answers, a search step finds the most useful passages. Those passages go into the prompt. The model then answers from them. That is the whole idea. The details are where systems win or lose.
This week builds the pieces one at a time. You will see how to split documents, how to index them, how to search with words (BM25) and with vectors (dense retrieval), how to combine both, and how to rerank a short list with a stronger model. Then you will see agentic RAG, where the model decides when to search and what to ask. Last, you will see how each stage fails. The lab builds a small version of every piece, with no API key.
The notes keep two jobs apart. The first is finding the right text. The second is using it well. Most RAG bugs belong to one of the two, and the fix depends on which.
How this pack is builtRequired readings are marked required. Sources I found myself are marked added. When a number is my own arithmetic, or an example is made up, the text says so. Citations like [1] link to the references.
After this week you should be able to:
| Time | What to do |
|---|---|
| 2 hours | Read the notes (section 2). Skim the figures first. |
| 3 hours | Read the three required papers with the reading guide questions. |
| 2 hours | Do the lab and at least four exercises. |
| 1 hour | Discussion and the quiz. |
| 2 hours | The graded assignment. |
A language model has two kinds of memory. The first is in its weights. It was learned in training. It is frozen, it cannot show a source, and it is hard to edit. The second is the context window. It is fresh, but small and costly. Lewis and co-authors called the first parametric memory. They paired it with a non-parametric memory, a searchable index of text that the model reads at run time [1]. Their paper names two open problems that this helps with: giving a source for a decision, and updating what the system knows [1].
That is the case for RAG in three lines.
| Option | Good for | Weak at |
|---|---|---|
| Put it all in the prompt | Small, stable knowledge. Simple to build. | Cost and speed grow with the size. Long inputs can be used badly [25]. |
| Fine-tune the model | Style, format, and skills that repeat. | Poor at adding many exact facts. No source for an answer. Slow to update. |
| RAG | Large or changing knowledge. Answers with citations. | New failure points: bad splits, missed passages, noise. More parts to run. |
The Anthropic article on contextual retrieval gives a plain rule: if your whole knowledge base is under about 200,000 tokens (roughly 500 pages), you may be able to skip retrieval and put it all in the prompt, with prompt caching to keep it cheap [6]. Past that size you need search.
When not to use RAGSkip it when the knowledge is small enough to include whole. Skip it when the question needs a view of the whole corpus ("what are the main themes?"). Plain RAG finds a few local passages, so it answers global questions badly. Edge et al. built GraphRAG for that case [23]. Skip it too when the model already knows a stable fact and a wrong passage would only add noise [24].
Every RAG system has two phases. Index time runs ahead of time, once per document change. Query time runs on each question. Figure 1 shows both.
Lewis et al. split RAG into a retriever and a generator [1]. In plain words: for a question x, the retriever gives a chance p(z | x) to each passage z. The generator gives a chance p(y | x, z) to each answer y when it reads that passage. The system then adds up over passages, so a passage counts in proportion to how likely the retriever thought it was. The paper has two versions [1]:
In that paper the retriever is DPR and the generator is a pretrained sequence-to-sequence model (BART), with a dense index of Wikipedia [1]. The query encoder and the generator were trained together, while the document encoder and the index stayed fixed. Wikipedia was cut into 100-word chunks, about 21 million in all [1]. Most systems you build today do not train jointly. They glue a retriever to a chat model through a prompt. The ideas still apply. The older "retriever-reader" systems, such as DrQA, had the same shape with an extractive reader [26].
| Step | What goes wrong |
|---|---|
| Parse and clean | Tables turn into soup. Headings are lost. Duplicate pages fill the index. |
| Chunk | An answer is cut in half. A chunk loses the name of what it is about. |
| Embed and index | The embedding model does not know your jargon. The index is stale. |
| Retrieve | The right passage is not in the top N. Nothing later can fix a miss here. |
| Rerank | A slow model is too costly. A weak one buries the right passage. |
| Generate | The model ignores the passage, or follows a wrong one, or cites nothing. |
The key point: the first stage sets a ceiling. If the right passage is not among the candidates, no reranker or prompt can bring it back. So measure recall at the retrieval step on its own, before you look at final answers (see 2.7).
You cannot embed a whole book as one vector, and you would not want to. A single vector for a long text squeezes everything into one point. Günther et al. say dense retrieval often does better on short segments, because the meaning is less over-compressed [28]. So we split documents into chunks, and each chunk becomes one item in the index.
Chunk size is a trade-off.
Common methods, from simple to smart:
Figure 2 shows the first three on a small made-up text.
Overlap costs storage. My own arithmetic: take a 10,000-word document, chunks of 200 words, and an overlap of 40 words. Each new chunk starts 200 − 40 = 160 words after the last one. That gives 63 chunks, against 50 with no overlap. The text stored is 63 × 200 = 12,600 words, which is 26% more than the 10,000 in the document. (I checked both counts with the lab's chunk_fixed.) Overlap is cheap insurance, but it is not free, and it does not help if the answer is longer than the overlap.
A chunk often does not say what it is about. Anthropic's example: the chunk "The company's revenue grew by 3% over the previous quarter" does not name the company or the year [6]. A search for "ACME revenue growth Q2 2023" will not match it well. The fix is simple. Before indexing, use an LLM to write a short note (the article says 50 to 100 tokens) that places the chunk in its document, and put the note in front of the chunk [6]. Then embed the combined text and also index it for BM25.
The article reports the drop in failed top-20 retrievals: contextual embeddings alone cut failures by 35% (5.7% to 3.7%). Adding contextual BM25 cut them by 49% (to 2.9%). Adding a reranker on top cut them by 67% (to 1.9%) [6]. These are Anthropic's numbers on their own datasets, so test on yours. The lab uses a cheap rule instead of an LLM: it prefixes each chunk with the document title. That shows the mechanism, not the size of the gain.
Late chunking attacks the same problem from the other side [28]. A long-context embedding model reads the whole document first. Chunk vectors are then made by pooling the token vectors that belong to each chunk. Each chunk vector has seen the full document.
RAPTOR builds a tree. It embeds chunks, clusters them, writes a summary of each cluster, then repeats on the summaries, up to a root [7]. At query time it can retrieve from any level. Low levels give detail. High levels give the big picture. The paper reports a gain of 20 points in absolute accuracy on the QuALITY benchmark when paired with GPT-4 [7].
GraphRAG extracts entities and links from the text to build a graph, then writes summaries for groups of closely related entities (communities) [23]. For a "global" question, each summary gives a partial answer, and the partial answers are combined. It is aimed at questions like "what are the main themes in this collection?", where nearest-neighbor search over chunks fails [23].
Both cost much more at index time, because an LLM reads and writes a lot. They pay off when questions need a view over many chunks.
Exact vector search compares the query with every stored vector. My own arithmetic, with made-up numbers: 1 million chunks with 768 numbers each is 1,000,000 × 768 = 768 million multiply-adds per query. Stored as 32-bit floats, that is 1,000,000 × 768 × 4 bytes = 3.07 GB. That is fine for a laptop. At 100 million chunks it is not.
Approximate nearest-neighbor (ANN) search trades a little recall for a lot of speed. Three ideas to know:
FAISS is the common open-source library that has all of these [9]. Its GPU work showed billion-scale search is practical [10]. The lesson for builders: ANN has a recall setting. Always check recall against exact search on a sample, so you know what you gave up.
BM25 scores a chunk by the query words it contains [4]. It adds up, for each query word t:
IDF(t) × f × (k1 + 1) ÷ ( f + k1 × (1 − b + b × |d| ÷ avgdl) )
Here f is how often t occurs in the chunk, |d| is the chunk length and avgdl the average length. Read it as three effects.
The lab uses k1 = 1.5 and b = 0.75, and an IDF of the form ln(1 + (N − n + 0.5) ÷ (n + 0.5)). That is a common variant. It never goes negative, unlike the plain log form, which turns negative for a word that appears in more than half of all chunks.
BM25 needs no training, runs on a plain inverted index, and is hard to beat on exact strings: names, IDs, error codes, and rare terms. The BEIR benchmark found BM25 to be a robust baseline across many tasks [18]. Its weakness is vocabulary mismatch. A search for "cash for each group" finds nothing in a document that says "cloud credit for each team".
A dense retriever maps text to a vector, so that texts with similar meaning land close together. Karpukhin et al. built Dense Passage Retrieval (DPR) with two BERT encoders, one for questions and one for passages. The score is the dot product of the two vectors [2]. All passage vectors are computed ahead of time and put in an ANN index.
The training idea is the key part of the paper. For each question, you have one correct passage. The other passages in the same batch act as free wrong answers (in-batch negatives). Adding a BM25 hit that is not the answer as a hard negative helps further [2]. The results: DPR beat a strong BM25 baseline by 9 to 19 points of absolute top-20 accuracy on open-domain QA sets, and it beat BM25 with only about 1,000 training examples [2].
Dense retrieval is not a free win. Weak spots are rare literal strings, new vocabulary, and domains far from the training data. BEIR tested models zero-shot on many new domains. It found BM25 a robust baseline. It found dense models cheaper to run but often behind the re-ranking and late-interaction models [18]. MTEB, a large benchmark of embedding models, found that no single method wins everywhere [19]. So choose an embedding model on your data.
One trick for the query side is HyDE: ask an LLM to write a fake answer, then search with the embedding of that fake answer. The fake text may be wrong in its facts, but it looks like an answer, so it lands near real ones [14].
The lab's dense retriever is a stand-inThe lab has no neural network. Its "dense" retriever hashes word concepts into a vector, using a small hand-made synonym table to mimic what a trained model learns. It is good enough to show the shape of dense retrieval (vectors, cosine, paraphrase matching). It is not a measure of real embedding quality.
BM25 and dense retrieval fail in different ways, so the usual answer is to run both and merge the lists. Anthropic's contextual retrieval pipeline does this [6], and the DPR paper also tested adding BM25 to DPR [2]. The merge needs care, because BM25 scores and cosine scores sit on different scales.
Reciprocal Rank Fusion (RRF) avoids the problem by using only the rank in each list [5]:
RRF(d) = sum over lists of 1 ÷ (k + rank of d in that list), with k = 60
A document missing from a list adds nothing for it. Cormack et al. report that k = 60 worked well, and the exact value is not critical [5]. Figure 3 gives a worked example. It is made up, and the sums are my own arithmetic.
In the lab, the same question set shows both single retrievers putting the right chunk at rank 2 for one question each, and hybrid putting it at rank 1 for all six. That is a toy result on a made-up corpus I wrote. It illustrates the effect. It does not prove hybrid is always better. Measure it on your own data.
Retrieval has two goals that pull apart. It must be fast over millions of chunks, and it must be precise at the top of the list. One model rarely does both. So systems use two stages. A cheap first stage fetches, say, 100 candidates. A slower, stronger model then reorders those 100, and the top few go to the LLM. Anthropic's tests followed this shape: fetch the top 150 chunks, rerank, and pass the top 20 [6].
There are three ways to score a (query, passage) pair. Figure 4 compares them.
A cross-encoder feeds the query and the passage into one model, so every query word can attend to every passage word. Nogueira and Cho fine-tuned BERT this way as a passage reranker and topped the MS MARCO leaderboard at the time, with a 27% relative gain in MRR@10 over the previous best [11]. monoT5 does the same job with a sequence-to-sequence model. It is trained to output the words "true" or "false", and the probability of "true" becomes the score [12]. Humeau et al. compared bi-encoders, cross-encoders and a middle design, the poly-encoder. They note that cross-encoders usually do better but are too slow for many uses [27].
The cost is plain. My own arithmetic: to rerank 100 candidates for one question, you run the model 100 times, once per pair. A bi-encoder runs the query once and then looks up stored vectors.
Khattab and Zaharia proposed a middle path [3]. Encode the query and the passage separately, as in a bi-encoder, but keep one vector per token instead of one per text. BERT-based token vectors are projected down to 128 dimensions [3]. The score is then:
score(q, d) = sum over query tokens i of ( max over passage tokens j of dot(qi, dj) )
Each query token looks for its best match in the passage, and the best matches are added up. This is called MaxSim. The "interaction" happens late, after both texts are encoded, and it is only a cheap dot product. So passage vectors can be computed offline, as with a bi-encoder, yet the score still sees word-level detail. The paper reports that ColBERT was about two orders of magnitude faster, and used four orders of magnitude fewer FLOPs per query, than BERT rerankers, while staying competitive in quality [3]. It can also be used for end-to-end retrieval with ANN search over the token vectors [3].
The price is storage. My own arithmetic, with made-up sizes: a passage of 100 tokens, each a 128-number vector at 2 bytes, takes 100 × 128 × 2 = 25,600 bytes. A single 768-number float32 vector takes 768 × 4 = 3,072 bytes. That is about 8.3 times more. The ColBERTv2 paper describes the same problem, an order of magnitude more space, and cuts the footprint by 6 to 10 times with residual compression and better training signals [13].
| Bi-encoder | Cross-encoder | Late interaction | |
|---|---|---|---|
| Vectors per passage | 1 | none | one per token |
| Precompute passages? | Yes | No | Yes |
| Cost per query | Lowest | Highest: one run per candidate | Low to medium |
| Storage | Small | Small | Large |
| Typical job | First stage | Final rerank | Rerank, or first stage |
BEIR, a benchmark of many zero-shot retrieval tasks, found on average that re-ranking and late-interaction models did best, at high compute cost, while dense and sparse models were cheaper but often behind [18]. That matches the table.
The pipeline in Figure 1 runs once, in a fixed order. That is classic RAG. It is simple, and it is the right choice for many products. But it has blind spots. It retrieves even when it does not need to. It uses the user's words as the query, even if they are a poor search query. And it never checks whether the passages were any good.
Agentic RAG hands those choices to the model. Think of it as three decisions, taken in a loop (Figure 5):
The first two show two routes: train the model to critique, or add a separate checker. The third shows a third route: let outcome-only rewards teach search behavior. For a project, a simple loop with a round limit around a good retriever is the place to start. The RAG survey groups these systems under "modular RAG", where steps can be swapped, repeated or routed [21].
If you cannot measure it, you cannot tell whether a change helped. RAG has two layers to measure, and you should measure them separately.
You need questions with known correct passages. Then for each ranked list:
A worked example (made up, my own arithmetic). The system returns [d3, d7, d1, d2]. The correct passages are d1 and d2.
| Metric | Working | Value |
|---|---|---|
| hit@3 | d1 is in the top 3 | 1 |
| recall@3 | 1 of 2 correct passages in the top 3 | 0.5 |
| recall@4 | 2 of 2 in the top 4 | 1 |
| MRR | first correct is at rank 3, so 1 ÷ 3 | 0.333 |
| nDCG@4 | DCG = 1/log24 + 1/log25 = 0.5 + 0.431 = 0.931. Best possible = 1/log22 + 1/log23 = 1 + 0.631 = 1.631. Ratio: | 0.571 |
Which one should you watch? Recall@k at the size of your candidate set tells you the ceiling. MRR and nDCG tell you whether the top of the list is clean. The lab's tests check the metric code against hand-computed values like these.
For the generated answer you want at least three things: is it correct against a reference, is it faithful (supported by the passages it was given), and is it relevant to the question. RAGAS proposed a "reference-free" way to score the retriever's focus, the generator's faithfulness, and the quality of the text, using an LLM as the judge [20]. Judges are handy, but they have their own errors and biases. Check them against a small set of human labels before you trust them.
Barnett et al. add an honest point from their three case studies: a RAG system's quality can only be fully checked while it runs, because the real questions are not known in advance [22]. So keep logging failures after launch.
BEIR (many retrieval tasks, zero-shot) and MTEB (many embedding tasks) are good for a first shortlist of models [18] [19]. They are not a verdict for your corpus. Build a small test set from real questions: 30 to 100 is plenty to start. For each, store the question, the correct passage ids, and the short correct answer. Add some questions that cannot be answered from the corpus, to test whether the system says "I don't know".
When a RAG system gives a bad answer, ask one question first: was the right passage in the context? If not, it is a retrieval problem. If yes, it is a reader problem. Agentic loops add a third group. Figure 6 sorts the common failures.
Barnett et al. list seven failure points from three case studies [22]. Three are on the retrieval side. Missing content: the question cannot be answered from the corpus, and the system should say so but may invent an answer instead. Missed top-ranked documents: the answer is in the corpus but not in the top K that gets returned. Not in context: the passage was retrieved but dropped when many hits were merged [22].
My own additions, from common practice: a stale index (documents changed, the index did not), and a permission leak (the index holds text that this user should not see, so access control must be applied at search time, not after). Neither is a research finding. Both are simple to cause and costly.
The other four in Barnett et al. are on this side: not extracted (the answer is in the context but the model misses it, typically with noise or conflicting text), wrong format, incorrect specificity, and incomplete answers [22].
Two well-known results explain why more context is not always better:
Agentic RAG adds the failures in Figure 6's third column: compounding errors, runaway loops, and prompt injection. The defenses are plain engineering. Cap the rounds. Stop if the query does not change. Count calls. Mark retrieved text as untrusted data in the prompt. Never let it trigger a tool call without a check.
| Symptom | Likely stage | First check, then fix |
|---|---|---|
| Right passage not in the top 50 | Retriever or chunker | Look at the chunk. Is the answer cut? Does it name its subject? Try hybrid search, contextual prefixes, a better embedding model. |
| Right passage is in the top 50 but not the top 5 | Ranking | Add a reranker. Check RRF settings. |
| Right passage is in the prompt, answer is wrong | Reader | Cut the number of passages. Reorder. Tell the model to cite and to ignore what does not help. |
| Confident answer to an unanswerable question | Reader, or missing content | Add an "I don't know" rule. Test with unanswerable questions. |
| Answers cite nothing, or cite ids that do not exist | Reader | Check every cited id against the retrieved ids in code, as the lab's check_citations does. |
| The agent searches ten times | Loop | Add a round limit and a no-change stop. |
This section is a short, general guide. It is not the official project brief. For the current project rules, deadlines and grading, use the course site [29]. The aim here is to show how this week's ideas fit a project that has a retrieval part.
Three readings are required. The rest are optional. For each required one you get: why it matters, the key ideas, and questions to think about as you read. Read the questions first. Take short notes as you go. For the optional ones, pick by interest. The first three optional ones are good for most people.
How to read a paperPass 1: read the title, abstract, introduction and conclusion (10 minutes). Pass 2: read the figures and tables. Pass 3: read only the parts your questions need. You do not need every proof or appendix.
Lewis, Perez, Piktus, Petroni, Karpukhin, Goyal, Küttler, Lewis, Yih, Rocktäschel, Riedel, Kiela. NeurIPS 2020. arXiv 2005.11401 · reference [1].
It named and set up RAG: a language model that reads passages found by a trained retriever. Everything this week is a variation on its two-part picture. Read section 2, and the ablation discussion with the "index hot-swapping" paragraph.
Karpukhin, Oğuz, Min, Lewis, Wu, Edunov, Chen, Yih. EMNLP 2020. arXiv 2004.04906 · reference [2].
It showed that a learned dense retriever can beat BM25 for open-domain QA. It also explains the training choices that make dense retrieval work. This is the retriever inside Lewis et al.
ERR-4042 question.Khattab and Zaharia. SIGIR 2020. arXiv 2004.12832 · reference [3].
It shows a third design between a bi-encoder and a cross-encoder. Read it to see how "late interaction" gets cross-encoder-like quality at a much lower query cost, and what you pay in storage.
These are sources I added. Each is in the references.
Blog post, 2024. anthropic.com/engineering/contextual-retrieval. A short, practical read.
It shows contextual prefixes, hybrid BM25 plus embeddings, and reranking, with failure-rate numbers for each step. Think about: what in your corpus would need a prefix? How would you test that it helps?
Foundations and Trends in Information Retrieval, 2009. doi 10.1561/1500000019.
The reference for where BM25 comes from. It is long. Read the sections on term frequency saturation and length normalization. Think about: change k1 and b in the lab and predict what happens to a long chunk.
SIGIR 2009. doi 10.1145/1571941.1572114.
A short paper with a very simple method. Think about: why is it safe to ignore the raw scores? When would that throw away useful information?
arXiv 1603.09320 and arXiv 2401.08281. Johnson et al. on GPU search is arXiv 1702.08734 [10].
How ANN search works in practice. Think about: what recall setting would you choose for a legal archive, and what for a casual FAQ bot?
arXiv 2401.18059 and arXiv 2404.16130.
Two ways to answer questions that span many chunks. Think about: what is the index-time cost? For which question types would you pay it?
arXiv 2310.11511, arXiv 2401.15884, arXiv 2503.09516.
Three routes to agentic retrieval: learned critique tokens, a separate evaluator, and outcome-reward RL. Think about: which one could you try with a closed model and no training?
arXiv 2104.08663 and arXiv 2210.07316.
Benchmarks for retrievers and embedding models. Think about: how would you use a leaderboard to shortlist a model, and what would you still test yourself?
Lessons from three real case studies. Its Table 2 lists the lessons. Think about: which failure point would you expect first in a project of your own?
arXiv 2302.00093 and arXiv 2307.03172.
Why "more passages" can hurt. Think about: how many passages would you pass, and in what order?
Time: about 2 hours. Goal: build small, testable versions of every stage in Figure 1, and measure what each one buys you. You do not need an API key or any package. The "language model" is a mock that picks the best sentence from the retrieved text. The "dense" retriever is a stand-in with no neural network.
Read this before you trust the numbersThe corpus is a made-up set of five short course documents (about 14 sentences). The six test questions were written by me, and I adjusted the wording of two of them while building the lab so that the demo shows clear effects. So the results illustrate mechanisms. They do not prove that one method beats another. Exercise 8 asks you to test this on data you choose yourself.
ERR-4042 wins.rag_toolkit.py. Each part has its own tests.rag_toolkit.py. Or download it: rag_toolkit.py.test_rag_toolkit.py in the same folder. Or download: test_rag_toolkit.py. The test code is listed after the exercises.python rag_toolkit.py for the demo and python test_rag_toolkit.py for the tests.rag_toolkit.py"""rag_toolkit.py - Week 2b lab: a tiny RAG pipeline. Standard library only.
Parts:
1. Chunkers: fixed, fixed with overlap, sentence groups, contextual prefix
2. Retrievers: BM25 from scratch, a toy dense retriever, hybrid (RRF)
3. Reranker: a toy late-interaction (MaxSim) scorer
4. Metrics: hit@k, recall@k, MRR, nDCG@k
5. Generator: a mock LLM that answers only from sources, cites them, or abstains
6. Agent loop: decide whether to retrieve, check the result, retry or stop
IMPORTANT: the "dense" retriever is a STAND-IN. It hashes words and letter
triples into a small vector and uses a hand-made synonym table. A real system
uses a trained neural embedding model. Nothing here needs an API key.
Run `python rag_toolkit.py` for a demo. Run `python test_rag_toolkit.py` for tests.
"""
import math
import re
import zlib
from dataclasses import dataclass, field
# ------------------------------------------------------------ text basics
STOPWORDS = set("""a an the of to in on at for and or is are was were be been do does did can could
may must should will would i you we they it my our your their me what which who when where how why
this that these those with by from as not no than then so if about after before into over per each
any have has had use used using""".split())
# Hand-made stand-in for what a neural model learns: words with similar meaning
# map to one shared concept. A real embedding model learns this from data.
SYNONYMS = {
"hand": "due", "submit": "due", "deadline": "due", "turn": "due",
"halfway": "milestone", "midway": "milestone", "mid": "milestone",
"shortest": "quiet", "quietest": "quiet", "emptiest": "quiet", "least": "quiet",
"queue": "wait", "line": "wait", "waiting": "wait", "crowded": "busy",
"money": "credit", "funds": "credit", "dollars": "credit", "cash": "credit",
"ask": "request", "appeal": "request", "grades": "grade", "scores": "grade",
"group": "team", "squad": "team", "funding": "credit",
"stuck": "error", "failed": "error", "crash": "error",
}
def stem(w):
for suf in ("ing", "ed", "es", "s"):
if len(w) > len(suf) + 2 and w.endswith(suf):
return w[: -len(suf)]
return w
def tokenize(text, drop_stop=True):
words = re.findall(r"[a-z0-9]+", text.lower())
return [stem(w) for w in words if not (drop_stop and w in STOPWORDS)]
_SYN = {stem(k): v for k, v in SYNONYMS.items()} # tokens are stemmed, so stem the keys too
def concept(token):
return _SYN.get(token, token)
def split_sentences(text):
parts = re.split(r"(?<=[.!?])\s+", text.strip())
return [p for p in parts if p]
# ---------------------------------------------------------------- chunking
@dataclass
class Chunk:
id: str
doc_id: str
text: str
context: str = "" # optional prefix added by contextual retrieval
@property
def indexed_text(self):
return (self.context + " " + self.text).strip()
def chunk_fixed(doc_id, text, size=30, overlap=0):
"""Split into windows of `size` words. `overlap` words repeat between neighbors."""
if size < 1 or not 0 <= overlap < size:
raise ValueError("need size >= 1 and 0 <= overlap < size")
words, step, out, i = text.split(), size - overlap, [], 0
while i < len(words):
out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, " ".join(words[i:i + size])))
if i + size >= len(words):
break
i += step
return out
def chunk_sentences(doc_id, text, max_words=30):
"""Pack whole sentences into chunks of at most max_words (a sentence is never cut)."""
out, cur, n = [], [], 0
for s in split_sentences(text):
w = len(s.split())
if cur and n + w > max_words:
out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, " ".join(cur)))
cur, n = [], 0
cur.append(s)
n += w
if cur:
out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, " ".join(cur)))
return out
def add_context(chunks, doc_titles, summarize=None):
"""Contextual retrieval, toy version: prefix each chunk with where it came from.
A real system asks an LLM to write the prefix. `summarize(chunk, title)` is that hook."""
summarize = summarize or (lambda c, t: f"From the document '{t}'.")
return [Chunk(c.id, c.doc_id, c.text, summarize(c, doc_titles[c.doc_id])) for c in chunks]
# ------------------------------------------------------------------- BM25
class BM25:
"""Okapi BM25 with the common non-negative IDF: ln(1 + (N - n + 0.5) / (n + 0.5))."""
def __init__(self, chunks, k1=1.5, b=0.75):
self.chunks, self.k1, self.b = chunks, k1, b
self.docs = [tokenize(c.indexed_text) for c in chunks]
self.N = len(self.docs)
self.avgdl = sum(len(d) for d in self.docs) / max(self.N, 1)
self.df = {}
for d in self.docs:
for t in set(d):
self.df[t] = self.df.get(t, 0) + 1
def idf(self, term):
n = self.df.get(term, 0)
return math.log(1 + (self.N - n + 0.5) / (n + 0.5))
def score(self, query, i):
d, s = self.docs[i], 0.0
for t in set(tokenize(query)):
f = d.count(t)
if f:
s += self.idf(t) * f * (self.k1 + 1) / (f + self.k1 * (1 - self.b + self.b * len(d) / self.avgdl))
return s
def search(self, query, k=5):
scored = [(self.score(query, i), i) for i in range(self.N)]
scored = [x for x in scored if x[0] > 0]
scored.sort(key=lambda x: (-x[0], x[1]))
return [(self.chunks[i].id, s) for s, i in scored[:k]]
# ------------------------------------------- toy dense retriever (stand-in)
def _bucket(feature, dim):
h = zlib.crc32(feature.encode())
return h % dim, (1.0 if (h >> 16) & 1 else -1.0)
def _normalize(v):
n = math.sqrt(sum(x * x for x in v))
return [x / n for x in v] if n else v
class ToyEmbedder:
"""Hashed bag of concepts + letter triples. NOT a neural model."""
def __init__(self, dim=256):
self.dim, self.idf = dim, {}
def fit(self, texts):
df, n = {}, len(texts)
for t in texts:
for w in {concept(x) for x in tokenize(t)}:
df[w] = df.get(w, 0) + 1
self.idf = {w: math.log(1 + n / c) for w, c in df.items()}
return self
def embed(self, text):
v = [0.0] * self.dim
for tok in tokenize(text):
c = concept(tok)
w = self.idf.get(c, math.log(1 + 10))
for feat, weight in [("w:" + c, 1.0)] + [("t:" + c[i:i + 3], 0.15) for i in range(max(len(c) - 2, 0))]:
j, sgn = _bucket(feat, self.dim)
v[j] += sgn * weight * w
return _normalize(v)
def embed_tokens(self, text, dim=64):
"""One small vector per token, for late interaction."""
small = ToyEmbedder(dim)
small.idf = {}
out = []
for tok in tokenize(text):
c, v = concept(tok), [0.0] * dim
for feat, weight in [("w:" + c, 1.0)] + [("t:" + c[i:i + 3], 0.15) for i in range(max(len(c) - 2, 0))]:
j, sgn = _bucket(feat, dim)
v[j] += sgn * weight
out.append(_normalize(v))
return out
def cosine(a, b):
return sum(x * y for x, y in zip(a, b))
class DenseIndex:
def __init__(self, chunks, dim=256):
self.chunks = chunks
self.emb = ToyEmbedder(dim).fit([c.indexed_text for c in chunks])
self.vecs = [self.emb.embed(c.indexed_text) for c in chunks]
def search(self, query, k=5):
q = self.emb.embed(query)
scored = sorted(((cosine(q, v), i) for i, v in enumerate(self.vecs)), key=lambda x: (-x[0], x[1]))
return [(self.chunks[i].id, s) for s, i in scored[:k] if s > 0]
# ------------------------------------------------------ fusion and rerank
def rrf(rankings, k=60, top=None):
"""Reciprocal Rank Fusion. `rankings` is a list of lists of ids, best first.
score(d) = sum over lists of 1 / (k + rank), with rank starting at 1."""
scores = {}
for lst in rankings:
for r, doc in enumerate(lst, start=1):
scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + r)
fused = sorted(scores.items(), key=lambda x: (-x[1], x[0]))
return fused[:top] if top else fused
def maxsim(query_vecs, doc_vecs):
"""Late interaction score: for each query token take its best match in the doc, then sum."""
if not doc_vecs:
return 0.0
return sum(max(cosine(q, d) for d in doc_vecs) for q in query_vecs)
def rerank_late_interaction(query, chunks_by_id, ids, embedder):
"""Re-order candidate ids by MaxSim. Token vectors for chunks could be precomputed offline."""
qv = embedder.embed_tokens(query)
scored = [(maxsim(qv, embedder.embed_tokens(chunks_by_id[i].indexed_text)), i) for i in ids]
scored.sort(key=lambda x: (-x[0], x[1]))
return [(i, s) for s, i in scored]
# ---------------------------------------------------------------- metrics
def hit_at_k(ranked, relevant, k):
return 1.0 if set(ranked[:k]) & set(relevant) else 0.0
def recall_at_k(ranked, relevant, k):
return len(set(ranked[:k]) & set(relevant)) / len(relevant) if relevant else 0.0
def mrr(ranked, relevant, k=None):
for r, d in enumerate(ranked[:k] if k else ranked, start=1):
if d in relevant:
return 1.0 / r
return 0.0
def ndcg_at_k(ranked, relevant, k):
"""Binary relevance. DCG = sum rel_i / log2(i + 1)."""
dcg = sum(1.0 / math.log2(i + 1) for i, d in enumerate(ranked[:k], start=1) if d in relevant)
ideal = sum(1.0 / math.log2(i + 1) for i in range(1, min(len(relevant), k) + 1))
return dcg / ideal if ideal else 0.0
def relevant_ids(chunks, needle):
"""Gold labels for the lab: a chunk is relevant if it contains the answer text."""
return [c.id for c in chunks if needle.lower() in c.text.lower()]
# --------------------------------------------------------------- generator
NO_ANSWER = "I don't know based on the sources I have."
@dataclass
class Answer:
text: str
citations: list = field(default_factory=list)
abstained: bool = False
def mock_llm_answer(question, retrieved, min_overlap=2):
"""A mock 'LLM'. It picks the source sentence that best matches the question.
Matches on rarer concepts count more. It answers only if at least `min_overlap`
concepts match, and it cites the chunk. Otherwise it abstains.
`retrieved` is a list of Chunk objects, best first."""
q = {concept(t) for t in tokenize(question)}
pool = [(ch, s, {concept(t) for t in tokenize(s)}) for ch in retrieved for s in split_sentences(ch.text)]
df = {}
for _, _, cs in pool:
for c in cs:
df[c] = df.get(c, 0) + 1
best, best_w, best_chunk = None, 0.0, None
for ch, sent, cs in pool:
hits = q & cs
if len(hits) < min_overlap:
continue
w = sum(math.log(1 + len(pool) / df[c]) for c in hits)
if w > best_w:
best, best_w, best_chunk = sent, w, ch
if best is None:
return Answer(NO_ANSWER, [], True)
return Answer(f"{best} [{best_chunk.id}]", [best_chunk.id], False)
def check_citations(answer, retrieved):
"""True when every cited id was really in the retrieved set (a basic faithfulness check)."""
have = {c.id for c in retrieved}
return all(c in have for c in answer.citations)
# --------------------------------------------------------------- agent loop
SMALL_TALK = {"hi", "hello", "hey", "thanks", "thank", "bye"}
def needs_retrieval(question):
"""Decision 1: retrieve or answer directly? Greetings need no sources."""
toks = re.findall(r"[a-z]+", question.lower())
return not (toks and len(toks) <= 3 and all(t in SMALL_TALK for t in toks))
def rewrite_query(question, retrieved):
"""Decision 2: what to ask next. Toy rule: (1) add the shared concept word for any
question word that has one, which fixes vocabulary mismatch; (2) add the most
frequent new content words from what we found. A real agent asks an LLM to rewrite."""
toks = tokenize(question)
extra = [concept(t) for t in toks if concept(t) != t]
seen, counts = set(toks), {}
for ch in retrieved[:2]:
for t in tokenize(ch.text):
if t not in seen and not t.isdigit():
counts[t] = counts.get(t, 0) + 1
extra += [t for t, _ in sorted(counts.items(), key=lambda x: (-x[1], x[0]))[:2]]
return (question + " " + " ".join(extra)).strip()
def agentic_answer(question, search_fn, chunks_by_id, max_rounds=3, k=3):
"""Retrieve, check, maybe retry. Returns (Answer, trace).
search_fn(query, k) -> list of ids. Decision 3: stop when grounded, or after max_rounds."""
trace = []
if not needs_retrieval(question):
trace.append("skip retrieval: small talk")
return Answer("Hello! Ask me about the course.", [], False), trace
query, retrieved = question, []
for rnd in range(1, max_rounds + 1):
ids = search_fn(query, k * rnd) # widen the search each round
retrieved = [chunks_by_id[i] for i in ids]
ans = mock_llm_answer(question, retrieved)
trace.append(f"round {rnd}: query={query!r} -> {ids} -> {'abstain' if ans.abstained else 'grounded'}")
if not ans.abstained:
return ans, trace
new_query = rewrite_query(question, retrieved)
if new_query == query:
trace.append("stop: the query did not change")
return Answer(NO_ANSWER, [], True), trace
query = new_query
trace.append("stop: round limit reached")
return Answer(NO_ANSWER, [], True), trace
# ------------------------------------------------------- the lab corpus
# A small, made-up help center for an imaginary course. All facts are invented.
DOCS = {
"policies": ("Course Policies",
"The project milestone is due in week 6 on Thursday at 11:59 pm. "
"Late days cannot be used on the milestone. The final report is due in week 10. "
"Each student has four late days to use on homework. "
"Regrade requests must be sent within seven days of the grade release. "
"Send every regrade request through the course forum, not by email."),
"office": ("Office Hours",
"Office hours run Monday to Friday. The Thursday 9 am session is the quietest, "
"with about 3 students waiting on average. The Tuesday 4 pm session is the busiest, "
"with about 14 students waiting. Bring your code and a short description of the bug."),
"setup": ("Lab Setup Guide",
"Install Python 3.11 and create a virtual environment. Install the course packages "
"with the provided requirements file. Error ERR-4042 means the API key file is missing. "
"Create a file named .env in the project folder and add your key. "
"Error ERR-5100 means the request timed out. Wait one minute and retry."),
"credits": ("Cloud Credits",
"Each team gets 50 dollars of cloud credit for the quarter. "
"Credit requests go through the staff form. Unused credit expires on the last day of the quarter."),
"dining": ("Campus Dining",
"The dining hall opens at 7 am. Late dinner is served until 9 pm on weekdays. "
"The Thursday market sells snacks until noon. Report lost items to the front desk."),
}
# (question, answer text that must appear in a relevant chunk)
QUERIES = [
("When do I hand in the halfway project checkpoint?", "project milestone is due"),
("What does ERR-4042 mean?", "ERR-4042 means"),
("Which session has the shortest queue?", "Thursday 9 am session is the quietest"),
("How much cloud money does each team get?", "50 dollars"),
("How many days do I have to ask for a regrade?", "within seven days"),
("Can I use late days on the milestone?", "Late days cannot be used"),
]
UNANSWERABLE = "What is the name of the instructor's cat?"
def build_chunks(strategy="sentence", size=22, overlap=5, contextual=False):
chunks = []
for doc_id, (title, text) in DOCS.items():
if strategy == "fixed":
chunks += chunk_fixed(doc_id, text, size, 0)
elif strategy == "overlap":
chunks += chunk_fixed(doc_id, text, size, overlap)
else:
chunks += chunk_sentences(doc_id, text, size)
if contextual:
chunks = add_context(chunks, {k: v[0] for k, v in DOCS.items()})
return chunks
class Pipeline:
"""Everything wired together: chunks -> BM25 + dense -> RRF -> optional rerank."""
def __init__(self, chunks):
self.chunks = chunks
self.by_id = {c.id: c for c in chunks}
self.bm25, self.dense = BM25(chunks), DenseIndex(chunks)
def search(self, query, k=3, mode="hybrid", depth=10):
if mode == "bm25":
return [i for i, _ in self.bm25.search(query, k)]
if mode == "dense":
return [i for i, _ in self.dense.search(query, k)]
fused = rrf([[i for i, _ in self.bm25.search(query, depth)],
[i for i, _ in self.dense.search(query, depth)]], top=depth)
ids = [i for i, _ in fused]
if mode == "hybrid+rerank":
ids = [i for i, _ in rerank_late_interaction(query, self.by_id, ids, self.dense.emb)]
return ids[:k]
def evaluate(pipe, mode, k=3):
rows = []
for q, needle in QUERIES:
rel = relevant_ids(pipe.chunks, needle)
ranked = pipe.search(q, k=10, mode=mode)
rows.append((hit_at_k(ranked, rel, k), recall_at_k(ranked, rel, k), mrr(ranked, rel, 10), ndcg_at_k(ranked, rel, k)))
n = len(rows)
return [round(sum(r[i] for r in rows) / n, 2) for i in range(4)]
def _demo():
print("== chunking ==")
for strat in ("fixed", "overlap", "sentence"):
cs = build_chunks(strat)
print(f"{strat:9s}: {len(cs)} chunks; first of 'policies': {cs[0].text[:60]!r}...")
chunks = build_chunks("sentence")
pipe = Pipeline(chunks)
print("\n== retrieval metrics over 6 questions (k=3): hit, recall, MRR, nDCG ==")
for mode in ("bm25", "dense", "hybrid", "hybrid+rerank"):
print(f"{mode:14s}", evaluate(pipe, mode))
ctx_pipe = Pipeline(build_chunks("sentence", contextual=True))
print(f"{'hybrid+context':14s}", evaluate(ctx_pipe, "hybrid"))
print("\n== same questions, hybrid, three chunkers (size 12 words) ==")
print("(a question scores 0 when no chunk contains the whole answer text)")
for strat in ("fixed", "overlap", "sentence"):
cs = build_chunks(strat, size=12, overlap=4)
print(f"{strat:9s} chunks={len(cs):2d}", evaluate(Pipeline(cs), "hybrid"))
print("\n== rank of the first relevant chunk, per question (- = not in top 10) ==")
print(f"{'mode':14s}" + " ".join(f"Q{i + 1:<2d}" for i in range(len(QUERIES))))
for mode in ("bm25", "dense", "hybrid", "hybrid+rerank"):
cells = []
for q, needle in QUERIES:
rel = relevant_ids(pipe.chunks, needle)
ranked = pipe.search(q, 10, mode)
cells.append(str(next((r for r, d in enumerate(ranked, 1) if d in rel), "-")))
print(f"{mode:14s}" + " ".join(f"{c:<3s}" for c in cells))
print("\n== grounded answers (hybrid + rerank) ==")
for q, _ in QUERIES[:3] + [(UNANSWERABLE, "")]:
ids = pipe.search(q, 3, mode="hybrid+rerank")
a = mock_llm_answer(q, [pipe.by_id[i] for i in ids])
print("Q:", q, "\nA:", a.text)
print("\n== agent loop ==")
for q in ("hello", "What does ERR-4042 mean?", UNANSWERABLE):
a, trace = agentic_answer(q, lambda s, k: pipe.search(s, k), pipe.by_id)
print("Q:", q)
for line in trace:
print(" ", line)
print(" ->", a.text)
q = "What is the cash for each group?"
a, trace = agentic_answer(q, lambda s, k: [i for i, _ in pipe.bm25.search(s, k)], pipe.by_id, k=1)
print("Q (BM25 only, so the first query misses):", q)
for line in trace:
print(" ", line)
print(" ->", a.text)
if __name__ == "__main__":
_demo()
$ python rag_toolkit.py
== chunking ==
fixed : 12 chunks; first of 'policies': 'The project milestone is due in week 6 on Thursday at 11:59 '...
overlap : 14 chunks; first of 'policies': 'The project milestone is due in week 6 on Thursday at 11:59 '...
sentence : 14 chunks; first of 'policies': 'The project milestone is due in week 6 on Thursday at 11:59 '...
== retrieval metrics over 6 questions (k=3): hit, recall, MRR, nDCG ==
bm25 [1.0, 1.0, 0.92, 0.94]
dense [1.0, 1.0, 0.92, 0.94]
hybrid [1.0, 1.0, 1.0, 1.0]
hybrid+rerank [1.0, 1.0, 1.0, 1.0]
hybrid+context [1.0, 1.0, 1.0, 1.0]
== same questions, hybrid, three chunkers (size 12 words) ==
(a question scores 0 when no chunk contains the whole answer text)
fixed chunks=21 [0.83, 0.83, 0.83, 0.83]
overlap chunks=26 [0.83, 0.83, 0.72, 0.75]
sentence chunks=22 [1.0, 1.0, 1.0, 1.0]
== rank of the first relevant chunk, per question (- = not in top 10) ==
mode Q1 Q2 Q3 Q4 Q5 Q6
bm25 1 1 2 1 1 1
dense 1 2 1 1 1 1
hybrid 1 1 1 1 1 1
hybrid+rerank 1 1 1 1 1 1
== grounded answers (hybrid + rerank) ==
Q: When do I hand in the halfway project checkpoint?
A: The project milestone is due in week 6 on Thursday at 11:59 pm. [policies#0]
Q: What does ERR-4042 mean?
A: Error ERR-4042 means the API key file is missing. [setup#1]
Q: Which session has the shortest queue?
A: The Thursday 9 am session is the quietest, with about 3 students waiting on average. [office#0]
Q: What is the name of the instructor's cat?
A: I don't know based on the sources I have.
== agent loop ==
Q: hello
skip retrieval: small talk
-> Hello! Ask me about the course.
Q: What does ERR-4042 mean?
round 1: query='What does ERR-4042 mean?' -> ['setup#1', 'setup#2', 'office#0'] -> grounded
-> Error ERR-4042 means the API key file is missing. [setup#1]
Q: What is the name of the instructor's cat?
round 1: query="What is the name of the instructor's cat?" -> ['dining#1', 'policies#0', 'credits#0'] -> abstain
round 2: query="What is the name of the instructor's cat? milestone thursday" -> ['policies#0', 'dining#1', 'office#0', 'policies#3', 'credits#0', 'policies#2'] -> abstain
stop: the query did not change
-> I don't know based on the sources I have.
Q (BM25 only, so the first query misses): What is the cash for each group?
round 1: query='What is the cash for each group?' -> [] -> abstain
round 2: query='What is the cash for each group? credit team' -> ['credits#0', 'credits#1'] -> grounded
-> Each team gets 50 dollars of cloud credit for the quarter. [credits#0]
How to read the demoChunkers. With 12-word chunks, fixed and overlapping chunks score 0.83 on hit@3, because one answer sentence is cut across chunks. Sentence chunks score 1.0. Overlap did not help here and even lowered MRR, since duplicate chunks compete in the ranking. Retrievers. For "Which session has the shortest queue?", BM25 puts the right chunk at rank 2. Only the word "session" matches. The answer text says "quietest" and "waiting", not "shortest" or "queue", and a shorter chunk about the busy Tuesday session scores a little higher. The stand-in dense retriever gets this one right through its synonym table. But for ERR-4042 it puts the ERR-5100 chunk first, because the two error sentences look alike as vectors and the digits barely matter. Hybrid puts the right chunk first for both. Agent. The last run shows a query that finds nothing at first, gets rewritten with the shared words "credit" and "team", and then finds its answer in round 2. Remember the warning above: this is a small, made-up setup.
build_chunks("fixed", size=12). Find the question that fails. Which words were split? Try sizes 8, 12, 16 and 24 and plot hit@3 against size.chunk_fixed.k1 to 0 and then to 5, and b to 0 and then to 1. Which question moves? Explain with the formula in 2.4 [4].rrf with k = 1, 10, 60, 1000 on two lists you write. At what k does the order of two close documents flip? Cormack et al. say 60 is a good default and not critical [5]. Does your toy example agree?maxsim on a 3-token query and a 4-token passage. Print the 3×4 table of dot products and pick the max per row by hand [3].build_chunks("sentence", contextual=True). Add a new document whose chunks never name their subject. Does BM25 now find them? Write a custom summarize function for add_context that mimics what an LLM would write [6].mock_llm_answer abstain on all of them? Find one that fools it into answering. This is failure point 1 in Barnett et al. [22]. Then raise min_overlap and see what happens to the answerable questions.DOCS and QUERIES with 8 to 10 short documents and 12 questions of your own. Re-run evaluate for all four modes. Does hybrid still win? Report the table and say what your corpus is.agentic_answer with max_rounds=1, 2 and 5 on the cat question. Count search calls. Then write a rewrite rule that never changes the query and confirm the loop stops.test_rag_toolkit.pyimport math
import unittest
from rag_toolkit import *
class TestChunking(unittest.TestCase):
TEXT = " ".join(f"w{i}" for i in range(50))
def test_fixed_no_overlap_covers_everything_once(self):
cs = chunk_fixed("d", self.TEXT, size=20)
self.assertEqual([len(c.text.split()) for c in cs], [20, 20, 10])
self.assertEqual(" ".join(c.text for c in cs), self.TEXT)
def test_overlap_repeats_words_between_neighbors(self):
cs = chunk_fixed("d", self.TEXT, size=20, overlap=5)
self.assertEqual(cs[0].text.split()[-5:], cs[1].text.split()[:5])
self.assertEqual(cs[-1].text.split()[-1], "w49")
def test_bad_chunk_arguments(self):
with self.assertRaises(ValueError):
chunk_fixed("d", self.TEXT, size=10, overlap=10)
def test_sentence_chunks_never_cut_a_sentence(self):
text = "One two three. Four five six seven. Eight nine."
cs = chunk_sentences("d", text, max_words=6)
self.assertEqual([c.text for c in cs], ["One two three.", "Four five six seven. Eight nine."])
def test_context_prefix_is_indexed_but_text_is_unchanged(self):
c = Chunk("d#0", "d", "The company grew 3%.")
out = add_context([c], {"d": "ACME Q2 report"})[0]
self.assertEqual(out.text, c.text)
self.assertIn("ACME Q2 report", out.indexed_text)
def test_contextual_prefix_lets_bm25_find_a_chunk_with_no_name_in_it(self):
chunks = [Chunk("a#0", "a", "Revenue grew by three percent."), Chunk("b#0", "b", "Revenue fell by two percent.")]
plain = BM25(chunks).search("ACME revenue grew")
ctx = BM25(add_context(chunks, {"a": "ACME report", "b": "Zeta report"})).search("ACME revenue grew")
self.assertEqual(ctx[0][0], "a#0")
self.assertEqual(plain[0][0], "a#0") # 'grew' already helped here...
self.assertGreater(ctx[0][1], plain[0][1]) # ...but the name raises the score
class TestBM25(unittest.TestCase):
def setUp(self):
self.chunks = [Chunk("a", "d", "error ERR-4042 means the key file is missing"),
Chunk("b", "d", "the cat sat on the mat"),
Chunk("c", "d", "the dog and the cat play in the park every day")]
self.bm = BM25(self.chunks)
def test_exact_rare_term_wins(self):
self.assertEqual(self.bm.search("ERR-4042")[0][0], "a")
def test_no_overlap_means_no_results(self):
self.assertEqual(self.bm.search("zebra"), [])
def test_rarer_terms_have_higher_idf(self):
self.assertGreater(self.bm.idf("4042"), self.bm.idf("cat"))
self.assertGreater(self.bm.idf("cat"), 0)
def test_repeating_a_term_has_diminishing_returns(self):
s = [BM25([Chunk("x", "d", " ".join(["apple"] * n) + " filler" * 5), Chunk("y", "d", "other words here")]).score("apple", 0)
for n in (1, 2, 4, 8)]
gains = [b - a for a, b in zip(s, s[1:])]
self.assertTrue(all(g > 0 for g in gains))
self.assertGreater(gains[0], gains[2])
self.assertLess(s[-1], 3 * s[0]) # saturates: 8x the count is far less than 8x the score
def test_shorter_document_scores_higher_for_same_count(self):
bm = BM25([Chunk("s", "d", "apple pie"), Chunk("l", "d", "apple " + "filler " * 40)])
self.assertGreater(bm.score("apple", 0), bm.score("apple", 1))
class TestDenseAndFusion(unittest.TestCase):
def test_embeddings_are_unit_length_and_deterministic(self):
e = ToyEmbedder().fit(["alpha beta", "gamma"])
v1, v2 = e.embed("alpha beta"), e.embed("alpha beta")
self.assertEqual(v1, v2)
self.assertAlmostEqual(math.sqrt(sum(x * x for x in v1)), 1.0)
def test_synonym_table_lets_dense_match_a_paraphrase_that_bm25_misses(self):
chunks = [Chunk("a", "d", "Each team gets 50 dollars of credit."), Chunk("b", "d", "The dining hall opens at 7 am.")]
q = "how much cash for a group"
self.assertEqual(BM25(chunks).search(q), [])
self.assertEqual(DenseIndex(chunks).search(q)[0][0], "a")
def test_rrf_formula(self):
fused = dict(rrf([["a", "b"], ["b", "c"]], k=60))
self.assertAlmostEqual(fused["a"], 1 / 61)
self.assertAlmostEqual(fused["b"], 1 / 62 + 1 / 61)
self.assertAlmostEqual(fused["c"], 1 / 62)
def test_rrf_rewards_agreement_between_lists(self):
top = rrf([["x", "a", "b"], ["y", "a", "c"]])[0][0]
self.assertEqual(top, "a")
def test_rrf_handles_one_list_and_empty_input(self):
self.assertEqual([d for d, _ in rrf([["a", "b"]])], ["a", "b"])
self.assertEqual(rrf([]), [])
class TestLateInteraction(unittest.TestCase):
def test_maxsim_of_identical_vectors_equals_number_of_query_tokens(self):
e = ToyEmbedder()
v = e.embed_tokens("alpha beta gamma")
self.assertAlmostEqual(maxsim(v, v), 3.0)
def test_maxsim_prefers_the_passage_that_covers_more_query_tokens(self):
e = ToyEmbedder()
q = e.embed_tokens("alpha beta gamma")
both = e.embed_tokens("alpha beta something")
one = e.embed_tokens("alpha delta epsilon")
self.assertGreater(maxsim(q, both), maxsim(q, one))
def test_rerank_returns_the_same_ids_reordered(self):
pipe = Pipeline(build_chunks())
ids = pipe.search("Which session has the shortest queue?", 6, "hybrid")
out = rerank_late_interaction("Which session has the shortest queue?", pipe.by_id, ids, pipe.dense.emb)
self.assertEqual(sorted(i for i, _ in out), sorted(ids))
self.assertEqual(out[0][0], "office#0")
class TestMetrics(unittest.TestCase):
def test_hit_recall_mrr(self):
ranked, rel = ["a", "b", "c", "d"], ["c", "z"]
self.assertEqual(hit_at_k(ranked, rel, 2), 0.0)
self.assertEqual(hit_at_k(ranked, rel, 3), 1.0)
self.assertEqual(recall_at_k(ranked, rel, 4), 0.5)
self.assertAlmostEqual(mrr(ranked, rel), 1 / 3)
self.assertEqual(mrr(ranked, ["q"]), 0.0)
self.assertEqual(mrr(ranked, rel, k=2), 0.0)
def test_ndcg(self):
self.assertAlmostEqual(ndcg_at_k(["a", "b"], ["a"], 2), 1.0)
self.assertAlmostEqual(ndcg_at_k(["x", "a"], ["a"], 2), 1 / math.log2(3))
self.assertAlmostEqual(ndcg_at_k(["a", "b", "c"], ["a", "c"], 3), (1 + 1 / 2) / (1 + 1 / math.log2(3)))
self.assertEqual(ndcg_at_k(["a"], [], 3), 0.0)
def test_relevant_ids_finds_chunks_containing_the_answer(self):
cs = [Chunk("a", "d", "The answer is 42."), Chunk("b", "d", "nothing")]
self.assertEqual(relevant_ids(cs, "answer is 42"), ["a"])
class TestGenerator(unittest.TestCase):
def setUp(self):
self.pipe = Pipeline(build_chunks())
def ask(self, q):
retrieved = [self.pipe.by_id[i] for i in self.pipe.search(q, 3, "hybrid+rerank")]
return mock_llm_answer(q, retrieved), retrieved
def test_answers_with_a_citation(self):
a, retrieved = self.ask("What does ERR-4042 mean?")
self.assertFalse(a.abstained)
self.assertIn("API key file is missing", a.text)
self.assertEqual(a.citations, ["setup#1"])
self.assertTrue(check_citations(a, retrieved))
def test_abstains_when_sources_do_not_help(self):
a, _ = self.ask(UNANSWERABLE)
self.assertTrue(a.abstained)
self.assertEqual(a.text, NO_ANSWER)
self.assertEqual(a.citations, [])
def test_abstains_with_no_sources(self):
self.assertTrue(mock_llm_answer("anything at all", []).abstained)
def test_a_fake_citation_is_caught(self):
_, retrieved = self.ask("What does ERR-4042 mean?")
self.assertFalse(check_citations(Answer("x [zzz#9]", ["zzz#9"]), retrieved))
def test_every_lab_question_is_answered_from_the_right_chunk(self):
for q, needle in QUERIES:
a, _ = self.ask(q)
self.assertFalse(a.abstained, q)
self.assertIn(needle.lower(), a.text.lower(), q)
class TestPipelineAndAgent(unittest.TestCase):
def setUp(self):
self.pipe = Pipeline(build_chunks())
def test_hybrid_is_never_worse_than_the_worst_single_retriever_on_the_lab_set(self):
scores = {m: evaluate(self.pipe, m)[2] for m in ("bm25", "dense", "hybrid")}
self.assertGreaterEqual(scores["hybrid"], min(scores["bm25"], scores["dense"]))
self.assertEqual(scores["hybrid"], 1.0)
def test_fixed_chunks_can_cut_an_answer_in_half(self):
cut = Pipeline(build_chunks("fixed", size=12))
whole = Pipeline(build_chunks("sentence", size=12))
self.assertLess(evaluate(cut, "hybrid")[1], evaluate(whole, "hybrid")[1])
def test_needs_retrieval(self):
self.assertFalse(needs_retrieval("hello"))
self.assertFalse(needs_retrieval("Thanks!"))
self.assertTrue(needs_retrieval("hello, when is the milestone due?"))
def test_agent_skips_retrieval_for_small_talk(self):
calls = []
ans, trace = agentic_answer("hi", lambda q, k: calls.append(q) or [], self.pipe.by_id)
self.assertEqual(calls, [])
self.assertIn("skip", trace[0])
def test_agent_stops_with_abstain_and_does_not_loop_forever(self):
calls = []
def search(q, k):
calls.append(q)
return self.pipe.search(q, k)
ans, trace = agentic_answer(UNANSWERABLE, search, self.pipe.by_id, max_rounds=3)
self.assertTrue(ans.abstained)
self.assertLessEqual(len(calls), 3)
self.assertIn("stop", trace[-1])
def test_agent_retries_with_a_rewritten_query_and_then_succeeds(self):
bm25_only = lambda q, k: [i for i, _ in self.pipe.bm25.search(q, k)]
ans, trace = agentic_answer("What is the cash for each group?", bm25_only, self.pipe.by_id, k=1)
self.assertFalse(ans.abstained)
self.assertEqual(len(trace), 2)
self.assertIn("credit", trace[1])
self.assertEqual(ans.citations, ["credits#0"])
def test_rewrite_adds_shared_concept_words(self):
self.assertIn("credit", rewrite_query("What is the cash for each group?", []).split())
if __name__ == "__main__":
unittest.main(verbosity=1)
$ python test_rag_toolkit.py
..................................
----------------------------------------------------------------------
Ran 34 tests in 0.040s
OK
Use these in a study group or a class. Try to answer with an example and a source. Give each about 5 to 10 minutes.
Pick one answer for each question, then press Check answers.
D. Lewis et al. describe the index as a non-parametric memory that can be replaced to update knowledge, and they note that giving a source for a decision is an open problem it helps with [1]. Fine-tuning does not give a pointer to a source. See 2.1.
A. BM25 gives rare terms a high weight and matches exact strings [4]. Anthropic's article gives the same kind of example, an error code that embeddings may miss and BM25 finds [6]. A dense retriever can miss the digits, as in the lab, where it ranked the similar ERR-5100 chunk first. See 2.4.
C. DPR uses the other passages in the batch as free negatives. It also tests adding a BM25 passage that does not contain the answer as a hard negative [2]. See 2.4.
B. My own arithmetic: X = 1/(60+1) = 0.01639, from one list. Y = 1/(60+2) + 1/(60+2) = 0.03226. A document that is good in both lists beats one that is first in only one [5]. See Figure 3.
A. My own arithmetic: each new chunk starts 100 − 20 = 80 words after the last, so starts are at 0, 80, 160 and so on. The last start that still leaves a chunk is 2,960, which is 80 × 37, so there are 37 + 1 = 38 chunks. With no overlap you get 30. The lab's chunk_fixed returns 38 for this case. See 2.3.
C. The note is short (50 to 100 tokens in the article), and the combined text is used for both embeddings and BM25 [6]. See 2.3.
D. Late interaction encodes the two texts separately, so passage vectors can be computed offline. The combining step is a cheap sum of best dot products [3]. A cross-encoder needs the query and the passage together, so it cannot precompute. See 2.5.
B. My own arithmetic: the first correct passage (d2) is at rank 2, so MRR = 1/2 = 0.5. In the top 3 only d2 appears, which is 1 of 2 correct passages, so recall@3 = 0.5. The other correct passage (d1) is at rank 4. See 2.7.
C. The three decisions are whether to retrieve, what to ask, and whether the evidence is enough. Self-RAG's reflection tokens and Search-R1's learned search queries are two ways to learn them [15] [17]. See 2.6.
A. The right passage was in the context, so retrieval did its job and the reader failed. Shi et al. found that irrelevant context lowers accuracy and report mitigations such as an instruction to ignore irrelevant information [24]. Liu et al. found that position in a long context matters [25]. Raising the number of passages to 50 would add more noise, not less. See 2.8.
Title: Chunk, retrieve, ground: three small experiments and a failure memo.
Time: 2 to 3 hours. Total: 100 points. Work: solo.
Extend the lab code and write a short memo (two pages at most). Mock models are fine. You may use a real embedding model or LLM if you have access, but say clearly which parts used it. Build your own small corpus for Parts B and C: at least 8 documents and 15 questions, of which at least 4 cannot be answered from the documents. You may write the documents yourself, or use public text that you are allowed to use. Say which.
chunk_paragraphs (split on blank lines, merge short paragraphs up to a size limit) or a heading-aware chunker for text with titles.evaluate for BM25, dense, hybrid, and hybrid with rerank. Report a table with hit@3, recall@3, MRR and nDCG@3.mock_llm_answer on all 15 questions. Report: answerable questions answered correctly, answerable questions wrongly refused, unanswerable questions correctly refused, and unanswerable questions wrongly answered.min_overlap (or your own rule) and show how the four counts move. Pick a setting and defend it. Which error is worse for your corpus [22]?check_citations.agentic_answer on your questions. Report how many used more than one round, how many of those ended in a correct answer, and how many calls to search you spent in total.rag_toolkit.py (or your own file names), the corpus, the questions with gold passages, and the tests. All tests must pass.| Criterion | Full marks | Partial | Points |
|---|---|---|---|
| A. Chunker | New chunker is correct and tested. Four chunkers compared with counts, lengths and metrics. Three or more passing tests. Clear explanation tied to overlap cost. | Chunker works but comparison is thin, or the tests do not cover edge cases. | 30 |
| B. Retrieval study | Complete table for four methods. RRF k varied. One failure traced with ranked lists. Hand-computed nDCG matches the code. Sample-size caveat stated. | Table is missing a method, or the failure analysis has no evidence. | 30 |
| C. Grounding and loop | Four counts reported and moved by a threshold. Citation test. Loop statistics given. Five failures sorted into Figure 6 columns with reasons. | Counts without a defended choice, or failures not sorted. | 25 |
| D. Design and honesty | Scale plan is specific, with a labelled size estimate. A risk and a control. Three honest limits. | Plan is generic, or limits are not stated. | 15 |
| Total | 100 | ||
Grade bands: 90 to 100 excellent. 75 to 89 good. 60 to 74 needs work. Under 60: redo and resubmit. Plain writing is rewarded: short sentences, no filler. Label your own arithmetic as your own.
| Term | Meaning |
|---|---|
| Agentic RAG | RAG where the model decides whether to retrieve, what to ask, and whether the evidence is enough, in a loop [15] [17]. |
| ANN (approximate nearest neighbor) | Vector search that gives up a little recall for much more speed [8] [9]. |
| Bi-encoder | A model that encodes query and passage separately into one vector each. Passage vectors can be precomputed [2]. |
| BM25 | A word-matching score with rare-word weighting, saturation for repeats, and length normalization [4]. |
| Chunk | A piece of a document that is indexed and retrieved as one unit. |
| Contextual retrieval | Adding a short, chunk-specific note about its place in the document before embedding and keyword indexing [6]. |
| Cross-encoder | A model that reads query and passage together and outputs a relevance score. Accurate, but it cannot precompute passages [11]. |
| Dense retrieval | Search by closeness of learned vectors, not by shared words [2]. |
| Embedding | A list of numbers that represents the meaning of a text, so that similar texts have nearby vectors. |
| Faithfulness | How well an answer is supported by the passages it was given [20]. |
| Failure point | A stage in a RAG system where things go wrong. Barnett et al. list seven [22]. |
| Grounded answer | An answer that uses only the given sources and cites them, or says it does not know. |
| GraphRAG | Indexing with an entity graph and community summaries, aimed at global questions [23]. |
| Hard negative | A wrong passage that looks close to right, used to train a retriever. DPR uses a BM25 hit that lacks the answer [2]. |
| HNSW | A graph-based ANN index with several layers, with search cost growing roughly with the logarithm of the data size [8]. |
| Hybrid search | Running keyword and vector search and merging the results [6]. |
| HyDE | Searching with the embedding of an LLM-written fake answer instead of the question [14]. |
| Index time / query time | Work done ahead of time on documents, versus work done for each question. |
| Inverted index | A table from each word to the chunks that contain it. It is what makes BM25 fast. |
| Late interaction | Scoring with per-token vectors computed separately, combined late with MaxSim [3]. |
| Lost in the middle | Models use information in the middle of a long context worse than at the start or end [25]. |
| MaxSim | For each query token take its best match among passage tokens, then sum [3]. |
| MRR | Mean of 1 divided by the rank of the first correct result. |
| nDCG | A ranking score that credits every correct item with a discount for lower ranks, divided by the best possible score. |
| Non-parametric memory | Knowledge kept outside the weights, such as an index that can be replaced [1]. |
| Parametric memory | Knowledge stored in the model's weights [1]. |
| Prompt injection | Text in retrieved or other data that tries to give the model orders. Treat retrieved text as data. |
| RAG | Retrieval-augmented generation: fetch passages, then generate an answer from them [1]. |
| RAPTOR | A tree of recursive cluster summaries over chunks [7]. |
| Recall@k | The share of all correct items found in the top k. |
| Reranker | A slower, stronger model that reorders a short candidate list [11] [12]. |
| RRF | Reciprocal Rank Fusion: sum of 1 / (k + rank) over lists, with k = 60 [5]. |
| Self-RAG | A model trained to retrieve on demand and critique with reflection tokens [15]. |
| Stand-in | A simple substitute used in a lab to show an idea, such as the lab's toy dense retriever. |
I opened every link below on October 28, 2026, with three exceptions. The two DOI links for items 4 and 5 and the publisher page for item 4 refuse automated requests (HTTP 403 or 406), so I could not open them by script. I confirmed those two papers from their publisher page text and from the author-hosted copy of item 5, which opens normally. Items 1 to 3 are the required readings for this week. Items 4 to 28 are sources I added from my own research. Items 29 and 30 are the course pages. For papers on arXiv I give the arXiv identifier and the year it was first posted. I give a venue only where the course page names one, or where the item is a journal or proceedings paper whose venue I confirmed.
About the sourcesEvery claim about a source in these notes comes from that source's own page, which I opened while writing. For papers, I checked the title, authors and abstract on arXiv. I read parts of the full text of the three required papers, and of Barnett et al. (the list of failure points). For the Anthropic article I read the full text. For the RRF paper I read the abstract and the k = 60 passage in the author-hosted copy. For the BM25 monograph I read the publisher's abstract page and search results, not the full text. I did not read every added paper in full. For those, claims are limited to what the abstract or the page itself says. Where a number is my own arithmetic, or the example is made up, the text says so. Where I cite the lecture slides [30], I mean only that the lecture covers the point. If you find a mistake, please tell the author.