TAI BUI
Back to curriculum
BuildPython

RAG (Retrieval-Augmented Generation)

A model only knows what it was trained on, up to its cutoff. Your company's docs, your codebase, and last week's meeting notes aren't in there. RAG fixes that by retrieving the relevant documents and putting them in the prompt. It's the most deployed pattern in production AI, and if you build one thing from this course, build a RAG pipeline.

TIME: ~90 minutesPREREQUISITES: Phase 10 (LLMs from Scratch), Phase 11 Lessons 01-05

Learning Objectives

  • Build a complete RAG pipeline: document loading, chunking, embedding, vector storage, retrieval, and generation
  • Implement semantic search using a vector database (ChromaDB, FAISS, or Pinecone) with proper indexing
  • Explain why RAG is preferred over fine-tuning for knowledge-grounded applications (cost, freshness, attribution)
  • Evaluate RAG quality using retrieval metrics (precision, recall) and generation metrics (faithfulness, relevance)

The Problem

You build a chatbot for your company. A customer asks "What's the refund policy for enterprise plans?" The LLM responds with a generic answer about typical SaaS refund policies. The actual policy, buried in a 200-page internal wiki, says enterprise customers get a 60-day window with pro-rated refunds. The LLM has never seen this document, and it can't know what it wasn't trained on.

Fine-tuning is one solution. Take the LLM, train it on your internal docs, and deploy the updated model. This works, but it has real costs. Training costs thousands of dollars in compute. The model goes stale the moment a document changes. You can't tell which source an answer drew from. And if the company acquires another product line next month, you train again.

RAG is the other solution. Leave the model untouched. When a question comes in, search your document store for relevant passages, paste them into the prompt before the question, and let the model answer using those passages as context. The document store can be updated in minutes, and you can see which documents were retrieved. The model itself stays the same. That is why RAG is the dominant pattern in production: it's cheaper, fresher, easier to audit, and works with any LLM.

Warm-up0/3
1. A model has an 8,000-token context window. What does that limit mean when you want to add reference documents to a prompt?Warm-up
Three items to be sent in one prompt (instructions, reference documents, a question) feeding a language model that has an 8,000-token context window, and an unknown labelled limit.
Typed setup from the question.
2. A model finished training last year. A user asks about a document your company wrote last week. Why can't the model answer from its own knowledge?Warm-up
A timeline: training data up to a training-end point last year, a company document written last week, and the user's question now, with an unknown for answering from the model's own knowledge.
Spacing is schematic.
3. What is a hallucination in an LLM answer?Warm-up
A chat: a user asks about the refund policy for enterprise plans and the model replies with a confident 30-day refund window.
Typed example, not from a real model.
Check: The Problem0/3
1. What is RAG?Define
A company refund-policy document and a user question about enterprise refunds, an unknown step between them and a language model that answers.
Typed setup; the unknown stands for how the answer is produced.
2. A customer asks about enterprise refunds. The wiki states a 60-day window, but the chatbot gives a generic SaaS answer. What is missing from the setup?Analyze
A wiki box that says 60-day window and a chatbot box separated by an unknown, with the chatbot producing a generic SaaS answer to an enterprise refund question.
From the lesson's Problem section.
3. Support policies change weekly, agents must cite which document each answer came from, and you have a base LLM but no training budget. What do you build?Design
Three requirements: support policies change weekly, agents must cite the source document for each answer, and there is a base LLM but no training budget, plus an unknown for what to build.
Restated from the question.

The Concept

The RAG Pattern

The entire pattern fits in four steps:

Mermaid diagram

Query -> Retrieve -> Augment prompt -> Generate. RAG systems share this shape, and they differ in the details of each step: how you chunk, how you embed, how you search, and how you build the prompt.

Why RAG Beats Fine-Tuning

ConcernFine-tuningRAG
Cost$1,000-$100,000+ per training run$0.01-$0.10 per query (embedding + LLM)
FreshnessStale until retrainedUpdated in minutes by re-indexing docs
AuditabilityCannot trace answer to sourceCan show exact retrieved passages
HallucinationStill hallucinates freelyGrounded in retrieved documents
Data privacyTraining data baked into weightsDocuments stay in your vector store

Fine-tuning changes the model's weights. RAG changes the model's context for one request. For most applications, that temporary context is what you want.

