Retrieval-Augmented Generation for Knowledge-Intensive NLP
The open-book exam, explained as the search engine it actually is.
Download rag_from_scratch.py — everything below runs from it. NumPy only. There is no language model in the loop: the "generation" step assembles the prompt and prints it.
I've shipped RAG. Twice. Embedding model, vector database, LLM, done. And every time it answered wrong, I blamed the LLM. Then I read the paper and found out the LLM is the small half. The half you build, tune and debug is the retriever — and nobody had told me it's a search engine with a matrix in it.
RAG is an open-book exam
You know this:
answer = llm(question) # closed book: whatever the weights happen to remember
The paper's twist is to hand the model the right page first:
hits = index.search(embed(question), k=3) # open the book to the right pages
prompt = f"Context:\n{hits}\n\nQuestion: {question}"
answer = llm(prompt) # answer from the page, not memory
That's the whole paper's beating heart. The model is the student, the index is the book, and the prompt is the page you slide across the desk. Or: a cache in front of a database. The weights are the database — slow to change, expensive to rebuild. The index is the cache — swap it any time. The paper literally does this: swap the 2018 Wikipedia index for a 2016 one and the answers to "who is the current…" about world leaders change to match. Nothing retrained. The book changed, the student didn't.
| Term | Means | Exam analogy |
|---|---|---|
| Query x | The question, as typed | The exam question |
| Passage z | One chunk of the corpus | One page of the textbook |
| Index | Every passage as a vector, stacked | The textbook's index |
| Retriever pη(z | x) | Scores a passage against the question | Finding the page |
| Generator pθ(y | x, z) | The LLM, given passage plus question | The student, page open |
| Parametric memory | What's in the weights | What the student memorised |
| Non-parametric memory | What's in the index | The textbook |
Before this, a closed-book model like T5 kept every fact in its weights, and the extractive systems (DPR with a reader, REALM) fetched passages but could only copy a span out of one. RAG fetches, then generates. That set the state of the art on three open-domain QA benchmarks, ahead of the 11-billion-parameter closed-book T5 and the extractive retrievers.
The one formula
Read it as pipeline stages, retriever first:
| Step | Shape | What it does |
|---|---|---|
d(z) | (768,) × 21M rows | Every passage through BERT once. Frozen. Stored in FAISS. |
q(x) | (768,) | The question through a second BERT — the only part of the retriever that trains |
d(z)T q(x) | scalar | Similarity. A dot product, the same one as attention |
top-k | k passages | argsort, keep the best k (5 or 10 in the paper) |
pη(z | x) | k numbers, sum to 1 | Softmax over the k scores: how much to trust each page |
pθ(y | x, z) | one answer per passage | BART, given [passage ; question], writes an answer |
Σ | one answer | Weighted average of the k answers, weights from the retriever |
Embedding without a model
The paper's retriever is DPR: two BERT encoders (see the BERT page), one for questions, one for passages, trained so a question and the passage that answers it land close together. I don't have a trained BERT in a NumPy script and don't want one hiding the pipeline. So the script uses the oldest trick in search: count the words, weight rare words higher (tf-idf), hash each word to a slot in a fixed-width vector, divide by the length.
Honest for the shape — one fixed-size vector per chunk, compared by dot product, exactly what FAISS sees. Dishonest for the meaning: DPR knows "owns" and "owned" are one idea; a hash table doesn't. That costs us in a minute.
Here's one chunk becoming a vector:
chunk 4: Lantern's default job timeout is 45 minutes.
tokens: ['lantern', 'default', 'job', 'timeout', '45', 'minutes']
buckets: [279, 223, 248, 402, 299, 675]
vector shape: (1024,) nonzero entries: 6 of 1024
L2 norm: 1.000 ✓
bucket 279 lantern idf 1.38 weight 0.200
bucket 223 default idf 3.40 weight 0.491
bucket 248 job idf 2.01 weight 0.291
bucket 402 timeout idf 2.70 weight 0.391
bucket 299 45 idf 3.40 weight 0.491
bucket 675 minutes idf 3.40 weight 0.491
vocabulary: 118 distinct tokens -> 1024 buckets, 8 hash collisions
"lantern" is in 14 of the 21 chunks so it earns the smallest weight; "default", "45" and "minutes" are in one chunk each so they carry the vector. That's all tf-idf is. The corpus is 21 sentences about a made-up job scheduler called Lantern — invented, so nothing can be fact-checked against the world; consistent, so every question has one right chunk.
Retrieval is one matrix multiply
Stack the chunk vectors into a matrix. Embed the question the same way.
index @ q gives one score per chunk; argsort
gives the top k. That's the retriever. At 21 rows it's NumPy; at 21
million it's FAISS with an approximate nearest-neighbour structure so you
don't scan every row. Same object.
Three questions, top-3 each. * marks the answer chunk:
Q: What is Lantern's default job timeout?
query tokens: ['lantern', 'default', 'job', 'timeout']
* #1 0.720 chunk 4 Lantern's default job timeout is 45 minutes.
#2 0.315 chunk 5 A job that exceeds its timeout is retried at most twice.
#3 0.251 chunk 0 Lantern is the internal job scheduler at Fennel Systems...
Q: Which team owns Lantern?
query tokens: ['team', 'owns', 'lantern']
* #1 0.342 chunk 12 Lantern is owned by the Platform team.
#2 0.174 chunk 13 The Platform team's on-call rotation is one week long.
#3 0.172 chunk 14 Priya Raman is the current tech lead of the Platform team.
Q: How long are failed jobs kept in the dead-letter table?
query tokens: ['long', 'failed', 'jobs', 'kept', 'dead', 'letter', 'table']
* #1 0.766 chunk 19 Failed jobs are kept in the dead-letter table for 30 days.
#2 0.398 chunk 0 Lantern is the internal job scheduler at Fennel Systems...
#3 0.175 chunk 13 The Platform team's on-call rotation is one week long.
Three for three — but look at the middle one. "owns" matched nothing, because the corpus says "owned". The hit came from "team" plus "lantern", scored a limp 0.342, and the runners-up got there on "team" alone. That gap between word overlap and meaning is the whole reason DPR exists. The pipeline is identical either way.
The prompt is the page you slide across the desk
Take the top-k chunks, number them, put the question underneath. That string is what a real system sends to the LLM. The script prints it instead of sending it, because from here the LLM's job is the easy half.
Answer using only the context below.
If the answer is not in the context, say you do not know.
Context:
[1] Lantern's default job timeout is 45 minutes.
[2] A job that exceeds its timeout is retried at most twice.
[3] Lantern is the internal job scheduler at Fennel Systems: every batch job
at Fennel is submitted to Lantern, queued by Lantern, given a timeout by
Lantern, and retried by Lantern when it fails; failed jobs that keep
failing are kept by Lantern in the dead-letter table, and failed jobs stay
there until they expire.
Question: What is Lantern's default job timeout?
Answer:
(103 words. An LLM call goes here. There is none in this script.)
Now the same question with the retriever unplugged:
Context:
(none)
Question: What is Lantern's default job timeout?
Answer:
Two RAGs: the paper's and the one you'll ship
Nobody says this out loud: the system everyone calls RAG is not the system in the paper. The paper trains the retriever and the generator together. What gets shipped is a frozen pipeline.
| Piece | The paper (2020) | What gets shipped | This script |
|---|---|---|---|
| Retriever | DPR bi-encoder; query side fine-tuned | A frozen embedding model, or BM25 | Hashed tf-idf, no training |
| Index | FAISS over 21M Wikipedia passages, 100 words each | A vector database | np.stack, 21 rows |
| Generator | BART-large, fine-tuned | A frozen LLM behind an API | Prints the prompt |
| Passages → model | One at a time, k answers averaged | All k pasted into one prompt | All k pasted into one prompt |
| Training signal | (question, answer) pairs; no label for which passage to fetch | None | None |
The training is the clever part. Fine-tune the query encoder and BART jointly on question–answer pairs; the document encoder and the index stay frozen, because re-embedding 21 million passages every step is not a thing. The gradient still reaches the retriever through pη(z | x): if a passage helped the generator get the answer, the query encoder learns to pull that kind of passage closer. Nobody labels "this was the right passage". It's learned from the answer alone.
Two variants. RAG-Sequence picks one passage for the whole answer, runs the generator once per passage, averages the k answers. RAG-Token re-picks the passage at every generated token, so one answer can draw on several documents. On the QA benchmarks they're within a couple of points, RAG-Sequence usually ahead; RAG-Token wins Jeopardy-question generation, where an answer needs facts from more than one page. The frozen pipeline is neither — one prompt, one call — and the script codes that, because that's what you'll debug. The generator itself is a transformer decoder: the attention page with a mask.
Three ways the retriever fails quietly
Every one of these shows up as "the LLM was wrong". None of them is the LLM. The script breaks each on purpose.
1. Skip the normalisation and length wins
Delete the divide-by-norm and a dot product stops being a cosine. A long chunk has a long vector, and a long vector wins comparisons it has no business winning. The corpus has a README paragraph — 37 tokens, mentions everything, answers nothing. Watch it take over:
Q: What is Lantern's default job timeout?
cosine #1: chunk 4 (0.720) raw dot #1: chunk 0 (26.88)
Q: Which team owns Lantern?
cosine #1: chunk 12 (0.342) raw dot #1: chunk 0 (11.48)
Q: How long are failed jobs kept in the dead-letter table?
cosine #1: chunk 19 (0.766) raw dot #1: chunk 0 (68.36)
Three of three, with the biggest scores on the page. Your retriever has
become return longest_paragraph.
2. Ask something the corpus can't answer
argsort always returns k rows. There is no "I don't know" in
top-k. Ask about a team that doesn't exist and you get the nearest team
that does, with a better score than a real hit:
Q (answer IS in corpus): Which team owns Lantern?
* #1 0.342 chunk 12 Lantern is owned by the Platform team.
Q (answer NOT in corpus): Who is the tech lead of the Security team?
#1 0.530 chunk 14 Priya Raman is the current tech lead of the Platform team.
#2 0.201 chunk 12 Lantern is owned by the Platform team.
#3 0.129 chunk 13 The Platform team's on-call rotation is one week long.
A model that skims will answer "Priya Raman". A score threshold would have to sit above 0.53 and below 0.34 at the same time. It can't. You need a reranker, or a model that actually reads the context and refuses — and an eval for it, because the retriever will never tell you.
3. Chunks too big: the fact is buried
Merge the 21 sentences into 3 chunks of 7. Same corpus, same questions, same maths. Every chunk now mentions Lantern, so every chunk looks a bit right, and the gap between hit and runner-up collapses:
#1 score #2 score gap #1 is answer in #1?
small chunks:
Q1 0.720 0.315 0.405 chunk 4 yes
Q2 0.342 0.174 0.168 chunk 12 yes
Q3 0.766 0.398 0.368 chunk 19 yes
big chunks:
Q1 0.535 0.232 0.303 chunk 0 yes, sentence 5 of 7
Q2 0.215 0.206 0.009 chunk 2 NO
Q3 0.226 0.200 0.026 chunk 0 NO
prompt words for Q1, k=3: small chunks 103 big chunks 271 (whole document: 239)
Q2's gap goes from 0.168 to 0.009 — a coin toss — and the coin lands wrong. Q3 loses to the README paragraph, which has every one of its words, twice. And k=3 of 3 chunks is the whole document: the retriever did nothing, you pasted the wiki into the prompt and paid 271 words for a six-word question. Chunk size is the most consequential knob in the pipeline and the one nobody tunes.
நான் RAG-ஐ ship செய்திருக்கிறேன். இரண்டு முறை. Embedding model, vector database, LLM — முடிந்தது. ஒவ்வொரு முறை பதில் தவறாக வந்தபோதும் நான் LLM-ஐத்தான் குற்றம் சொன்னேன். பிறகு இந்தக் கட்டுரையைப் படித்தேன்: LLM சின்ன பாதிதான். நீங்கள் கட்டி, tune செய்து, debug செய்யும் பாதி retriever — அதற்குள் ஒரு matrix இருக்கும் search engine அது என்று யாரும் என்னிடம் சொல்லவில்லை.
RAG என்பது ஒரு open-book தேர்வு
இது உங்களுக்குத் தெரியும்:
answer = llm(question) # closed book: weights-ல் நினைவிருப்பது மட்டும்
கட்டுரையின் திருப்பம்: சரியான பக்கத்தை முதலில் மாணவனிடம் கொடுப்பது:
hits = index.search(embed(question), k=3) # சரியான பக்கத்தில் புத்தகம் திற
prompt = f"Context:\n{hits}\n\nQuestion: {question}"
answer = llm(prompt) # நினைவிலிருந்து அல்ல, பக்கத்திலிருந்து
இதுதான் முழுக் கட்டுரையின் இதயம். Model என்பது மாணவன், index என்பது புத்தகம், prompt என்பது நீங்கள் மேசையின் மேல் நகர்த்தித் தரும் பக்கம். அல்லது: ஒரு database-ன் முன் இருக்கும் cache. Weights தான் database — மாற்ற மெதுவானது, மீண்டும் கட்ட விலை அதிகம். Index தான் cache — எப்போது வேண்டுமானாலும் மாற்றலாம். கட்டுரை நேரடியாக இதைச் செய்கிறது: 2018 Wikipedia index-ஐ 2016 index-ஆக மாற்றினால், உலகத் தலைவர்கள் பற்றி "இப்போது யார்…" என்ற கேள்விகளுக்கான பதில்கள் அதற்கேற்ப மாறுகின்றன. எதுவும் retrain செய்யப்படவில்லை. புத்தகம் மாறியது, மாணவன் அல்ல.
RAG-nu solradhu open-book exam dhaan. Model dhaan student, index dhaan book, prompt dhaan neenga mesai mela nagarthi kudukra page. Index-a maathina podhum — retrain thevai illa.
| சொல் | அர்த்தம் | தேர்வு உதாரணம் |
|---|---|---|
| Query x | தட்டச்சு செய்த கேள்வி | தேர்வுக் கேள்வி |
| Passage z | Corpus-ன் ஒரு chunk | பாடப் புத்தகத்தின் ஒரு பக்கம் |
| Index | ஒவ்வொரு passage-ம் vector-ஆக, அடுக்கி வைக்கப்பட்டது | புத்தகத்தின் index |
| Retriever pη(z | x) | கேள்விக்கு எதிராக ஒரு passage-க்கு score தருகிறது | பக்கத்தைக் கண்டுபிடிப்பது |
| Generator pθ(y | x, z) | Passage-ம் கேள்வியும் கொடுக்கப்பட்ட LLM | புத்தகம் திறந்த மாணவன் |
| Parametric memory | Weights-ல் இருப்பது | மாணவன் மனப்பாடம் செய்தது |
| Non-parametric memory | Index-ல் இருப்பது | பாடப் புத்தகம் |
இதற்கு முன், T5 போன்ற closed-book model ஒவ்வொரு உண்மையையும் தன் weights-ல் வைத்திருந்தது; extractive அமைப்புகள் (reader-உடன் DPR, REALM) passages-ஐ எடுத்தன, ஆனால் ஒன்றிலிருந்து ஒரு span-ஐ நகலெடுக்க மட்டுமே முடிந்தது. RAG எடுக்கிறது, பிறகு generate செய்கிறது. அது மூன்று open-domain QA benchmarks-ல் state of the art-ஐ அமைத்தது — 11 பில்லியன் parameter closed-book T5-ஐயும், extractive retrievers-ஐயும் முந்தி.
அந்த ஒரு சூத்திரம்
இதை pipeline stages-ஆகப் படியுங்கள், retriever முதலில்:
| படி | Shape | என்ன செய்கிறது |
|---|---|---|
d(z) | (768,) × 21M rows | ஒவ்வொரு passage-ம் BERT வழியாக ஒரு முறை. Frozen. FAISS-ல் சேமிப்பு. |
q(x) | (768,) | கேள்வி இரண்டாவது BERT வழியாக — retriever-ல் train ஆகும் ஒரே பகுதி |
d(z)T q(x) | scalar | Similarity. ஒரு dot product — attention-ல் இருந்த அதே ஒன்று |
top-k | k passages | argsort, சிறந்த k-ஐ வைத்துக்கொள் (கட்டுரையில் 5 அல்லது 10) |
pη(z | x) | k எண்கள், கூட்டினால் 1 | k scores மேல் softmax: ஒவ்வொரு பக்கத்தையும் எவ்வளவு நம்புவது |
pθ(y | x, z) | ஒரு passage-க்கு ஒரு பதில் | BART, [passage ; question] கொடுத்தால் பதில் எழுதுகிறது |
Σ | ஒரு பதில் | k பதில்களின் எடையிட்ட சராசரி, எடைகள் retriever-லிருந்து |
Model இல்லாமல் embedding
கட்டுரையின் retriever DPR: இரண்டு BERT encoders (BERT பக்கம் பாருங்கள்) — ஒன்று கேள்விகளுக்கு, ஒன்று passages-க்கு. ஒரு கேள்வியும் அதற்குப் பதில் தரும் passage-ம் அருகருகே விழும்படி train செய்யப்படுகின்றன. என்னிடம் NumPy script-ல் train செய்த BERT இல்லை, pipeline-ஐ மறைக்கும் ஒன்றும் வேண்டாம். அதனால் script தேடலின் மிகப் பழைய தந்திரத்தைப் பயன்படுத்துகிறது: வார்த்தைகளை எண்ணு, அரிதான வார்த்தைகளுக்கு அதிக எடை (tf-idf), ஒவ்வொரு வார்த்தையையும் hash செய்து நிலையான அகல vector-ல் ஒரு slot-க்கு அனுப்பு, நீளத்தால் வகுத்துவிடு.
Shape-ஐப் பொறுத்தவரை நேர்மையானது — ஒரு chunk-க்கு ஒரு நிலையான அளவு vector, dot product-ஆல் ஒப்பிடப்படுகிறது; FAISS பார்ப்பதும் அதுதான். அர்த்தத்தைப் பொறுத்தவரை நேர்மையற்றது: "owns"-ம் "owned"-ம் ஒரே கருத்து என்று DPR-க்குத் தெரியும்; hash table-க்குத் தெரியாது. அதற்கான விலையை ஒரு நிமிடத்தில் கட்டப்போகிறோம்.
ஒரு chunk vector-ஆக மாறுவதைப் பாருங்கள்:
chunk 4: Lantern's default job timeout is 45 minutes.
tokens: ['lantern', 'default', 'job', 'timeout', '45', 'minutes']
buckets: [279, 223, 248, 402, 299, 675]
vector shape: (1024,) nonzero entries: 6 of 1024
L2 norm: 1.000 ✓
bucket 279 lantern idf 1.38 weight 0.200
bucket 223 default idf 3.40 weight 0.491
bucket 248 job idf 2.01 weight 0.291
bucket 402 timeout idf 2.70 weight 0.391
bucket 299 45 idf 3.40 weight 0.491
bucket 675 minutes idf 3.40 weight 0.491
vocabulary: 118 distinct tokens -> 1024 buckets, 8 hash collisions
"lantern" 21 chunks-ல் 14-ல் இருக்கிறது, அதனால் அதற்கு மிகச் சிறிய எடை; "default", "45", "minutes" ஒவ்வொன்றும் ஒரே ஒரு chunk-ல் மட்டும், அதனால் அவைதான் vector-ஐச் சுமக்கின்றன. Tf-idf என்பது இவ்வளவுதான். Corpus என்பது Lantern என்ற கற்பனையான job scheduler பற்றிய 21 வாக்கியங்கள் — கற்பனை, அதனால் உலகத்தோடு எதையும் fact-check செய்ய முடியாது; ஒத்திசைவானது, அதனால் ஒவ்வொரு கேள்விக்கும் ஒரே சரியான chunk இருக்கிறது.
Retrieval என்பது ஒரு matrix multiply
Chunk vectors-ஐ ஒரு matrix-ஆக அடுக்குங்கள். கேள்வியையும் அதே முறையில்
embed செய்யுங்கள். index @ q ஒவ்வொரு chunk-க்கும் ஒரு score
தருகிறது; argsort top-k தருகிறது. அதுதான் retriever. 21
rows-ல் இது NumPy; 21 மில்லியனில் இது FAISS — ஒவ்வொரு row-ஐயும் scan
செய்யாமல் இருக்க approximate nearest-neighbour அமைப்புடன். ஒரே பொருள்.
மூன்று கேள்விகள், தலா top-3. * பதில் chunk-ஐக் குறிக்கிறது:
Q: What is Lantern's default job timeout?
query tokens: ['lantern', 'default', 'job', 'timeout']
* #1 0.720 chunk 4 Lantern's default job timeout is 45 minutes.
#2 0.315 chunk 5 A job that exceeds its timeout is retried at most twice.
#3 0.251 chunk 0 Lantern is the internal job scheduler at Fennel Systems...
Q: Which team owns Lantern?
query tokens: ['team', 'owns', 'lantern']
* #1 0.342 chunk 12 Lantern is owned by the Platform team.
#2 0.174 chunk 13 The Platform team's on-call rotation is one week long.
#3 0.172 chunk 14 Priya Raman is the current tech lead of the Platform team.
Q: How long are failed jobs kept in the dead-letter table?
query tokens: ['long', 'failed', 'jobs', 'kept', 'dead', 'letter', 'table']
* #1 0.766 chunk 19 Failed jobs are kept in the dead-letter table for 30 days.
#2 0.398 chunk 0 Lantern is the internal job scheduler at Fennel Systems...
#3 0.175 chunk 13 The Platform team's on-call rotation is one week long.
மூன்றுக்கு மூன்றும் சரி — ஆனால் நடுவில் இருப்பதைப் பாருங்கள். "owns" எதனோடும் பொருந்தவில்லை, ஏனெனில் corpus "owned" என்று சொல்கிறது. "team" மற்றும் "lantern" மூலம்தான் அந்த hit வந்தது, சோர்வான 0.342 score-உடன்; அடுத்த இடங்களுக்கு வந்தவை "team" ஒன்றை மட்டும் வைத்து வந்தன. வார்த்தை பொருத்தத்திற்கும் அர்த்தத்திற்கும் இடையிலான அந்த இடைவெளிதான் DPR இருப்பதற்கான முழுக் காரணம். Pipeline இரண்டு வழியிலும் ஒன்றுதான்.
"owns" match aagala, corpus-la "owned" nu irukku — adhaan score 0.342-la thongudhu. Word overlap-kum meaning-kum idaila irukra gap dhaan DPR irukradhukku full reason. Pipeline rendu vazhiyilum onnu dhaan.
Prompt என்பது நீங்கள் மேசையின் மேல் நகர்த்தித் தரும் பக்கம்
Top-k chunks-ஐ எடுத்து, எண் கொடுத்து, கீழே கேள்வியை வையுங்கள். அந்த string-தான் ஒரு உண்மையான அமைப்பு LLM-க்கு அனுப்புவது. Script அதை அனுப்பாமல் அச்சிடுகிறது, ஏனெனில் இங்கிருந்து LLM-ன் வேலைதான் எளிய பாதி.
Answer using only the context below.
If the answer is not in the context, say you do not know.
Context:
[1] Lantern's default job timeout is 45 minutes.
[2] A job that exceeds its timeout is retried at most twice.
[3] Lantern is the internal job scheduler at Fennel Systems: every batch job
at Fennel is submitted to Lantern, queued by Lantern, given a timeout by
Lantern, and retried by Lantern when it fails; failed jobs that keep
failing are kept by Lantern in the dead-letter table, and failed jobs stay
there until they expire.
Question: What is Lantern's default job timeout?
Answer:
(103 words. An LLM call goes here. There is none in this script.)
இப்போது அதே கேள்வி, retriever-ஐக் கழற்றிவிட்டு:
Context:
(none)
Question: What is Lantern's default job timeout?
Answer:
இரண்டு RAG-கள்: கட்டுரையினுடையது, நீங்கள் ship செய்யப்போவது
இதை யாரும் வெளிப்படையாகச் சொல்வதில்லை: எல்லோரும் RAG என்று அழைக்கும் அமைப்பு கட்டுரையில் இருக்கும் அமைப்பு அல்ல. கட்டுரை retriever-ஐயும் generator-ஐயும் சேர்ந்து train செய்கிறது. Ship ஆவது ஒரு frozen pipeline.
| பகுதி | கட்டுரை (2020) | Ship ஆவது | இந்த script |
|---|---|---|---|
| Retriever | DPR bi-encoder; query பக்கம் fine-tune | Frozen embedding model, அல்லது BM25 | Hashed tf-idf, training இல்லை |
| Index | 21M Wikipedia passages மேல் FAISS, தலா 100 வார்த்தைகள் | ஒரு vector database | np.stack, 21 rows |
| Generator | BART-large, fine-tune செய்யப்பட்டது | API-க்குப் பின்னால் ஒரு frozen LLM | Prompt-ஐ அச்சிடுகிறது |
| Passages → model | ஒவ்வொன்றாக, k பதில்கள் சராசரி | எல்லா k-ம் ஒரே prompt-ல் | எல்லா k-ம் ஒரே prompt-ல் |
| Training signal | (கேள்வி, பதில்) இணைகள்; எந்த passage எடுக்க வேண்டும் என்பதற்கு label இல்லை | ஏதுமில்லை | ஏதுமில்லை |
Training-தான் புத்திசாலித்தனமான பகுதி. Query encoder-ஐயும் BART-ஐயும் கேள்வி–பதில் இணைகள் மேல் சேர்ந்து fine-tune செய்யுங்கள்; document encoder-ம் index-ம் frozen-ஆகவே இருக்கும், ஏனெனில் ஒவ்வொரு step-லும் 21 மில்லியன் passages-ஐ மீண்டும் embed செய்வது நடக்கக்கூடிய காரியமல்ல. இருந்தும் gradient pη(z | x) வழியாக retriever-ஐ அடைகிறது: ஒரு passage generator-க்குப் பதில் கிடைக்க உதவியிருந்தால், அந்த வகை passage-ஐ அருகில் இழுக்க query encoder கற்றுக்கொள்கிறது. "இதுதான் சரியான passage" என்று யாரும் label போடுவதில்லை. பதிலிலிருந்து மட்டுமே அது கற்கப்படுகிறது.
இரண்டு variants. RAG-Sequence முழுப் பதிலுக்கும் ஒரே passage-ஐத் தேர்ந்தெடுக்கிறது, ஒவ்வொரு passage-க்கும் generator-ஐ ஒரு முறை ஓட்டி, k பதில்களைச் சராசரிப்படுத்துகிறது. RAG-Token generate ஆகும் ஒவ்வொரு token-லும் passage-ஐ மீண்டும் தேர்ந்தெடுக்கிறது, அதனால் ஒரே பதில் பல documents-லிருந்து எடுத்துக்கொள்ள முடியும். QA benchmarks-ல் இரண்டும் சில புள்ளிகள் இடைவெளிக்குள்தான், பொதுவாக RAG-Sequence முன்னால்; Jeopardy கேள்வி உருவாக்கத்தில் RAG-Token வெல்கிறது — அங்கே ஒரு பதிலுக்கு ஒன்றுக்கும் மேற்பட்ட பக்கங்களின் உண்மைகள் தேவை. Frozen pipeline இரண்டும் அல்ல — ஒரு prompt, ஒரு call — script அதைத்தான் எழுதுகிறது, ஏனெனில் நீங்கள் debug செய்யப்போவது அதைத்தான். Generator-ம் ஒரு transformer decoder தான்: attention பக்கம், ஒரு mask-உடன்.
Retriever அமைதியாகத் தோற்கும் மூன்று வழிகள்
இவை ஒவ்வொன்றும் "LLM தவறாகச் சொல்லிவிட்டது" என்றுதான் வெளியில் தெரியும். எதுவும் LLM அல்ல. Script ஒவ்வொன்றையும் வேண்டுமென்றே உடைக்கிறது.
1. Normalisation-ஐத் தவிர்த்தால் நீளம் ஜெயிக்கும்
Divide-by-norm-ஐ நீக்கினால் dot product cosine-ஆக இருப்பது நின்றுவிடும். நீளமான chunk-க்கு நீளமான vector; நீளமான vector தனக்குச் சம்பந்தமே இல்லாத ஒப்பீடுகளையும் ஜெயிக்கும். Corpus-ல் ஒரு README பத்தி இருக்கிறது — 37 tokens, எல்லாவற்றையும் குறிப்பிடுகிறது, எதற்கும் பதில் சொல்வதில்லை. அது கைப்பற்றுவதைப் பாருங்கள்:
Q: What is Lantern's default job timeout?
cosine #1: chunk 4 (0.720) raw dot #1: chunk 0 (26.88)
Q: Which team owns Lantern?
cosine #1: chunk 12 (0.342) raw dot #1: chunk 0 (11.48)
Q: How long are failed jobs kept in the dead-letter table?
cosine #1: chunk 19 (0.766) raw dot #1: chunk 0 (68.36)
மூன்றுக்கு மூன்றும், பக்கத்திலேயே மிகப் பெரிய scores-உடன். உங்கள்
retriever return longest_paragraph ஆகிவிட்டது.
2. Corpus-ஆல் பதில் சொல்ல முடியாத ஒன்றைக் கேளுங்கள்
argsort எப்போதும் k rows-ஐத் திருப்பித் தரும். Top-k-ல்
"எனக்குத் தெரியாது" என்பதே கிடையாது. இல்லாத team பற்றிக் கேளுங்கள் —
இருக்கும் அருகிலுள்ள team கிடைக்கும், அதுவும் உண்மையான hit-ஐ விட
சிறந்த score-உடன்:
Q (answer IS in corpus): Which team owns Lantern?
* #1 0.342 chunk 12 Lantern is owned by the Platform team.
Q (answer NOT in corpus): Who is the tech lead of the Security team?
#1 0.530 chunk 14 Priya Raman is the current tech lead of the Platform team.
#2 0.201 chunk 12 Lantern is owned by the Platform team.
#3 0.129 chunk 13 The Platform team's on-call rotation is one week long.
மேலோட்டமாகப் படிக்கும் model "Priya Raman" என்று பதில் சொல்லும். ஒரு score threshold ஒரே நேரத்தில் 0.53-க்கு மேலேயும் 0.34-க்குக் கீழேயும் இருக்க வேண்டியிருக்கும். அது முடியாது. உங்களுக்கு ஒரு reranker வேண்டும், அல்லது context-ஐ உண்மையிலேயே படித்து மறுக்கும் model வேண்டும் — அதற்கு ஒரு eval-ம் வேண்டும், ஏனெனில் retriever இதை ஒருபோதும் சொல்லாது.
3. Chunks மிகப் பெரியவை: உண்மை புதைந்துவிடுகிறது
21 வாக்கியங்களை தலா 7-ஆக 3 chunks-ஆக இணையுங்கள். அதே corpus, அதே கேள்விகள், அதே கணிதம். இப்போது ஒவ்வொரு chunk-ம் Lantern-ஐக் குறிப்பிடுகிறது, அதனால் ஒவ்வொன்றும் கொஞ்சம் சரியாகத் தெரிகிறது; hit-க்கும் அடுத்தவருக்கும் இடையிலான இடைவெளி சரிந்துவிடுகிறது:
#1 score #2 score gap #1 is answer in #1?
small chunks:
Q1 0.720 0.315 0.405 chunk 4 yes
Q2 0.342 0.174 0.168 chunk 12 yes
Q3 0.766 0.398 0.368 chunk 19 yes
big chunks:
Q1 0.535 0.232 0.303 chunk 0 yes, sentence 5 of 7
Q2 0.215 0.206 0.009 chunk 2 NO
Q3 0.226 0.200 0.026 chunk 0 NO
prompt words for Q1, k=3: small chunks 103 big chunks 271 (whole document: 239)
Q2-ன் இடைவெளி 0.168-லிருந்து 0.009-ஆகக் குறைகிறது — ஒரு நாணய சுழற்சி — நாணயமும் தவறாக விழுகிறது. Q3, README பத்தியிடம் தோற்கிறது; அதில் Q3-ன் ஒவ்வொரு வார்த்தையும் இருக்கிறது, இரண்டு முறை. மேலும் 3 chunks-ல் k=3 என்பது முழு documentதான்: retriever எதுவும் செய்யவில்லை, நீங்கள் wiki-ஐ prompt-ல் ஒட்டி, ஆறு வார்த்தைக் கேள்விக்கு 271 வார்த்தைகள் கட்டினீர்கள். Pipeline-ல் மிக முக்கியமான knob chunk size-தான், யாரும் tune செய்யாத knob-ம் அதுதான்.
Chunk periusaana odane Q2-oda gap 0.168-la irundhu 0.009-kku vandhurudhu — coin toss, adhuvum thappa vizhudhu. Chunk size dhaan pipeline-la romba mukkiyamaana knob, adhe dhaan yaarum tune panradhu illa.
मैंने RAG ship किया है। दो बार। Embedding model, vector database, LLM — हो गया। और हर बार जब जवाब ग़लत आया, मैंने LLM को दोष दिया। फिर मैंने paper पढ़ा और पता चला कि LLM तो छोटा आधा हिस्सा है। जिसे आप बनाते हैं, tune करते हैं और debug करते हैं वह आधा हिस्सा है retriever — और किसी ने मुझे नहीं बताया था कि वह एक search engine है जिसके अंदर एक matrix बैठा है।
RAG एक open-book परीक्षा है
यह आप जानते हैं:
answer = llm(question) # closed book: weights को जो याद है, बस वही
Paper का मोड़ यह है कि सही पन्ना पहले model के हाथ में दिया जाए:
hits = index.search(embed(question), k=3) # सही पन्ने पर किताब खोलो
prompt = f"Context:\n{hits}\n\nQuestion: {question}"
answer = llm(prompt) # याददाश्त से नहीं, पन्ने से
यही पूरे paper का दिल है। Model विद्यार्थी है, index किताब है, और prompt वह पन्ना है जिसे आप मेज़ पर सरकाकर देते हैं। या यों कहें: database के आगे लगा एक cache। Weights ही database हैं — बदलने में धीमे, दोबारा बनाने में महँगे। Index ही cache है — जब चाहो बदल दो। Paper सचमुच यही करता है: 2018 का Wikipedia index हटाकर 2016 वाला लगा दो, और विश्व नेताओं के बारे में "अभी कौन है…" वाले सवालों के जवाब उसी हिसाब से बदल जाते हैं। कुछ भी retrain नहीं हुआ। किताब बदली, विद्यार्थी नहीं।
| शब्द | मतलब | परीक्षा वाली मिसाल |
|---|---|---|
| Query x | सवाल, जैसा टाइप किया गया | परीक्षा का सवाल |
| Passage z | Corpus का एक chunk | किताब का एक पन्ना |
| Index | हर passage एक vector के रूप में, ऊपर-नीचे रखे हुए | किताब का index |
| Retriever pη(z | x) | सवाल के सामने passage को score देता है | पन्ना ढूँढ़ना |
| Generator pθ(y | x, z) | LLM, जिसे passage और सवाल दोनों मिले हैं | खुली किताब वाला विद्यार्थी |
| Parametric memory | जो weights में है | जो विद्यार्थी ने रट रखा है |
| Non-parametric memory | जो index में है | किताब |
इससे पहले, T5 जैसा closed-book model हर तथ्य अपने weights में रखता था, और extractive systems (reader के साथ DPR, REALM) passages तो लाते थे पर उनमें से सिर्फ़ एक span नक़ल कर पाते थे। RAG लाता है, फिर generate करता है। इसी ने तीन open-domain QA benchmarks पर state of the art बनाया — 11 अरब parameter वाले closed-book T5 और extractive retrievers, दोनों से आगे।
वह एक सूत्र
इसे pipeline के चरणों की तरह पढ़िए, पहले retriever:
| चरण | Shape | क्या करता है |
|---|---|---|
d(z) | (768,) × 21M rows | हर passage एक बार BERT से होकर। Frozen। FAISS में रखा हुआ। |
q(x) | (768,) | सवाल दूसरे BERT से होकर — retriever का यही एक हिस्सा train होता है |
d(z)T q(x) | scalar | Similarity. एक dot product, वही जो attention में था |
top-k | k passages | argsort, सबसे अच्छे k रख लो (paper में 5 या 10) |
pη(z | x) | k संख्याएँ, जोड़ = 1 | k scores पर softmax: किस पन्ने पर कितना भरोसा |
pθ(y | x, z) | हर passage पर एक जवाब | BART, [passage ; question] मिलने पर जवाब लिखता है |
Σ | एक जवाब | k जवाबों का भारित औसत, भार retriever से |
Model के बिना embedding
Paper का retriever DPR है: दो BERT encoders (BERT वाला पन्ना देखिए), एक सवालों के लिए, एक passages के लिए, इस तरह train किए गए कि सवाल और उसका जवाब देने वाला passage पास-पास गिरें। मेरे NumPy script में कोई train किया हुआ BERT नहीं है, और मैं चाहता भी नहीं कि कोई चीज़ pipeline को ढँक दे। तो script खोज की सबसे पुरानी तरकीब इस्तेमाल करता है: शब्द गिनो, दुर्लभ शब्दों को ज़्यादा भार दो (tf-idf), हर शब्द को hash करके एक तयशुदा चौड़ाई वाले vector के slot में डालो, और लंबाई से भाग दो।
Shape के लिहाज़ से ईमानदार — हर chunk का एक तय आकार का vector, dot product से तुलना, बिलकुल वही जो FAISS देखता है। मतलब के लिहाज़ से बेईमान: DPR जानता है कि "owns" और "owned" एक ही बात है; hash table नहीं जानता। इसकी क़ीमत एक मिनट में चुकानी पड़ेगी।
यह देखिए, एक chunk vector बनते हुए:
chunk 4: Lantern's default job timeout is 45 minutes.
tokens: ['lantern', 'default', 'job', 'timeout', '45', 'minutes']
buckets: [279, 223, 248, 402, 299, 675]
vector shape: (1024,) nonzero entries: 6 of 1024
L2 norm: 1.000 ✓
bucket 279 lantern idf 1.38 weight 0.200
bucket 223 default idf 3.40 weight 0.491
bucket 248 job idf 2.01 weight 0.291
bucket 402 timeout idf 2.70 weight 0.391
bucket 299 45 idf 3.40 weight 0.491
bucket 675 minutes idf 3.40 weight 0.491
vocabulary: 118 distinct tokens -> 1024 buckets, 8 hash collisions
"lantern" 21 में से 14 chunks में है, इसलिए उसे सबसे कम भार मिलता है; "default", "45" और "minutes" एक-एक chunk में ही हैं, इसलिए vector का बोझ वही उठाते हैं। Tf-idf बस इतना ही है। Corpus, Lantern नाम के एक गढ़े हुए job scheduler के बारे में 21 वाक्य हैं — गढ़े हुए, ताकि दुनिया से कुछ भी fact-check न हो सके; और आपस में संगत, ताकि हर सवाल का एक ही सही chunk हो।
Retrieval एक matrix multiply है
Chunk vectors को एक matrix में जमा दीजिए। सवाल को भी उसी तरह embed
कीजिए। index @ q हर chunk का एक score देता है;
argsort top-k देता है। बस यही retriever है। 21 rows पर यह
NumPy है; 2.1 करोड़ पर यह FAISS है, एक approximate nearest-neighbour
structure के साथ, ताकि हर row scan न करनी पड़े। चीज़ वही है।
तीन सवाल, हर एक पर top-3। * जवाब वाले chunk का निशान है:
Q: What is Lantern's default job timeout?
query tokens: ['lantern', 'default', 'job', 'timeout']
* #1 0.720 chunk 4 Lantern's default job timeout is 45 minutes.
#2 0.315 chunk 5 A job that exceeds its timeout is retried at most twice.
#3 0.251 chunk 0 Lantern is the internal job scheduler at Fennel Systems...
Q: Which team owns Lantern?
query tokens: ['team', 'owns', 'lantern']
* #1 0.342 chunk 12 Lantern is owned by the Platform team.
#2 0.174 chunk 13 The Platform team's on-call rotation is one week long.
#3 0.172 chunk 14 Priya Raman is the current tech lead of the Platform team.
Q: How long are failed jobs kept in the dead-letter table?
query tokens: ['long', 'failed', 'jobs', 'kept', 'dead', 'letter', 'table']
* #1 0.766 chunk 19 Failed jobs are kept in the dead-letter table for 30 days.
#2 0.398 chunk 0 Lantern is the internal job scheduler at Fennel Systems...
#3 0.175 chunk 13 The Platform team's on-call rotation is one week long.
तीन में से तीन — पर बीच वाले को देखिए। "owns" किसी से मेल नहीं खाया, क्योंकि corpus में "owned" लिखा है। सही hit "team" और "lantern" के दम पर आया, एक कमज़ोर 0.342 score के साथ, और उपविजेता सिर्फ़ "team" के भरोसे वहाँ पहुँच गए। शब्दों के मेल और मतलब के बीच की यही खाई DPR के होने की पूरी वजह है। Pipeline दोनों हाल में एक जैसा ही रहता है।
Prompt वही पन्ना है जो आप मेज़ पर सरकाते हैं
Top-k chunks लीजिए, उन्हें नंबर दीजिए, नीचे सवाल रख दीजिए। यही string एक असली system LLM को भेजता है। Script उसे भेजने के बजाय छाप देता है, क्योंकि यहाँ से आगे LLM का काम ही आसान आधा हिस्सा है।
Answer using only the context below.
If the answer is not in the context, say you do not know.
Context:
[1] Lantern's default job timeout is 45 minutes.
[2] A job that exceeds its timeout is retried at most twice.
[3] Lantern is the internal job scheduler at Fennel Systems: every batch job
at Fennel is submitted to Lantern, queued by Lantern, given a timeout by
Lantern, and retried by Lantern when it fails; failed jobs that keep
failing are kept by Lantern in the dead-letter table, and failed jobs stay
there until they expire.
Question: What is Lantern's default job timeout?
Answer:
(103 words. An LLM call goes here. There is none in this script.)
अब वही सवाल, retriever को हटाकर:
Context:
(none)
Question: What is Lantern's default job timeout?
Answer:
दो RAG: paper वाला, और जो आप ship करेंगे
यह बात कोई खुलकर नहीं कहता: जिस system को सब RAG कहते हैं, वह paper वाला system है ही नहीं। Paper retriever और generator को साथ train करता है। जो ship होता है वह एक frozen pipeline है।
| हिस्सा | Paper (2020) | जो ship होता है | यह script |
|---|---|---|---|
| Retriever | DPR bi-encoder; query वाला हिस्सा fine-tune | एक frozen embedding model, या BM25 | Hashed tf-idf, कोई training नहीं |
| Index | 2.1 करोड़ Wikipedia passages पर FAISS, हर एक 100 शब्द | एक vector database | np.stack, 21 rows |
| Generator | BART-large, fine-tune किया हुआ | API के पीछे एक frozen LLM | Prompt छाप देता है |
| Passages → model | एक-एक करके, k जवाबों का औसत | सारे k एक ही prompt में | सारे k एक ही prompt में |
| Training signal | (सवाल, जवाब) जोड़े; कौन-सा passage लाना है इसका कोई label नहीं | कोई नहीं | कोई नहीं |
असली चतुराई training में है। Query encoder और BART को सवाल–जवाब जोड़ों पर साथ fine-tune कीजिए; document encoder और index frozen रहते हैं, क्योंकि हर step पर 2.1 करोड़ passages दोबारा embed करना कोई चीज़ ही नहीं है। फिर भी gradient pη(z | x) के रास्ते retriever तक पहुँचता है: अगर किसी passage ने generator को सही जवाब तक पहुँचाया, तो query encoder वैसे passages को पास खींचना सीख लेता है। कोई यह label नहीं लगाता कि "यही सही passage था"। यह सिर्फ़ जवाब से सीखा जाता है।
दो variants। RAG-Sequence पूरे जवाब के लिए एक passage चुनता है, हर passage पर generator एक बार चलाता है, और k जवाबों का औसत लेता है। RAG-Token हर generate होने वाले token पर passage दोबारा चुनता है, ताकि एक ही जवाब कई documents से खींच सके। QA benchmarks पर दोनों दो-चार अंक के भीतर हैं, आम तौर पर RAG-Sequence आगे; Jeopardy-सवाल बनाने में RAG-Token जीतता है, जहाँ एक जवाब के लिए एक से ज़्यादा पन्नों के तथ्य चाहिए। Frozen pipeline इनमें से कोई नहीं है — एक prompt, एक call — और script वही लिखता है, क्योंकि आप उसी को debug करेंगे। Generator ख़ुद एक transformer decoder है: attention वाला पन्ना, एक mask के साथ।
Retriever के चुपचाप फ़ेल होने के तीन तरीक़े
इनमें से हर एक "LLM ग़लत था" बनकर सामने आता है। इनमें से कोई भी LLM नहीं है। Script हर एक को जान-बूझकर तोड़ता है।
1. Normalisation छोड़िए और लंबाई जीत जाएगी
Divide-by-norm हटा दीजिए और dot product cosine रहना बंद कर देता है। लंबे chunk का vector लंबा होता है, और लंबा vector ऐसी तुलनाएँ जीत जाता है जिनमें उसका कोई हक़ नहीं। Corpus में एक README पैराग्राफ़ है — 37 tokens, ज़िक्र सबका, जवाब किसी का नहीं। देखिए वह कैसे क़ब्ज़ा कर लेता है:
Q: What is Lantern's default job timeout?
cosine #1: chunk 4 (0.720) raw dot #1: chunk 0 (26.88)
Q: Which team owns Lantern?
cosine #1: chunk 12 (0.342) raw dot #1: chunk 0 (11.48)
Q: How long are failed jobs kept in the dead-letter table?
cosine #1: chunk 19 (0.766) raw dot #1: chunk 0 (68.36)
तीन में से तीन, और पन्ने के सबसे बड़े scores के साथ। आपका retriever
return longest_paragraph बन चुका है।
2. ऐसा कुछ पूछिए जिसका जवाब corpus में है ही नहीं
argsort हमेशा k rows लौटाता है। Top-k में "मुझे नहीं पता"
जैसा कुछ होता ही नहीं। ऐसी team के बारे में पूछिए जो है ही नहीं, और आपको
सबसे नज़दीकी मौजूद team मिलेगी — असली hit से बेहतर score के साथ:
Q (answer IS in corpus): Which team owns Lantern?
* #1 0.342 chunk 12 Lantern is owned by the Platform team.
Q (answer NOT in corpus): Who is the tech lead of the Security team?
#1 0.530 chunk 14 Priya Raman is the current tech lead of the Platform team.
#2 0.201 chunk 12 Lantern is owned by the Platform team.
#3 0.129 chunk 13 The Platform team's on-call rotation is one week long.
ऊपर-ऊपर से पढ़ने वाला model "Priya Raman" कह देगा। किसी score threshold को एक ही साथ 0.53 से ऊपर और 0.34 से नीचे बैठना पड़ेगा। यह हो नहीं सकता। आपको एक reranker चाहिए, या ऐसा model जो सचमुच context पढ़े और मना कर दे — और उसके लिए एक eval भी, क्योंकि retriever आपको कभी नहीं बताएगा।
3. Chunks बहुत बड़े: तथ्य दब जाता है
21 वाक्यों को 7-7 करके 3 chunks में मिला दीजिए। वही corpus, वही सवाल, वही गणित। अब हर chunk में Lantern का ज़िक्र है, तो हर chunk थोड़ा-थोड़ा सही लगता है, और सही hit और उपविजेता के बीच की खाई ढह जाती है:
#1 score #2 score gap #1 is answer in #1?
small chunks:
Q1 0.720 0.315 0.405 chunk 4 yes
Q2 0.342 0.174 0.168 chunk 12 yes
Q3 0.766 0.398 0.368 chunk 19 yes
big chunks:
Q1 0.535 0.232 0.303 chunk 0 yes, sentence 5 of 7
Q2 0.215 0.206 0.009 chunk 2 NO
Q3 0.226 0.200 0.026 chunk 0 NO
prompt words for Q1, k=3: small chunks 103 big chunks 271 (whole document: 239)
Q2 की खाई 0.168 से घटकर 0.009 रह जाती है — यानी सिक्का उछालना — और सिक्का ग़लत तरफ़ गिरता है। Q3 उसी README पैराग्राफ़ से हार जाता है, जिसमें उसके सारे शब्द मौजूद हैं, दो-दो बार। और 3 chunks में से k=3 यानी पूरा document: retriever ने कुछ किया ही नहीं, आपने wiki को prompt में चिपका दिया और छह शब्दों के सवाल के लिए 271 शब्द चुकाए। Chunk size इस pipeline का सबसे असरदार knob है और वही है जिसे कोई tune नहीं करता।
The code
Pure NumPy. No torch, no model, no training — just the retriever, so you can watch the scores move. Every function under 15 lines.
Download the full script — nine short functions plus eight runnable demos. Then:
python3 rag_from_scratch.py
D = 1024 # embedding width. DPR's is 768. Ours is a hash table, not a model.
def bucket(token):
"""Hashing trick: token -> a fixed slot in [0, D). No vocabulary to build,
store or ship. crc32 rather than hash() because hash() is salted per
process and the index would not survive a restart."""
return zlib.crc32(token.encode()) % D
def fit_idf(chunks):
"""Inverse document frequency, one weight per bucket.
A word in most chunks ("lantern") is worth little; a word in one chunk
("timeout") is worth a lot. log((N+1)/(df+1)) + 1, the smoothed textbook
form. Same instinct as BM25, minus the tuning knobs."""
df = np.zeros(D)
for c in chunks:
for b in set(bucket(t) for t in tokenise(c)):
df[b] += 1
return np.log((len(chunks) + 1) / (df + 1)) + 1
def embed(text, idf, normalise=True):
"""text -> (D,) vector.
(1) count each bucket -- term frequency
(2) multiply by idf -- rare words count more
(3) divide by the L2 norm -- so dot product == cosine, length-blind
Step 3 is the one people delete. Demo 5 shows what happens."""
v = np.zeros(D)
for t in tokenise(text):
v[bucket(t)] += 1
v *= idf
if normalise:
v /= np.linalg.norm(v) + 1e-12
return v
def retrieve(question, index, idf, k=3, normalise=True):
"""The whole retriever: one matrix-vector product and an argsort.
scores[i] = similarity(question, chunk i). Return the k best, best first.
There is no "nothing matched" -- argsort always returns k rows."""
q = embed(question, idf, normalise)
scores = index @ q # (n_chunks,)
top = np.argsort(-scores)[:k]
return [(int(i), float(scores[i])) for i in top]
chunk, tokenise, build_index (an
np.stack) and build_prompt (the f-string you saw
above) are in the script.
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — reverse-engineering from broken behaviour is your native mode, and every row here is a bug you will one day ship.
| Break this | Watch for |
|---|---|
Pass normalise=False to build_index and retrieve | Chunk 0, the 37-token README paragraph, wins all three questions. Raw score 68.36 on a question it doesn't answer. |
k=1 on the two-hop question in demo 8 | hop 2 in prompt: False. The prompt says "served by Beacon" and never mentions a port. At k=3 it arrives at rank 3, 0.187, next to the wrong port. |
chunk(DOCUMENT, 7) — 3 giant chunks instead of 21 | Q2's gap drops from 0.168 to 0.009, and Q2 and Q3 both retrieve a chunk without the answer. The prompt grows from 103 words to 271. |
build_prompt(q, []) — no retrieval | Context: (none). The model answers from its weights. For a private corpus that's a guess dressed as a fact. |
| Ask "Who is the tech lead of the Security team?" | Top hit 0.530 for the Platform team's lead — higher than a genuine hit at 0.342. No threshold separates them. |
D = 32 instead of 1024 | The vocabulary line reports 87 hash collisions. "Which team owns Lantern?" now returns the max_workers sentence at 0.557 — two unrelated words share a slot. |
On the maths anxiety
The retriever is a dot product and an argsort. You've done both today. The tf-idf weight is a log, there for the usual reason: it squashes "in 1 chunk" versus "in 14" into a sensible range. L2 normalisation is a divide. That's the entire retrieval half.
The formula's Σ is a weighted average of k answers, and the weights are a
softmax over k similarity scores — the same softmax as attention. The
training loss is the negative log of that average: log so multiplication
becomes addition and small probabilities don't underflow.
loss.backward() does the rest, and it doesn't go through the
index, because the paper froze it. You would too.
What you actually need for RAG work isn't maths: a chunking strategy, an embedding model, a reranker, an eval set of questions with known answers, and the habit of reading the retrieved passages before blaming the model.
Where to go next
This page gave you one thing: the retriever as a search engine you can break. That's deliberately narrow. These fill in the trained version, the family tree, and the real code.
-
Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
§2 is the whole method in a few pages and reads as the stage table above. Then the "Additional Results" subsection of §4 for the hot-swapping experiment and the ablation that swaps DPR for BM25 — the honest comparison for my hash table.
-
How to Build an Open-Domain Question Answering System?
The family tree I skipped: DrQA, ORQA, REALM, DPR, RAG, Fusion-in-Decoder, side by side, with the training objectives written out. Go here to see "the query encoder learns from the answer alone" as an equation.
-
RAG — Hugging Face Transformers documentation
The paper's actual system as three classes:
RagRetriever,RagSequenceForGeneration,RagTokenForGeneration. Where the jointly-trained version I only described becomes code you can call, with the real DPR encoders and the real FAISS index.