RAG Fundamentals

Build a mental model and a working pipeline for retrieval-augmented generation: why RAG exists, the embed → chunk → index → retrieve → generate loop, and how to tell whether it actually works.

~3hragembeddingsretrievalevaluation
Learning objectives
  • Explain the problem RAG solves and when to reach for it (vs. fine-tuning or a bigger context window).
  • Describe the five stages of a RAG pipeline: embed → chunk → index → retrieve → generate.
  • Read and write the Python for each stage — with a worked, commented example behind every exercise.
  • Name the core RAG evaluation metrics and what each one catches.
Note

Python for JavaScript developers — a 60-second primer. The code below uses a few Python idioms that map cleanly onto things you already know:

  • # comment — Python's //. There are no ; line endings; a newline ends a statement.
  • Indentation is the block. Where JS uses { }, Python uses a : then an indented block.
  • f-strings: f"top {k} chunks" is Python's template literal — the JS `top ${k} chunks`.
  • List comprehension: [f(x) for x in items] is exactly items.map(f). Add if cond to filter: [x for x in items if cond] is items.filter(...).
  • @ between two numpy arrays is the dot/matrix product (not a decorator here).
  • list[float] in a signature is just a type hint — like TypeScript's : number[], optional and not enforced at runtime.

Every code block is commented line-by-line; you shouldn't need Python fluency to follow it.

Why RAG exists

A large language model only "knows" what was in its training data, frozen at a cutoff date, blended into its weights. That creates three practical problems:

  1. Staleness — it can't know anything that happened after training.
  2. No private knowledge — it has never seen your company's docs, tickets, or database.
  3. Hallucination — asked about something it half-knows, it will confidently invent an answer.

Retrieval-Augmented Generation (RAG) fixes all three the same way: instead of relying on the model's parametric memory, you retrieve relevant text from an external source at query time and inject it into the prompt as context. The model then answers from the provided passages, not from memory.

Note

RAG is not the only tool. Fine-tuning changes behavior and style by updating weights; RAG changes what facts are available at inference. If the problem is "the model doesn't know our 2026 pricing," that's a knowledge problem → RAG. If it's "the model won't answer in our brand voice," that's a behavior problem → fine-tuning (or better prompting). They compose.

The pipeline

RAG has two phases. Indexing happens once (or on a schedule) over your corpus. Querying happens on every user request.

INDEXING (offline):     documents → chunk → embed → store vectors + text in an index
QUERYING (per request): question → embed → similarity search → top-k chunks → prompt + generate

1. Embeddings

An embedding is a fixed-length vector of floats (e.g. 1536 dimensions) that represents the meaning of a piece of text. An embedding model is trained so that texts with similar meaning land close together in that vector space, and unrelated texts land far apart.

from openai import OpenAI          # the OpenAI SDK; `import` ~ JS `import`

client = OpenAI()                  # reads your API key from the env var OPENAI_API_KEY

def embed(text: str) -> list[float]:      # `text: str` = param typed as string; `-> list[float]` = returns a float array
    resp = client.embeddings.create(      # call the embeddings endpoint
        model="text-embedding-3-small",   # which embedding model to use
        input=text,                       # the text to turn into a vector
    )
    return resp.data[0].embedding         # data[0] = first (only) result; .embedding = the vector itself

v = embed("How do I reset my password?")
len(v)   # -> 1536   (len() is Python's Array.length / .length)

"Close" is measured with a similarity metric, most commonly cosine similarity — the cosine of the angle between two vectors, from -1 (opposite) to 1 (identical direction). Here it is in code, because you'll implement exactly this in the first exercise:

import numpy as np                 # numpy = the array-math library (think a fast, vectorised math kit)

def cosine_similarity(a: list[float], b: list[float]) -> float:
    a, b = np.array(a), np.array(b)         # turn the plain float lists into numpy arrays so we can do vector math
    dot = a @ b                             # `@` = dot product: sum of a[i]*b[i] over every dimension
    norm = np.linalg.norm(a) * np.linalg.norm(b)   # norm() = vector length (magnitude); multiply the two lengths
    return float(dot / norm)                # cosine = dot product divided by the product of lengths → range -1..1