Fine-tuning earns its cost when you need the model to adopt a specific style, tone, or reasoning pattern that prompting alone can't produce. For factual knowledge, RAG wins.

Embedding Models

An embedding model converts text into a dense vector. Similar texts produce vectors that are close together in this high-dimensional space. "How do I reset my password?" and "I need to change my password" produce nearly identical vectors even though they share few words. "The cat sat on the mat" produces a very different one.

Common embedding models in the 2026 lineup (see Phase 5 · 22 for the full analysis):

ModelDimensionsProviderNotes
text-embedding-3-small1536 (Matryoshka)OpenAIBest price/performance for most use cases
text-embedding-3-large3072 (Matryoshka)OpenAIHigher accuracy, truncatable to 256/512/1024
Gemini Embedding 23072 (Matryoshka)GoogleTop MTEB retrieval; 8K context
voyage-41024/2048 (Matryoshka)Voyage AIDomain variants (code, finance, law)
Cohere embed-v41024 (Matryoshka)CohereStrong multilingual, 128K context
BGE-M31024 (dense + sparse + ColBERT)BAAI (open-weight)Three views from one model
Qwen3-Embedding4096 (Matryoshka)Alibaba (open-weight)Top open-weight retrieval score
all-MiniLM-L6-v2384Open-weight (Sentence Transformers)Prototyping baseline

Here we build our own embedding with TF-IDF. Production systems use neural models instead, but TF-IDF makes the idea concrete: text goes in, a vector comes out, and similar texts land close together.

Vector Similarity

How do you measure similarity between two vectors? There are three common options.

Cosine similarity: the cosine of the angle between two vectors. Ranges from -1 (opposite) to 1 (identical). It ignores magnitude and only looks at direction, and it's the default for RAG.

cosine_sim(a, b) = dot(a, b) / (||a|| * ||b||)

Dot product: the raw inner product. Larger vectors get higher scores. Useful when magnitude carries information (longer documents might be more relevant).

dot(a, b) = sum(a_i * b_i)

L2 (Euclidean) distance: straight-line distance in the vector space. Smaller distance = more similar. Sensitive to magnitude differences.

L2(a, b) = sqrt(sum((a_i - b_i)^2))

Use cosine similarity unless you have a reason not to. It copes with documents of different lengths because it normalizes by magnitude, and "vector search" usually means cosine similarity.

Chunking Strategies

Documents are usually too long to embed as a single vector. A 50-page PDF covers dozens of topics, and one vector for all of them is a poor summary of any. So you split documents into chunks and embed each chunk separately.

Fixed-size chunking: split every N tokens. Simple and predictable. A 512-token chunk with 50-token overlap means chunk 1 is tokens 0-511, chunk 2 is tokens 462-973, and so on. The overlap keeps a sentence from being cut at an unlucky boundary.

Semantic chunking: split at natural boundaries such as paragraphs, sections, or markdown headers. Each chunk is a coherent unit of meaning. It takes more work to implement and tends to retrieve better.

Recursive chunking: try to split at the largest boundary first (section headers). If a section is still too large, split at paragraph boundaries. If a paragraph is still too large, split at sentence boundaries. This is the approach behind LangChain's RecursiveCharacterTextSplitter, and it works well in practice.

Chunk size matters more than it looks:

  • Too small (64-128 tokens): each chunk lacks context. "It increased 15% last quarter" means nothing without knowing what "it" refers to.
  • Too large (2048+ tokens): each chunk covers multiple topics, diluting relevance. When you search for revenue data, you get a chunk that's 10% about revenue and 90% about headcount.
  • Sweet spot (256-512 tokens): enough context to be self-contained, focused enough to be relevant.

Most production RAG systems use 256-512 token chunks with 50-token overlap. Anthropic's RAG guidelines recommend this range.

Vector Databases

Once you have embeddings, you need somewhere to store and search them. Here are the common options.

DatabaseTypeBest for
FAISSLibrary (in-process)Prototyping, small to medium datasets
ChromaLightweight DBLocal development, small deployments
PineconeManaged serviceProduction without ops overhead
WeaviateOpen source DBSelf-hosted production
pgvectorPostgres extensionAlready using Postgres
QdrantOpen source DBHigh-performance self-hosted

Here we build a simple in-memory vector store. It keeps vectors in a list and runs brute-force cosine similarity over all of them, which is what FAISS does with a flat index. It starts to slow down somewhere around 100,000 vectors. Production systems use approximate nearest neighbor (ANN) algorithms like HNSW to search millions of vectors in milliseconds.

