RAG From Scratch

Getting a model to answer from your documents, without retraining it.

Prerequisites

This course assumes you know what a token, an embedding and a context window are. If those words mean nothing to you, start with how an LLM works, then come back.

Course outline
  1. 0The problem we are solving
  2. 1The complete pipeline
  3. 2The settings that matter
  4. 3The limits, honestly
  5. 4Going further

Chapter 0The problem we're trying to solve

Imagine the situation. You have 300 PDFs: internal procedures, technical documentation, meeting notes. You'd like to ask a question in plain language, something like "what is the procedure if sensor X saturates?", and get an accurate answer from these documents, with the page it comes from.

You open ChatGPT. First problem: it doesn't know your documents, it learned from public text up to a certain date. Second problem, more insidious: it doesn't tell you when it doesn't know. A language model is trained to produce a plausible sequence of words, not to verify. Faced with a question it doesn't know the answer to, it makes one up that looks like a good answer. This is called a hallucination.

How do you give a model your own documents, when it has never seen them?

There are three possible answers. The first two don't work here, and seeing where they fail helps explain the third.

Option 1: retrain it on your documents

This is fine-tuning. You extend the model's training with your data. It sounds logical, and it's almost always the wrong tool here:

Why it doesn't work well Fine-tuning mostly learns a style, a format, a tone, rather than precise facts. A model "fine-tuned" on your procedures will write like your procedures without actually reproducing the exact figures. It's expensive and takes hours. You have to start over every time a document changes. And most importantly: it cannot cite its source, because the information is diluted across billions of weights.

Option 2: paste everything into the prompt

Since the model reads what you write to it, why not paste all 300 PDFs before the question? Because there is a context window: the amount of text a model can read at once. It is limited, and it is metered. 300 PDFs won't fit. And even if they did, you would pay for your entire documentation with every question, for accuracy that drops as useful information gets buried in noise.

Option 3: give it only the relevant passages, the RAG

The idea is simple once stated: you don't give everything, you give just what's needed. Before asking the model a question, you automatically search for the 3 to 5 relevant passages in the 300 PDFs, and paste them into the prompt along with the question. The model only has to compose an answer from what it sees.

The analogy that works

It's an open-book exam.
Fine-tuning is memorizing the book (long, imprecise, to redo with each edition).
Pasting everything into the prompt is re-reading the entire library with every question.
RAG is opening the book to the right page, then answering from that.

Hence the name, RAG, for Retrieval-Augmented Generation:

  • Retrieval · find the relevant passages among all your documents.
  • Augmented · augment the prompt: add these passages to it.
  • Generation · generate: the model composes the answer from them.
Watch out for this

The model does not search your document base. There is no access to it. It is your code that searches, finds the passages, and writes them into the prompt. The model only sees text that is slightly longer than usual. All the interesting work of RAG happens before the call to the model.

What this changes in practice: the answer becomes verifiable, since you know which document and page it came from. Updating knowledge takes a few seconds, the time to reindex a file. And the model stays generic, so you can swap it for another.

RAG in one image: two distinct phases

Once only · prepare
Your documents
Splitinto passages
Transformedinto numbers
Storedin a database
With each question · answer
Question
We searchfor nearby passages
We assemblethe prompt
The model composes
The rest of this course fills in these boxes, one by one.

That leaves the hard part: how does a machine find the right passages? Searching for words from the question is not enough. If the document says "the sensor is in saturation" and you ask "what to do when X overflows?", no words match. You need to search by meaning, That is what embeddings are for, explained in the course on how an LLM works.

Chapter 1The complete pipeline

You now have the two building blocks. Let's assemble the machine. It works in two phases you must never confuse: one preparation done just once, and one loop replayed with every question.

Phase 1 · indexing, just once
  • Extract the text. An embedding model reads only text: you pull content from PDFs, .docx files, web pages, and throw away formatting.
  • Split into chunks. You cut into pieces of 200 to 500 tokens. Two reasons: a whole document exceeds the embedding model's window (often 512 tokens), and a single vector for 50 pages would be far too vague to find precise information.
  • Plan for overlap. You repeat a few sentences from the end of one chunk at the start of the next, so an idea landing exactly on a boundary stays whole somewhere.
  • Vectorize each chunk. Each piece goes through the embedding model and comes out as a vector, its address in the space of meaning.
  • Store everything. In a vector database, you keep three things per chunk: the vector (to search), the original text (to answer), and file and page metadata (to cite).

Splitting and its overlap

the extracted text, in full chunk 1 chunk 2 chunk 3 in amber: the repeated zones to never split an idea in two
A typical setting: 300-token chunks, 15% overlap.
The rule to remember

You search on vectors, you answer with text. The vector finds, never writes. That is why you store both.