# Higher score = more similar in meaning:
cosine_similarity(embed("reset my password"),
                  embed("I forgot my login credentials"))   # ≈ 0.6  (same intent, different words)
cosine_similarity(embed("reset my password"),
                  embed("what are your business hours"))     # ≈ 0.1  (unrelated)
Tip

Exercise 1 has you build exactly this embed + cosine_similarity pair and predict the ranking of four strings before running it — the point is to feel that embeddings capture meaning, not keywords ("forgot my credentials" scores high even though it shares no words with "reset password").

Watch out

You must embed queries and documents with the same model. Mixing embedding models (or model versions) puts vectors in different spaces and retrieval becomes noise.

2. Chunking

You rarely embed a whole document — it's too coarse (one vector can't represent 40 pages) and blows the context window. Instead you split documents into chunks (e.g. a few hundred tokens each), and embed each chunk. Chunking is a genuine design choice:

  • Too large → the chunk mixes many topics, so its embedding is a blurry average and retrieval gets imprecise; you also waste context tokens.
  • Too small → a chunk loses the surrounding context needed to be meaningful ("It costs €49" — what costs €49?).
  • Overlap — sliding a window with some overlap (e.g. 10–20%) keeps sentences that straddle a boundary intact.

Here's the core chunker in code. It's a sliding window: take size tokens, step forward by size - overlap, repeat — so consecutive chunks share overlap tokens.

def chunk_tokens(tokens: list[str], size: int = 200, overlap: int = 30) -> list[list[str]]:
    step = size - overlap              # how far the window advances each time (smaller step = more overlap)
    chunks = []                        # accumulator (an empty list, like `const chunks = []`)
    for start in range(0, len(tokens), step):   # range(0, N, step) = 0, step, 2*step, …  (like a for-loop with i += step)
        window = tokens[start : start + size]   # slice: tokens from `start` up to (not including) `start+size`
        chunks.append(window)                   # push this window onto the result (like Array.push)
    return chunks

# A token here is just a word for illustration; in production you'd split on a real tokenizer.
words = "the quick brown fox jumps over the lazy dog".split()   # .split() with no arg splits on whitespace → a list
chunk_tokens(words, size=4, overlap=2)
# -> [['the','quick','brown','fox'],        # window 1
#     ['brown','fox','jumps','over'],        # window 2 shares 'brown','fox' with window 1 (that's the overlap)
#     ['jumps','over','the','lazy'],
#     ['the','lazy','dog']]                  # last window is short — that's fine

The same list comprehension, written compactly, is what you'll compare against in the exercise:

# Identical logic, one line. Read it as: "for each start in the range, take that window."
def chunk_tokens(tokens, size=200, overlap=30):
    step = size - overlap
    return [tokens[i : i + size] for i in range(0, len(tokens), step)]
Tip

Exercise 2 has you run this chunker three ways over a real article — one giant chunk, fixed chunks with no overlap, and chunks with 15% overlap — and observe how the choice changes what a query can retrieve. Now you have the exact function to start from.

3. Indexing / storing

Each chunk's embedding is stored in a vector index alongside the original text and metadata (source, title, timestamp). A vector database (pgvector, Qdrant, Azure AI Search, …) does the approximate-nearest-neighbor search efficiently at scale. For learning, a plain Python list is enough:

# Build the tiny "index": for each chunk keep its text AND its embedding, side by side.
chunks = ["...", "...", "..."]                       # your chunked passages (strings)
index = [(text, np.array(embed(text))) for text in chunks]   # list of (text, vector) pairs — a list of tuples
# A tuple `(a, b)` is just a fixed 2-element pair, like a readonly [a, b] in JS.

4. Retrieval

At query time you embed the user's question and ask the index for the top-k most similar chunks (e.g. k=4). Those chunks are your evidence.