The Full Pipeline

Mermaid diagram

The indexing phase runs once per document, or again when a document changes. The querying phase runs on every user request. In production, indexing can chew through millions of documents over hours, while a query has to come back in under a second.

Real Numbers

Typical production parameters look like this:

  • k = 5 to 10 retrieved chunks per query
  • Chunk size = 256 to 512 tokens with 50-token overlap
  • Context budget: 2,500-5,000 tokens of retrieved content per query
  • Total prompt: ~8,000-16,000 tokens (system prompt + retrieved chunks + conversation history + user query)
  • Embedding dimension: 384-3072 depending on model
  • Indexing throughput: 100-1,000 documents per second with API embeddings
  • Query latency: 50-200ms for retrieval, 500-3000ms for generation
Check: The Concept0/24
1. Two sentences, "How do I reset my password?" and "I need to change my password", share few words. What should a good embedding model do with them?Define
Two sentences about a password drawn word by word with the shared words I, my and password highlighted, and an unknown for how their embedding vectors relate.
Highlighted: the words the sentences share.
2. What does a vector database do?Define
A table of four chunk ids with 2-number stored vectors, a query vector, and an unknown for what the store returns.
Typed 2-number vectors for display; real vectors have hundreds of dimensions.
3. What is chunking?Define
A 91-word document bar and five labelled pieces below it, chunk 0 to chunk 4, each 30 words except the last, staggered so neighbors overlap.
Counts from running the lesson's chunk_text on its sample refund document.
4. What does cosine similarity measure between two vectors?Define
Two arrows from the origin on a plane, a = (3, 1) and b = (1, 2), and an unknown for their similarity score.
Typed example vectors.
5. What does top-k retrieval return?Define
Six stored chunks listed in insertion order, a query, and an unknown for what top-k retrieval with k = 3 returns.
Typed setup.
6. In a RAG system, what limit does the context window place on retrieval?Define
A row: vector store of many chunks, retrieved top-k chunks, a prompt sent to the model, and the model with its context window, plus an unknown for the limit on retrieval.
From the lesson's pipeline.
7. What is indexing in a RAG system?Define
Seven chips naming parts of a RAG system in mixed order, and an unknown asking which of them is indexing.
Chip order is shuffled on purpose.
8. Which setup matches the typical production retrieval parameters?Define
A settings card with two blanks: the number of chunks retrieved per query and the token budget for retrieved content.
Values left open.
9. Why is RAG preferred over fine-tuning for most knowledge-grounded applications?Explain
An application that answers from documents with two approaches, fine-tuning and RAG, and an unknown for why RAG is preferred for most of them.
Typed setup.
10. Dot product is sometimes chosen over cosine similarity. When does that make sense?Explain
A query vector (1, 1) and two document vectors, doc A (1, 1) and doc B (3, 3), pointing in the same direction but with different lengths.
Typed vectors.
11. What is the correct order of steps in a basic RAG pipeline?Explain
Five chips naming pipeline steps in mixed order and an unknown for the correct order.
Chip order is shuffled on purpose.
12. Which similarity measure is the default for RAG, and why?Explain
A query vector (2, 1) and two chunk vectors, chunk A (1, 0.5) and chunk B (4, 3), with different lengths and an unknown for the default similarity measure.
Typed vectors.
13. How should you evaluate the quality of a RAG system?Explain
A trace of one question through the toy pipeline: the question, two retrieved chunks with source files and scores, and the answer, followed by an unknown asking how to measure quality.
Captured by running the lesson's main.py (chunk size 50, overlap 10, top 3; first two shown).
14. When is fine-tuning still worth its cost compared with RAG?Explain
Two boxes: fine-tuning changes the weights at $1,000-$100,000+ per training run, and RAG changes the context of one request at $0.01-$0.10 per query, with an unknown for when fine-tuning earns its cost.
Costs from the lesson's comparison table.
15. Why does fixed-size chunking use overlap between neighboring chunks?Explain
Two staggered bars: chunk 1 covering tokens 0 to 511 and chunk 2 covering tokens 462 to 973, overlapping on tokens 462 to 511.
Ranges from the lesson's fixed-size chunking example.
16. How does recursive chunking differ from fixed-size chunking?Explain
A 1,500-token document drawn as two sections, Refund policy and Security, split into paragraphs of 220, 180, 380, 420 and 300 tokens, with a 512-token chunk size limit and an unknown splitter.
Section names and token counts are typed for illustration.
17. Why do chunks of only 64-128 tokens tend to retrieve poorly?Explain
A row of twelve small numbered chunks from one report with chunk 7 outlined, a revenue question, and an unknown for how well retrieval works at this chunk size.
Illustrative layout; chunk count is typed.
18. Why do production systems use approximate nearest neighbor search such as HNSW instead of brute force?Explain
A cloud of twenty-eight stored-vector dots with a query triangle in the middle, and an unknown for the search method.
Schematic, positions random.
19. Chunks are 512 tokens and retrieved content has a budget of 5,000 tokens. What is the largest k that fits?Apply
An empty bar for a 5,000-token budget, a note that each chunk is 512 tokens, and an unknown for the largest k that fits.
Numbers from the question.
20. A query about revenue returns a chunk that is 10% revenue and 90% headcount. What is the most likely cause?Analyze
A query about revenue pointing to a retrieved chunk drawn as a bar that is 10 percent revenue and 90 percent headcount, and an unknown for the cause.
Proportions from the question.
21. A fixed-size chunker with no overlap splits "Enterprise customers get a 60-day" and "window with pro-rated refunds" into two chunks. Retrieval keeps missing the policy. What helps most?Analyze
Two adjacent chunks, 'Enterprise customers get a 60-day' and 'window with pro-rated refunds', with a cut line between them and an unknown for what helps.
Chunk text from the question.
22. Retrieval returns the chunk stating a 60-day refund window, but the generated answer says 30 days. Which part failed, and which measure shows it?Analyze
Three stacked cards: the question about the enterprise refund window, the retrieved chunk mentioning a 60-day refund window, and the generated answer of 30 days, with an unknown asking which part failed.
Typed example following the question.
23. Your corpus grows to millions of chunks, and the brute-force scan now takes seconds per query. Retrieval should take tens of milliseconds. Which change fits?Design
Two bars: current per-query time of seconds and a much shorter target of tens of milliseconds, for a corpus of millions of chunks, plus an unknown for which change fits.
Bar lengths are schematic.
24. A legal team wants answers in a strict house style that prompting alone cannot produce, grounded in contract clauses that are amended every month. Which design fits?Design
Two requirement boxes: a strict house style that prompting alone cannot produce, and contract clauses that are amended every month, plus an unknown for which design fits.
Restated from the question.