From a passage to a single vector

To compare whole passages, you don't want one vector per word: you want one single vector for an entire paragraph. The embedding model reads the whole passage, contextualizes each word, then averages. It outputs one point that sums up the whole thing.

Detail that always surprises

The size of this vector is set by the model (384, 768, 1024, 3072 numbers), never by text length. A 5-word sentence and a 300-word paragraph both give a vector of the same size.

Remember this now

There are two distinct families of models: an embedding model (small and fast, it turns text into vectors for searching) and an LLM (large, it composes). They do different jobs and you never confuse them.

The two tokenizers in a RAG

The key

A RAG manipulates two different tokenizers. That's the trap that surprises everyone.

Two paths, two tokenizers

Indexing
Documents
Tokenizerembedding
Vectors → index
Generation
Question + chunks
TokenizerLLM
Response
One splits for search, the other budgets generation.
Concrete steps

① Chunk by tokens, not characters → respect the embedding model's window.

② Count tokens (tiktoken) → don't exceed the LLM's context.

③ Remember the tax: a French corpus fills chunks faster, so you fit fewer.

For your RAG

It's the contextual embedding that matters, only it knows how to distinguish meanings. It is calculated once per chunk at indexing, then stored in your database.

Phase 2 · the query, with every question
  • Vectorize the question. With exactly the same embedding model as the chunks. Otherwise the vectors live in two different spaces and are not comparable.
  • Search for the closest. You ask the database for the k chunks whose vector is closest to the question's (k is typically 3 to 10).
  • Assemble the prompt. Three blocks: an instruction ("answer by relying on the context below"), the text of the k chunks, then the question.
  • Generate. The LLM reads this prompt and composes. It has the material in view, so it invents much less.
  • Show sources. Thanks to metadata kept in step 5, you indicate which documents and pages the answer came from.

How do you measure "close"?

By the angle between two vectors, called cosine similarity. The intuition: in this space, it is the direction that carries meaning, not the length of the arrow. Two texts pointing in the same direction talk about the same thing.

Small angle = same subject

the question chunk A · 0.95 chunk B · 0.2 1 · identical 0 · no relation −1 · opposite
Score between 0 and 1 in practice. You keep the highest. Above 0.8, it is generally relevant.
Good news

You do not implement any of this. The vector database does the computing and optimization: you choose the metric (cosine) and the k, you call search(vector, k), you get back the passages sorted.

→ The full details (cosine formula, worked example, brute force vs HNSW, late chunking) are in cheat sheet on this page.

Chapter 2The settings that matter

A RAG that answers poorly is almost always one of these six things.

chunk size200 to 500 tokens. Too small: the passage loses context. Too large: the vector gets vague and finds poorly.
overlap10 to 15% of chunk size. Enough to not split an idea, not enough to duplicate the whole database.
k3 to 10 passages. Too few: information is missing. Too many: you overwhelm the model and pay tokens for nothing.
score thresholdIgnore chunks below ~0.8 cosine, rather than send off-topic stuff to the LLM "because you needed k results".
same modelThe same embedding model for documents and questions, no exception.
count in tokensSplit by tokens, not characters, and remember that French text costs roughly 1.5× more.

What this crosses with

The payoff

You store your chunks as vectors. For a question, you turn it into a vector and retrieve the vectors that are closest.

Three reminders that overlap

Two models, not one: the embedding model (search) ≠ the LLM (answer).

Window of the embedding model often 512 tokens → hence chunking by tokens.

Linguistic tax: a French chunk fills the window faster → fewer words per vector.

Vectors live in a vector database. Finding the closest among millions is done by approximate search (ANN).

Chapter 3The limits, honestly

RAG is not magic. Knowing where it breaks saves weeks of work.

Extraction is the weak link A two-column PDF, a table, a scan: the extracted text comes out jumbled, and everything downstream inherits this mess. The biggest quality loss comes from here, long before model choice.
Context is lost at boundaries Each chunk is encoded alone: if a "he" refers to the previous paragraph, the vector ignores it. Overlap helps, but does not solve it.
Global questions fail "How many documents mention X?", "give me a summary of everything": RAG brings back k passages, it never reads the whole thing. This is not the tool for that.
A bad question finds poorly A vague question gives a vague vector, so random neighbors. Hence techniques to automatically rephrase the question.
Search is approximate Beyond a few hundred thousand vectors, you do not explore everything: the index trades a tiny chance of exactness for much speed.
And most of all

RAG reduces hallucinations, it does not eliminate them. If the supplied passages are off-topic, the model will still write something. That is why showing sources matters: it makes the error visible.

Chapter 4Going further