def retrieve(question: str, k: int = 4) -> list[str]:
    q = np.array(embed(question))                       # embed the question into the SAME space as the chunks
    # For every stored (text, vector) pair, score it against the question, keep (score, text):
    scored = [(cosine_similarity(q, vec), text) for (text, vec) in index]
    scored.sort(reverse=True)                           # sort by score descending (highest similarity first)
    return [text for (score, text) in scored[:k]]       # scored[:k] = first k items; return just their text

5. Generation

Finally you build a prompt that contains the retrieved chunks and the question, and instruct the model to answer only from the provided context — and to say it doesn't know if the context is insufficient.

def answer(question: str) -> str:
    context = "\n\n".join(retrieve(question))   # join the retrieved chunks into one string, blank line between each
    prompt = f"""Answer the question using ONLY the context below.
If the context doesn't contain the answer, say you don't know.

Context:
{context}

Question: {question}
"""                                             # triple-quoted f-string = multi-line template literal with {…} interpolation
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],   # a list with one message dict (dict ~ JS object)
    )
    return resp.choices[0].message.content      # pull the text out of the response

That "only from the context / say you don't know" instruction is what turns retrieval into grounding — it's the difference between RAG and just pasting search results next to a hallucinating model.

Tip

Exercise 3 wires these four functions (embed, cosine_similarity, retrieve, answer) into the smallest possible end-to-end RAG over ~10 chunks. Everything you need is above — the exercise is assembling it and then asking a question your corpus can't answer to confirm it abstains.

Evaluating RAG

RAG has two failure surfaces, so you evaluate both the retriever and the generator:

MetricQuestion it answersFailure it catches
Context recallDid retrieval fetch the chunks that contain the answer?Missing evidence (retriever failure)
Context precisionAre the retrieved chunks actually relevant (not noise)?Distracting/irrelevant retrieval
Faithfulness / groundednessIs the answer supported by the retrieved context?Hallucination despite good context
Answer relevanceDoes the answer actually address the question?On-topic-but-useless answers

A key diagnostic habit: if the answer is wrong, first check whether the right chunk was even retrieved. The one-line habit that saves hours is to print the retrieved chunks next to the answer:

def debug_answer(question: str):
    hits = retrieve(question)                   # the chunks the retriever chose
    print("RETRIEVED:")
    for i, text in enumerate(hits):             # enumerate() gives (index, item) pairs, like JS .entries()
        print(f"  [{i}] {text[:80]}…")          # text[:80] = first 80 chars; f-string formats the line
    print("ANSWER:", answer(question))
# Now read the two together:
#   • answer-bearing chunk NOT in RETRIEVED  → retriever failure (fix chunking / k / embeddings)
#   • it IS retrieved but the answer is wrong → generator/faithfulness failure (tighten the prompt or model)

If recall failed, no amount of prompt tuning helps. If recall was fine but the answer is still wrong, that's a generation/faithfulness problem.

Tip

Exercise 4 has you deliberately break retrieval and use exactly this "print retrieved chunks next to the answer" technique to classify the failure as retriever vs generator. Tools like Ragas, DeepEval, or an LLM-as-judge automate the scoring later, but always keep a small hand-labeled "golden set" of question→expected-answer pairs as ground truth.

Pitfalls

  • Garbage retrieval, confident answer. If you don't instruct the model to abstain, it will answer from memory when retrieval fails — reintroducing the hallucination you were avoiding.
  • Embedding/model mismatch. Different models for query vs. documents ⇒ meaningless similarity.
  • Chunk size chosen by vibes. It materially changes quality; treat it as a tunable evaluated against your golden set.
  • No evaluation. Without measuring recall/faithfulness you're flying blind and can't tell a retriever bug from a generation bug.

Recap

  • RAG grounds an LLM in external, current, private knowledge by retrieving relevant text at query time and instructing the model to answer only from it.
  • The pipeline is embed → chunk → index → retrieve → generate; indexing is offline, retrieval and generation are per-request.
  • Embeddings put meaning in a vector space; cosine similarity finds the closest chunks; use the same embedding model for queries and documents.
  • Evaluate the retriever (context recall/precision) and the generator (faithfulness, answer relevance) separately — and diagnose wrong answers by checking retrieval first.
  • You now have a commented, worked example for every exercise: cosine_similarity (Ex 1), chunk_tokens (Ex 2), the assembled pipeline (Ex 3), and debug_answer (Ex 4).