Build It

Step 1: Document Chunking

python
def chunk_text(text, chunk_size=200, overlap=50):
    words = text.split()
    chunks = []
    start = 0
    while start < len(words):
        end = start + chunk_size
        chunk = " ".join(words[start:end])
        chunks.append(chunk)
        start += chunk_size - overlap
    return chunks

Step 2: TF-IDF Embeddings

We start with a simple embedding function. TF-IDF (Term Frequency-Inverse Document Frequency) is not a neural embedding, but it does turn text into vectors that capture word importance. Words that are frequent in a document get a higher TF, and words that are rare across the corpus get a higher IDF. Multiply them and the distinctive words end up with the largest values.

python
import math
from collections import Counter
 
def build_vocabulary(documents):
    vocab = set()
    for doc in documents:
        vocab.update(doc.lower().split())
    return sorted(vocab)
 
def compute_tf(text, vocab):
    words = text.lower().split()
    count = Counter(words)
    total = len(words)
    return [count.get(word, 0) / total for word in vocab]
 
def compute_idf(documents, vocab):
    n = len(documents)
    idf = []
    for word in vocab:
        doc_count = sum(1 for doc in documents if word in doc.lower().split())
        idf.append(math.log((n + 1) / (doc_count + 1)) + 1)
    return idf
 
def tfidf_embed(text, vocab, idf):
    tf = compute_tf(text, vocab)
    return [t * i for t, i in zip(tf, idf)]

Step 3: Cosine Similarity Search

python
def cosine_similarity(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)
 
def search(query_embedding, stored_embeddings, top_k=5):
    scores = []
    for i, emb in enumerate(stored_embeddings):
        sim = cosine_similarity(query_embedding, emb)
        scores.append((i, sim))
    scores.sort(key=lambda x: x[1], reverse=True)
    return scores[:top_k]