Once you have a basic RAG in place, here is the order to improve it.

  • Hybrid search. Combine semantic search (vectors) and exact-word search (BM25). Essential once you have references, product codes, proper names that vectors handle poorly.
  • Re-ranking. Get 30 candidates, then have a small specialized model sort them finely by reading the question and the passage together. Often the best gain for effort.
  • Better splitting. Late chunking (encode the whole document before splitting) or contextual retrieval (prefix each chunk with a document summary) recover context lost at boundaries.
  • Evaluation. Build a set of about 30 questions where you know the answer and expected source, then measure. Without this, you optimize blind.
End of course

You now have the complete mental model: split, vectorize, store, find by proximity, paste into prompt, generate, cite.

Switch to the Sheets tab for the condensed version, with technical details, formulas, and reference diagrams.

The RAG pipeline

From your raw documents to a sourced answer. Step by step.

Plan of the sheet
  1. §1The sources
  2. §2Text extraction
  3. §3Chunking
  4. §4Embedding each chunk
  5. §5Storage: the vector database
  6. §6The question and its embedding
  7. §7Search by similarity (cosine)
  8. §8Augmentation: the prompt
  9. §9Generation
  10. §10The answer, sourced

Overview: two phases

Phase 1 · indexing, once only
Documents
Extraction
Chunking
Embedding
Vector DB
↑ the database is read by the search below ↓
Phase 2 · query, every question
Question
Embedding
Search
Prompt
LLM
Answer
Knowledge is stored once, queried every time.
The mental model, in a nutshell

The LLM does not search your database. It's you who searches, then you pass the found text to it in the prompt. It has no access to your database.

The essentials in 8 points

  1. Two phases: index once, query every time.
  2. The LLM does not search. You search, you give it the text.
  3. Two models: embedding (search) + LLM (answer).
  4. The same embedding model for chunks AND the question.
  5. We search on vectors, we answer with text.
  6. Chunking by tokens + overlap.
  7. k = number of chunks retrieved · "close" = cosine similarity.
  8. Sources come from metadata stored with each chunk.
Part I · Indexing

§1The sources

PDF, Word (.docx), web pages, source code... the knowledge you want to make queryable. At this stage, nothing is done yet: these are files on a disk.

§2Text extraction

The key

An embedding model reads only text. We discard the formatting (fonts, columns, images), we keep the content.

document.pdf
report.docx
script.py
plain text
Without this step, it's impossible to vectorize anything.

§3Chunking

Why chunk: 2 reasons

① An entire document exceeds the window of the embedding model (often 512 tokens).
② A single vector for 50 pages would be too vague to find a specific piece of info.

Typical size: 200 to 500 tokens. We chunk by tokens, not by characters.

Chunks + overlap

extracted text (long) split by tokens chunk 1 chunk 2 chunk 3 amber zones = overlap repeat a few sentences to avoid cutting an idea
If info lands right on a boundary, the overlap ensures it is complete in at least one chunk.

§4Embedding each chunk

The key

The chunk's vector is its address in the meaning space. Two chunks with similar meaning = nearby vectors, even if they don't use the same words.

The internal mechanism

The chunktext
tokenizer1 vector / token
poolingthe average
[0.07, 0.5, …]1 vector · fixed size
In practice: you pass the text, you receive an array of numbers.
Critical point

Attention only links tokens within the chunk itself. Each chunk is encoded in isolation: the vector of chunk 2 knows nothing about chunk 1 or 3.

Attention does not cross boundaries

chunk 1• • •
chunk 2• • •
chunk 3• • •
↓ each produces its vector, alone ↓
vector 1
vector 2
vector 3
✕ = attention does not pass from one chunk to another.
Strengths Fast: chunks encoded in parallel, and you can re-encode a single document without touching the others. Precise: one chunk = one targeted idea = clean search.
Limitation Context lost at boundaries: if a "he" refers to the previous chunk, the vector doesn't know. Overlap lessens this, without solving it.
The variants: late chunking, contextual retrieval, sentence-window

Late chunking: reverse the order, embed the entire document first (each token sees its neighbors), and only split the vectors afterwards, just before pooling. Each chunk vector has therefore "seen" its neighbors. Cost: a long-context model and more computation.

entire doc
full encodingglobal attention
split + poolingAFTER

Contextual retrieval: prepend a short document summary to the head of each chunk before encoding it.

Sentence-window: encode small chunks, but return to the LLM the chunk plus its neighbors when answering.

Isolated chunking remains the default: simpler, and a vector overloaded with context retrieves less sharply.

§5Storage: the vector database

3 things per chunk

