Foundations
Retrieval-Augmented Generation (RAG) is how you make an LLM answer from your documents instead of only its frozen training data. This beginner chapter builds the mental model from the ground up: why RAG exists, the retrieve → augment → generate loop, what an embedding really is, how chunking decides what the model can find, and how to assemble a minimal but correct pipeline in plain Python. You will finish able to explain and hand-build the simplest RAG that actually works.
Learning objectives
- Explain why RAG exists — the three problems it solves that a bare LLM cannot.
- Trace the retrieve → augment → generate loop and name what each stage owns.
- Describe what an embedding is and why cosine similarity measures meaning.
- Chunk a document sensibly and explain how chunk size changes what can be found.
- Hand-build a minimal RAG pipeline in Python and reason about where it breaks.
- Tell the difference between RAG, fine-tuning, and a bigger context window — and when to reach for each.
1 · Why RAG exists
A large language model knows only what it saw during training. That creates three concrete problems the moment you point it at real work:
| Problem | What it looks like | How RAG fixes it |
|---|---|---|
| Stale knowledge | The model has no idea about last week's incident, your internal runbook, or a document written after its training cutoff. | You retrieve the current document at question time and put it in front of the model. |
| No private data | It was never trained on your company's wiki, tickets, or contracts. | You index those sources yourself and feed the relevant pieces in. |
| Confident hallucination | Asked something it doesn't know, it invents a plausible-sounding answer. | Grounding the answer in retrieved text (with citations) gives it real facts to quote instead of guessing. |
The insight behind RAG is simple: don't ask the model to remember — give it the material and ask it to read. Instead of baking knowledge into the weights, you keep knowledge in a searchable store and fetch just the relevant slice for each question.
2 · The retrieve → augment → generate loop
Every RAG system, from a 30-line script to a platform serving millions, is this same loop:
| Stage | Owns | Gets wrong when… |
|---|---|---|
| Retrieve | Turn the question into a search and pull the top-k most relevant chunks from the store. | the right chunk isn't found (bad chunking, weak search) — nothing downstream can recover it. |
| Augment | Assemble a prompt that puts those chunks in front of the model with clear instructions. | too much/irrelevant text is stuffed in, burying the useful part or blowing the context budget. |
| Generate | The LLM reads the chunks and writes an answer, ideally citing which chunk it used. | the model ignores the context and answers from memory, or invents a citation. |
3 · Embeddings — turning meaning into numbers
To “search by meaning,” we need a way to measure how similar two pieces of text are. An embedding is a list of numbers (a vector) that represents the meaning of a piece of text. Texts with similar meaning get vectors that point in similar directions — even if they share no words. “car won't start” and “vehicle fails to turn over” land near each other; “banana bread recipe” lands far away.
We compare two vectors with cosine similarity: the cosine of the angle between them, from -1 (opposite) through 0 (unrelated) to 1 (identical direction). Here is the whole idea in runnable Python — no library needed:
cosine.py# Cosine similarity from scratch — the heart of semantic search.
import math
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
def norm(a):
return math.sqrt(sum(x * x for x in a))
def cosine(a, b):
return dot(a, b) / (norm(a) * norm(b) + 1e-9) # +tiny to avoid /0
# Pretend these are 4-number embeddings of three sentences.
car_wont_start = [0.9, 0.1, 0.0, 0.2]
vehicle_no_crank = [0.8, 0.2, 0.1, 0.1] # same meaning, different words
banana_bread = [0.0, 0.1, 0.9, 0.8] # unrelated topic
print('car vs vehicle :', round(cosine(car_wont_start, vehicle_no_crank), 3))
print('car vs banana :', round(cosine(car_wont_start, banana_bread), 3))
car vs vehicle : 0.965
car vs banana : 0.229
The two car sentences score high (~0.97) despite sharing no words; the unrelated one scores low. That is semantic search in one function. In a real system you don't hand-write these vectors — an embedding model produces them. The rule to remember: embed the documents once, embed each question at query time, and rank documents by cosine similarity to the question.
embed() and everything else stays the same.4 · Chunking — deciding what can be found
You don't embed a whole 40-page document as one vector — that would blur every topic together. You split it into chunks (say, a few sentences or a paragraph each), embed each chunk, and retrieve chunks. Chunking is quietly one of the most important decisions in RAG, because a fact can only be retrieved if it sits inside a chunk that scores well for the question.
| Chunk size | Upside | Downside |
|---|---|---|
| Too small (a sentence) | Very precise; the vector is about one idea. | Loses context — a chunk saying “it must be restarted” is useless if “it” (the service) is in the previous sentence. |
| Too large (a whole page) | Keeps context together. | The vector averages many topics, so it matches weakly for any single question; you also waste prompt space. |
| Just right (a paragraph, ~100–300 words, small overlap) | One coherent idea with enough surrounding context to stand alone. | Requires tuning per corpus — there is no universal number. |
A common, sturdy default: split on paragraphs, then pack ~200 words per chunk with a small overlap (repeat the last sentence or two into the next chunk) so a fact spanning a boundary still lands whole in at least one chunk. Here's a minimal chunker:
chunk.pydef chunk_words(text, size=60, overlap=15):
"""Split text into ~size-word chunks that overlap by `overlap` words.
Overlap keeps a fact whole even if it straddles a boundary."""
words = text.split()
chunks, i = [], 0
step = size - overlap
while i < len(words):
chunks.append(' '.join(words[i:i + size]))
i += step
return chunks
doc = ('The checkout service reads orders from the queue. '
'If the queue backs up, latency climbs and the pod restarts. '
'To recover, drain the queue and scale the consumer group. '
'Never restart the database to fix a queue problem.') * 1
for i, c in enumerate(chunk_words(doc, size=12, overlap=3)):
print(f'chunk {i}: {c}')
chunk 0: The checkout service reads orders from the queue. If the queue backs up,
chunk 1: queue backs up, latency climbs and the pod restarts. To recover, drain
chunk 2: recover, drain the queue and scale the consumer group. Never restart the
chunk 3: restart the database to fix a queue problem.
Notice how the overlap repeats a few words between chunks — so “drain the queue” appears in two chunks and can be found from either side of the boundary.
5 · Build a minimal RAG pipeline
Now assemble the loop end to end. We use a fake but deterministic embedder (a bag-of-words hash) so this runs offline and you can see every moving part; then we show exactly where a real embedder and a real LLM plug in.
mini_rag.pyimport math, re
from collections import Counter
# ---- 1. a FAKE embedder (deterministic bag-of-words). Swap for a real model later. ----
VOCAB = {}
def embed(text):
counts = Counter(re.findall(r'[a-z]+', text.lower()))
for w in counts:
VOCAB.setdefault(w, len(VOCAB))
vec = [0.0] * len(VOCAB)
for w, c in counts.items():
vec[VOCAB[w]] = float(c)
return vec
def cosine(a, b):
n = max(len(a), len(b)); a = a + [0.0]*(n-len(a)); b = b + [0.0]*(n-len(b))
dot = sum(x*y for x, y in zip(a, b))
na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(x*x for x in b))
return dot / (na*nb + 1e-9)
# ---- 2. index the documents (embed once, keep the vectors) ----
docs = [
'To restart the checkout service, drain the queue then scale the consumer group.',
'The billing service stores invoices in Postgres and retries failed charges.',
'Never restart the database to fix a queue backlog; it makes recovery slower.',
]
index = [(d, embed(d)) for d in docs]
# ---- 3. retrieve: top-k chunks by cosine similarity to the question ----
def retrieve(question, k=2):
q = embed(question)
scored = sorted(index, key=lambda di: cosine(q, di[1]), reverse=True)
return [d for d, _ in scored[:k]]
# ---- 4. augment: build a grounded prompt ----
def build_prompt(question, chunks):
context = '\n'.join(f'[{i+1}] {c}' for i, c in enumerate(chunks))
return (f'Answer ONLY from the context. Cite sources like [1].\n\n'
f'Context:\n{context}\n\nQuestion: {question}\nAnswer:')
q = 'how do I restart checkout?'
chunks = retrieve(q)
print('RETRIEVED:')
for c in chunks: print(' -', c)
print('\nPROMPT SENT TO THE LLM:\n' + build_prompt(q, chunks))
RETRIEVED:
- To restart the checkout service, drain the queue then scale the consumer group.
- Never restart the database to fix a queue backlog; it makes recovery slower.
PROMPT SENT TO THE LLM:
Answer ONLY from the context. Cite sources like [1].
Context:
[1] To restart the checkout service, drain the queue then scale the consumer group.
[2] Never restart the database to fix a queue backlog; it makes recovery slower.
Question: how do I restart checkout?
Answer:
That is a complete RAG retrieve+augment. The right two chunks came back and the prompt grounds the model in them. The last step — generate — sends that prompt to an LLM:
generate.py# The generation step. Complete and correct; needs YOUR API key to actually run.
# pip install anthropic ; export ANTHROPIC_API_KEY=sk-...
from anthropic import Anthropic # runs in your own environment, not the sandbox
client = Anthropic()
def generate(prompt):
msg = client.messages.create(
model='claude-opus-4-8', # any current model id
max_tokens=300,
messages=[{'role': 'user', 'content': prompt}],
)
return msg.content[0].text
# answer = generate(build_prompt(q, retrieve(q)))
# print(answer) -> 'Drain the queue, then scale the consumer group. [1]'
6 · RAG vs fine-tuning vs a bigger context window
Newcomers often ask “why not just fine-tune the model, or paste the whole document in?” Each has a place:
| Approach | Best for | Weakness |
|---|---|---|
| RAG | Knowledge that changes, is large, or is private; answers that must cite a source. | Adds a retrieval step to build and tune; quality is capped by retrieval quality. |
| Fine-tuning | Teaching a style, format, or skill (e.g. always answer as JSON in our schema). | Poor for facts — retraining to add one document is slow and costly; can still hallucinate. |
| Bigger context (paste it all) | One small, static document you can include every time. | Doesn't scale to a big corpus; costs tokens on every call; the model can lose facts in the middle of very long context. |
If your RAG answer is wrong, which stage should you suspect first, and why?
Show answer
Why do we embed documents once but embed the question every time?
Show answer
🪜 Practice — from a working toy to real intuition beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Change the question in mini_rag.py to “where are invoices stored?” and confirm the billing chunk is now retrieved first.
Show solution
Setq = 'where are invoices stored?' and re-run. The billing/Postgres document should now rank first because its words overlap the question.
Add a fourth document about a topic of your choice to docs, re-run, and check it's only retrieved for relevant questions.
Show solution
Append a new string todocs; because the index is rebuilt from docs, it's embedded automatically. Ask an unrelated question and confirm it does not surface.
Raise k from 2 to 3 and observe how the prompt grows. Note the cost/benefit of retrieving more chunks.
Show solution
More chunks = more chance the answer is present, but more tokens and more noise the model must ignore. Beyond a point, extra chunks hurt (covered in 3.3).Use chunk_words() from section 4 to split a longer paragraph, embed each chunk, and retrieve at the chunk level instead of the document level.
Show solution
Replacedocs with the list returned by chunk_words(long_text). Retrieval now returns fine-grained chunks, which is what real systems index.
The bag-of-words embedder fails when question and answer share no words (“car won't start” vs “vehicle won't crank”). Demonstrate it, then explain why a real embedding model wouldn't have this problem.
Show solution
Index “vehicle won't crank” and query “car won't start”: cosine is ~0 because no words overlap. A real embedding model maps both to nearby vectors because it encodes meaning, not word identity.Add a similarity threshold: if the top chunk scores below it, return “I don't know” instead of a chunk. Explain why refusing is sometimes the correct RAG behavior.
Show solution
Guardretrieve(): if the best cosine < threshold, return [] and have the prompt instruct the model to say it lacks the information. Grounded refusal beats a confident wrong answer.
Context: You're handing this beginner pipeline to a teammate who will make it real.
Your task: Write a short note (5–8 sentences) explaining what to change to move from the fake embedder to a production one, and which single stage you'd tell them to measure first.
Requirements:
- Name the exact function that must be swapped (
embed()) and what it should return. - State that the rest of the loop is unchanged by that swap.
- Recommend measuring retrieval quality first (did the right chunk come back?), and suggest a simple way to eyeball it.
- Mention chunking as the other high-leverage knob.
💡 Hint: You don't need code — this is about communicating the mental model. The next chapter (3.2) builds the real retrieval layer.
✓ Checkpoint — you can move on when you can…
- RAG solves stale knowledge, private data, and hallucination by retrieving facts at question time.
- The loop is always retrieve → augment → generate; retrieval quality caps the whole thing.
- An embedding turns meaning into a vector; cosine similarity ranks by meaning, not words.
- Chunking decides what can be found — a fact must live in a retrievable chunk; overlap protects boundaries.
- You can hand-build the entire loop in plain Python; real systems only swap the embedder and the LLM.
- Use RAG for what the model knows, fine-tuning for how it behaves.