Step 4: Prompt Construction

This is the "augmented" part of RAG. Format the retrieved chunks into a prompt and ask the LLM to answer from that context.

python
def build_rag_prompt(query, retrieved_chunks):
    context = "\n\n---\n\n".join(
        f"[Source {i+1}]\n{chunk}"
        for i, chunk in enumerate(retrieved_chunks)
    )
    return f"""Answer the question based ONLY on the following context.
If the context doesn't contain enough information, say "I don't have enough information to answer that."
 
Context:
{context}
 
Question: {query}
 
Answer:"""

Step 5: The Complete RAG Pipeline

The class ties the steps together. It remembers which source each chunk came from, and query runs retrieval, prompt construction, and the generator from Step 6.

python
class RAGPipeline:
    def __init__(self, chunk_size=200, overlap=50, top_k=5):
        self.chunk_size = chunk_size
        self.overlap = overlap
        self.top_k = top_k
        self.chunks = []
        self.embeddings = []
        self.vocab = []
        self.idf = []
        self.sources = []
 
    def index(self, documents, source_names=None):
        all_chunks = []
        all_sources = []
        for i, doc in enumerate(documents):
            doc_chunks = chunk_text(doc, self.chunk_size, self.overlap)
            all_chunks.extend(doc_chunks)
            name = source_names[i] if source_names else f"doc_{i}"
            all_sources.extend([name] * len(doc_chunks))
 
        self.chunks = all_chunks
        self.sources = all_sources
        self.vocab = build_vocabulary(all_chunks)
        self.idf = compute_idf(all_chunks, self.vocab)
        self.embeddings = [
            tfidf_embed(chunk, self.vocab, self.idf)
            for chunk in all_chunks
        ]
        return len(all_chunks)
 
    def query(self, question, top_k=None):
        k = top_k or self.top_k
        query_emb = tfidf_embed(question, self.vocab, self.idf)
        results = search(query_emb, self.embeddings, k)
 
        retrieved = []
        for idx, score in results:
            retrieved.append({
                "chunk": self.chunks[idx],
                "source": self.sources[idx],
                "score": score,
                "index": idx
            })
 
        chunk_texts = [r["chunk"] for r in retrieved]
        prompt = build_rag_prompt(question, chunk_texts)
        answer = simple_generate(prompt, chunk_texts)
 
        return {
            "question": question,
            "answer": answer,
            "prompt": prompt,
            "retrieved": retrieved
        }

Step 6: Generation (simulated)

In production this step is an LLM API call. Here we fake it by pulling out the retrieved sentence that shares the most words with the question, after dropping common stop words.

python
def simple_generate(prompt, retrieved_chunks):
    query_section = prompt.lower().split("question:")[-1]
    query_words = set(query_section.split())
    stop_words = {"the", "a", "an", "is", "are", "was", "were", "what", "how",
                  "why", "when", "where", "do", "does", "for", "of", "in", "to",
                  "and", "or", "on", "at", "by", "it", "its", "this", "that"}
    query_words = query_words - stop_words
 
    best_sentence = ""
    best_score = 0
 
    for chunk in retrieved_chunks:
        for sentence in chunk.split("."):
            sentence = sentence.strip()
            if len(sentence) < 10:
                continue
            words = set(sentence.lower().split())
            overlap = len(query_words & words)
            if overlap > best_score:
                best_score = overlap
                best_sentence = sentence
 
    return best_sentence if best_sentence else "I don't have enough information."