RAG Fundamentals — Exercises

Work through these in a scratch Python project. You need an OpenAI (or Azure OpenAI) key, or swap in a local embedding model — the concepts are identical.


Exercise 1 — Embeddings & cosine similarity

Embed four short strings and compute the cosine similarity of the first against the other three. Predict the ranking before you run it.

  • "How do I reset my password?"
  • "I forgot my login credentials"
  • "What are your business hours?"
  • "The mitochondria is the powerhouse of the cell"
Reference solution
import numpy as np
from openai import OpenAI
client = OpenAI()

def embed(t): return client.embeddings.create(model="text-embedding-3-small", input=t).data[0].embedding
def cos(a, b):
    a, b = np.array(a), np.array(b)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

texts = ["How do I reset my password?",
         "I forgot my login credentials",
         "What are your business hours?",
         "The mitochondria is the powerhouse of the cell"]
vs = [embed(t) for t in texts]
for t, v in zip(texts[1:], vs[1:]):
    print(f"{cos(vs[0], v):.3f}  {t}")

Expected: the "forgot my credentials" line scores highest (same intent, different words — this is exactly what embeddings buy you over keyword search), business hours is middling, the biology sentence is lowest.


Exercise 2 — Chunk a document three ways

Take any ~2,000-word article. Chunk it (a) as one big chunk, (b) fixed 200-token chunks with no overlap, (c) 200-token chunks with 15% overlap. Embed each chunking and note how many chunks each produces.

Reference solution

The point isn't the exact code but the observation: (a) gives 1 blurry vector — a query about a detail buried on "page 2" retrieves the whole thing or nothing useful; (b) can split a sentence or a Q/A pair across a boundary, orphaning context; (c) overlap keeps boundary-straddling ideas intact at the cost of a few more chunks. A simple splitter:

def chunk(tokens, size=200, overlap=30):
    step = size - overlap
    return [tokens[i:i+size] for i in range(0, len(tokens), step)]

Exercise 3 — Minimal end-to-end RAG (no vector DB)

Build the smallest possible RAG over ~10 chunks using only a Python list + numpy for search. Embed the chunks, embed a question, retrieve top-3 by cosine, and generate a grounded answer that is instructed to abstain when the context is insufficient.

Reference solution
import numpy as np
from openai import OpenAI
client = OpenAI()

chunks = [ ... ]  # ~10 short passages from your source
emb = [np.array(embed(c)) for c in chunks]

def retrieve(q, k=3):
    qv = np.array(embed(q))
    sims = [float(qv @ e / (np.linalg.norm(qv)*np.linalg.norm(e))) for e in emb]
    return [chunks[i] for i in np.argsort(sims)[::-1][:k]]

def answer(q):
    ctx = "\n\n".join(retrieve(q))
    prompt = (f"Answer ONLY from the context. If it's not there, say you don't know.\n\n"
              f"Context:\n{ctx}\n\nQuestion: {q}")
    return client.chat.completions.create(model="gpt-4o-mini",
        messages=[{"role":"user","content":prompt}]).choices[0].message.content

Now ask it a question your corpus cannot answer and confirm it abstains. If it doesn't, your abstention instruction is too weak — that's the hallucination failure mode in miniature.


Exercise 4 — Diagnose a wrong answer

Deliberately break retrieval (e.g. set k=1, or embed the query with a different model) so the pipeline returns a wrong or "I don't know" answer. Then, using only the retrieved chunks, decide: was this a retriever failure (right chunk never fetched) or a generator failure (right chunk fetched but answer wrong)?

Reference solution

Print the retrieved chunks next to the answer. If the answer-bearing chunk is absent from the top-k, it's a retrieval failure → fix chunking/k/embeddings; prompt tuning won't help. If the chunk is present but the answer is still wrong or invented, it's a faithfulness/generation failure → tighten the grounding instruction or the model. This "check retrieval first" reflex is the single most useful RAG debugging habit.