① the vector (the search key) · ② the original text (this is what we'll send to the LLM) · ③ the metadata (source, page → to cite).

What the database contains

IDVector (key)Chunk textSource
1[0.07, 0.5, …]"Low temperature cooking..."guide.pdf · p.3
2[−0.2, 0.1, …]"Preheat the oven..."guide.pdf · p.4
3[0.4, −0.3, …]"def cook(temp): …"script.py
Amber = we search on it · green = that's what we send to the LLM.
Not to be confused

The vector serves to find. The text serves to answer. This entire phase (§1 to §5) is done only once.

Part II · Query

§6The question and its embedding

Absolute rule

The question is encoded by exactly the same embedding model as the chunks. Otherwise the vectors live in different spaces and are not comparable.

question (text)
the SAME embedding model
[0.05, 0.48, …]

§7Search by similarity

First: a vector is ONE point

The array gives the coordinates of a single point. Each cell = one axis. 768 numbers = 1 point in a 768-dimensional space, not 768 points.

The array = the coordinates of a point

array 2 1 x axis y axis = x y 2 1 O (0,0) (2, 1) anchored at origin: direction + length
We can't draw 768D, but the angle between two arrows can still be calculated.
Cosine similarity

Meaning is carried by the direction. So to compare two vectors, we measure the angle between them.

Small angle = nearby meaning

origin question chunk A · cos ≈ 0.95 chunk B · cos ≈ 0.2 1 · identical 0 · unrelated -1 · opposite cos
In practice for embeddings: between 0 and 1. We keep the highest cosines.
The formula

cos(θ) = (A·B) / (‖A‖ × ‖B‖)

The dot product divided by the lengths, which cancels the size effect: only direction matters.

If the vectors are normalized (length 1): cosine = simple dot product. That's why it's fast.

The step-by-step calculation + a worked example

1. Dot product: A·B = a₁b₁ + a₂b₂ + … + aₙbₙ (multiply term by term, sum).

2. Length (Pythagoras): ‖A‖ = √(a₁² + a₂² + … + aₙ²).

3. Divide. (If you want the angle: θ = arccos(cos θ), but for search we stop at cosine, which is the score.)

Example. A = [2, 1], B = [1, 3]
A·B = 2×1 + 1×3 = 5
‖A‖ = √5 ≈ 2.24 · ‖B‖ = √10 ≈ 3.16
cos θ = 5 / (2.24 × 3.16) ≈ 0.71 → about 45 degrees.

0.71 means "fairly close". In practice we mainly keep scores > ~0.8.

Trig circle memo: reading a cosine

The cosine of an angle = the horizontal coordinate of the point on the circle. The smaller the angle, the further right we are, the closer the cosine is to 1.

cos 0 π/2 π θ cos θ a vector angle ↑ (0→π/2→π) ⇒ cosine ↓ (1→0→-1)

For RAG: we aim for cos ≈ 1 → angle ≈ 0 → same direction.

How to find the k nearest?

Brute force = calculate the cosine with every vector, sort, keep the top-k. Exact, but N calculations per question.
ANN (approximate) = an index compares only a small fraction. Tiny chance of missing the closest one.

Brute force vs HNSW

Brute force (exact) question compare to N vectors slow if N is large ANN · HNSW start question hops between neighbors visits a handful of nodes
HNSW links each vector to its neighbors, then "walks" toward the question.
In practice

It's the vector database that does everything. You choose the metric (cosine) and the k, you call search(vector, k), you get back the k chunks sorted. You don't implement cosine or HNSW.

§8Augmentation: the prompt

This is the "augmented" in RAG

A prompt in 3 parts: instruction + context (the text of the k chunks) + question.

Prompt assembly

1 · Instruction"rely on the context"
2 · Contextthe text of the k chunks
3 · Questionfrom the user
PROMPT SENT TO LLM
The LLM receives the useful passages in its prompt. We bring the information to it.

§9Generation

Promptcontext + question
LLMgeneration
Answer+ sources
Generation LLM ≠ embedding model: each has its role, its tokenizer, its window.
Why it works

The answer is grounded in specific, identified passages → fewer hallucinations. The model has the material right in front of it instead of answering from memory.

§10The answer, sourced

Thanks to the metadata kept in storage (§5), we display the sources: which documents, which pages helped. The answer becomes verifiable.

The loop

Only steps §6 to §10 are replayed with each question.
Indexing (§1 to §5) is redone only if your documents change.

Cheat sheet recap

  1. Two phases: index once, query every time.
  2. The LLM does not search the database. You search, you pass the text to it.
  3. Two distinct models: embedding (search) + LLM (answer).
  4. The same embedding model for chunks AND the question.
  5. We search on vectors, we answer with text.
  6. Chunking by tokens + overlap.
  7. k chunks retrieved · proximity = cosine similarity (aim > 0.8).
  8. Metadata → sources → verifiable answer.