Check: Build It0/13
1. What makes the generation step "augmented" in RAG?Define
A question and an unknown second input both feeding a language model that writes an answer.
Typed layout.
2. What does TF-IDF do to a word's weight in a vector?Define
Three short texts and two unknown weights, for the words sat and mat in text 0's vector.
Corpus from the lesson's Step 2 demo.
3. Why does the prompt say to answer based ONLY on the context and give a fallback sentence?Explain
A prompt with an instruction to answer only from the context, a fallback sentence, a Context block labelled [Source 1], a question and an Answer line.
Output of the lesson's build_rag_prompt; line 2 is one line in the real string, wrapped here; chunk trimmed with ...
4. Why does TF-IDF multiply TF by IDF instead of using TF alone?Explain
Three short typed chunks and the TF value of each word in chunk 0, 0.167 for every word, with an unknown for the weights once IDF is multiplied in.
Typed example, TF computed.
5. chunk_text runs with its defaults (chunk_size=200, overlap=50) on a text of 400 words. How many chunks come back?Apply
A 400-word text bar with the call chunk_text using defaults chunk_size 200 and overlap 50, and an unknown for the number of chunks.
Typed setup.
6. chunk_text runs on ten words w0 to w9 with chunk_size=4 and overlap=1. Which words does the second chunk hold?Apply
Ten word chips w0 to w9, the call chunk_text with chunk_size 4 and overlap 1, and an unknown for the words in the second chunk.
Typed setup.
7. compute_idf runs over 3 chunks. Word A appears in exactly one chunk and word B appears in all three. What are their IDF values?Apply
Three chunk boxes: word A appears in chunk 0 only, word B appears in all three, with unknown IDF values for A and B.
Typed setup from the question.
8. cosine_similarity between the query and three stored embeddings gives 0.2, 0.9, and 0.5 for indexes 0, 1, and 2. What does search(..., top_k=2) return?Apply
A table of cosine scores for stored indexes 0, 1 and 2 (0.2, 0.9, 0.5), the call search with top_k=2, and an unknown for the returned value.
Values from the question.
9. What do cosine_similarity([1, 0], [3, 0]) and cosine_similarity([1, 0], [0, 1]) return?Apply
Two panels of vectors from the origin: pair 1 is [1, 0] and [3, 0] along the same line, pair 2 is [1, 0] and [0, 1] at right angles, with unknown return values.
Vectors from the question; arrow lengths scaled for display.
10. build_rag_prompt receives the query "Q" and the chunks ["alpha", "beta"]. How does its context part look?Apply
The call build_rag_prompt with the query Q and the chunks alpha and beta, and an unknown for the context part of the returned prompt.
Call from the question.
11. simple_generate gets a prompt ending "Question: What is the refund policy?" and one chunk, "Enterprise refund policy is 60 days. Support is open weekdays." What does it return?Apply
The tail of a prompt ending Question: What is the refund policy? and one retrieved chunk of two sentences about a 60-day refund policy and weekday support, with an unknown return value.
Inputs from the question.
12. RAGPipeline.index runs with chunk_size=200, overlap=50, a 350-word document named "hr" and a 100-word document named "it". What does it return and what is self.sources?Apply
Two document bars, hr with 350 words and it with 100 words, indexed with chunk_size 200 and overlap 50, and unknowns for the return value and self.sources.
Setup from the question.
13. A search over a TF-IDF index returns odd chunks for "What is the refund policy?" even though chunks about refund policy exist. Which cause fits this toy pipeline?Analyze
The query What is the refund policy? above the first words of the sample refund-policy chunk, and an unknown for why search returns odd chunks.
Chunk text copied from the lesson's sample documents.

Use It

With a real embedding model and LLM, the code changes very little:

python
from openai import OpenAI
 
client = OpenAI()
 
def embed(text):
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding
 
def generate(prompt):
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    return response.choices[0].message.content

Or with Anthropic:

python
import anthropic
 
client = anthropic.Anthropic()
 
def generate(prompt):
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text

The pipeline keeps its shape. Swap the embedding function, swap the generation function, and the retrieval logic, chunking, and prompt construction stay as they were.

For vector storage at scale, replace the brute-force search with a vector database:

python
import chromadb
 
client = chromadb.Client()
collection = client.create_collection("my_docs")
 
collection.add(
    documents=chunks,
    ids=[f"chunk_{i}" for i in range(len(chunks))]
)
 
results = collection.query(
    query_texts=["What is the refund policy?"],
    n_results=5
)

Chroma embeds the text for you (it uses all-MiniLM-L6-v2 by default) and stores the vectors in a local database. Same pattern, different plumbing.

Check: Use It0/2
1. When moving from the toy pipeline to a real embedding model and LLM, what changes?Explain
Five boxes for the toy pipeline (chunk_text, tfidf_embed, search, build_rag_prompt, simple_generate), two chips for a neural embedding model and an LLM API, and an unknown for which toy parts get replaced.
Part names from the lesson's main.py.
2. What does Chroma do when you call collection.add with documents but no vectors?Explain
Code that creates a Chroma collection and calls collection.add with documents and ids but no vectors, and an unknown for what gets stored.
Code copied from the lesson.

Ship It

This lesson produces:

  • outputs/prompt-rag-architect.md: a prompt for designing RAG systems for specific use cases
  • outputs/skill-rag-pipeline.md: a skill that teaches agents how to build and debug RAG pipelines
Author's Note

I'd run main.py and spend the most time on STEP 5. It re-indexes the same five documents at chunk sizes of 20, 50, 100, and 200 words and prints the chunk count, the top score, and the answer length for one query. That is the chunk-size tradeoff from the concept section as numbers you can watch change.

My bet is that the TF-IDF part is worth reading slowly. Tokens come from lower().split(), so "policy?" and "policy" are different vocabulary entries, and every distinct string becomes one dimension of the vector. That is plain to see in a toy, and it explains why the retrieved chunks sometimes look odd. A neural embedding model is what removes that limit.

I'd also keep the toy in proportion. The generator only picks the retrieved sentence that overlaps the question most, and the search loops over every stored embedding. A good answer from main.py tells you the retrieval worked, not that a model reasoned over it. When I move this to real embeddings and an LLM, I'd keep RAGPipeline and swap the embedding and generation functions.

Exercises

  1. Replace the TF-IDF embeddings with a simple bag-of-words approach (binary: 1 if word present, 0 if not). Compare retrieval quality on the sample documents. TF-IDF should outperform because it weights rare words higher.

  2. Experiment with chunk sizes: try 50, 100, 200, and 500 words on the same document set. For each size, run the same 5 queries and count how many return a relevant chunk in the top-3. Find the sweet spot where retrieval quality peaks.

  3. Add metadata to each chunk (source document name, chunk position). Modify the prompt template to include source attribution so the LLM cites its sources.

  4. Implement a simple evaluation: given 10 question-answer pairs, run each question through the RAG pipeline, and measure what percentage of retrieved chunks contain the answer. This is retrieval recall at k.

  5. Build a conversation-aware RAG pipeline: maintain a history of the last 3 exchanges and include them in the prompt alongside the retrieved chunks. Test with follow-up questions like "What about enterprise?" after asking about pricing.

Key Terms

TermWhat people sayWhat it actually means
RAG"AI that reads your docs"Retrieve relevant documents, paste them into the prompt, and generate an answer grounded in those documents
Embedding"Convert text to numbers"A dense vector representation of text where similar meanings produce similar vectors
Vector database"Search engine for AI"A data store optimized for storing vectors and finding the nearest neighbors by similarity
Chunking"Split docs into pieces"Breaking documents into smaller segments (typically 256-512 tokens) so each can be embedded and retrieved independently
Cosine similarity"How similar are two vectors"The cosine of the angle between two vectors; 1 = identical direction, 0 = orthogonal, -1 = opposite
Top-k retrieval"Get the k best matches"Return the k most similar chunks to the query from the vector store
Context window"How much text the LLM can see"The maximum number of tokens the LLM can process in a single request; retrieved chunks must fit within this
Augmented generation"Answer using given context"Generating a response using retrieved documents as context rather than relying solely on trained knowledge
TF-IDF"Word importance scoring"Term Frequency times Inverse Document Frequency; weights words by how distinctive they are within a corpus
Indexing"Preparing docs for search"The offline process of chunking, embedding, and storing documents so they can be searched at query time

Further Reading

  • Lewis et al., "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks" (2020): the original RAG paper from Facebook AI Research that formalized the retrieve-then-generate pattern
  • Anthropic's RAG documentation (docs.anthropic.com): practical guidelines for chunk sizes, prompt construction, and evaluation
  • Pinecone Learning Center, "What is RAG?": clear visual explanations of the RAG pipeline with production considerations
  • Sentence-BERT: Reimers & Gurevych (2019): the paper behind the all-MiniLM embedding models, showing how to train bi-encoders for semantic similarity
  • Karpukhin et al., "Dense Passage Retrieval for Open-Domain Question Answering" (EMNLP 2020) -- the DPR paper that proved dense bi-encoder retrieval beats BM25 on open-domain QA and set the pattern for modern RAG retrievers.
  • LlamaIndex High-Level Concepts -- the main concepts to know when building RAG pipelines: data loaders, node parsers, indices, retrievers, response synthesizers.
  • LangChain RAG tutorial -- the opposite-flavor orchestrator; chain-of-runnables view of the same retrieve-then-generate pattern.

Code

Ships With