Retrieval Augmented
Generation

How to give a language model access to a hundred thousand documents without ever putting a hundred thousand documents in the prompt.

WORK-ALONG SESSION  ·  Lesson 01 theory, Lesson 02 in code
YOU NEED  ·  Python 3.10+, an editor, one OpenAI API key
COST  ·  Under ten cents of embeddings
EIGHT LIVE DEMOS  ·  every claim on these slides is executed, not asserted
00  ·  Outcomes

What you will be able to do

  • Explain why a bigger context window does not remove the need for RAG.
  • Define token, chunk, embedding, dimension, vector database and retriever.
  • Draw both halves of a RAG system from memory.
  • Predict which chunks a retriever will return for a question, and say why.
  • Build a working ingestion pipeline in about sixty lines of Python.
  • Avoid the mistake that silently breaks most first RAG builds.
00  ·  How this session runs

Two windows, eight demos

Slides on one side, a terminal on the other. Whenever you see an orange bar, I stop talking and run the command in it. The output on the slide is the real output from that command.

Demo 00 ./run.sh check
Follow along One entry point for everything: ./run.sh setup once, then ./run.sh slides for this deck and ./run.sh demo 5 for any demo. demo/ingestion_pipeline.py is the finished file we build up to.
Part one

Why RAG exists

The problem is not that models are stupid.
The problem is that they are small.

01  ·  The problem

Several hundred internal documents

Policy guidelines. Technical specs. Customer support logs. Contracts.

Someone asks a question that one of those documents answers. The obvious move is to paste all of them into the model and ask.

That does not work — and the reason it does not work is the whole reason this lesson exists.

01  ·  Definition

Retrieval Augmented Generation

A language model combined with a retrieval system. The retrieval system searches external sources — documents, databases, knowledge bases — and pulls only the relevant pieces into the prompt, at the moment the model needs them.

The model does not get everything. It gets the right few pages.

That is the entire idea. Everything else in this session is mechanics.

02  ·  Tokens

Models do not read words

A token is the unit of text a model processes. Sometimes a whole word. Often a fragment of one. Models do not read letters and they do not read words.

Demo 01 python 01_tokens.py
text : 'Retrieval augmented generation is powerful' words : 5 tokens : 7 split as : Ret | rie | val | _augmented | _generation | _is | _powerful

One token is roughly three quarters of an English word. Common words are one token; rare or long words split into several. Spaces travel with the token that follows.

02  ·  The context window

Everything has to fit inside one window

The context window is the total number of tokens a model can hold in a single request. Everything you send and everything it writes back has to fit. Past that limit, the information is simply not there.

Where the frontier sits Models in 2026 sit somewhere between 200,000 and 2 million tokens depending on the model. That sounds enormous — until you look at how much text a real company actually holds.
Verify current figures before you quote them. They change every few months.
02  ·  The scale gap

Read the scale carefully

How much text, in tokens — logarithmic scale One chunk 1,000 These 5 articles ██████ 72,216 A frontier model window ██████████ 2,000,000 A mid sized company, 1 TB █████████████████████████ 250,000,000,000 An enterprise archive, 1 PB ██████████████████████████████████ 250,000,000,000,000

Each step to the right is ten times larger, not one step larger. The gap between a model window and an enterprise archive is not a gap you close by waiting for bigger models.

02  ·  Interactive

Try it: when do you actually need RAG?

Corpus scale explorer drag the slider
0
tokens
0
chunks @800
$0
to embed once
0
of a 2M window

Embedding cost assumes text-embedding-3-small at $0.02 per million tokens. One-off, not per question.

02  ·  The second reason

You also pay per token

Even when everything does fit, sending it all is the wrong move.

Send everything

  • Expensive — you pay for every token
  • Slow — latency scales with input
  • Worse answers — the signal is buried

Send the right five paragraphs

  • Cheap
  • Fast
  • Better answers
The point Sending 500,000 tokens of irrelevant context to answer one question is expensive, slow, and produces worse answers than sending the five right paragraphs.
Part two

The shape of the system

Two pipelines, not one.
Learn them separately and the rest is easy.

03  ·  The whole system in one picture

Two pipelines

Pipeline 01  ·  INGESTION  ·  runs once, before any question
Sourcedocuments
Chunkingcut to size
Embeddingmodel
Vectorslists of numbers
Vectordatabase
Pipeline 02  ·  RETRIEVAL  ·  runs on every question
Userquestion
Embeddingmodel
Retrieverranks by closeness
Top matchingchunks
LLM writesthe answer
Not a drawing shortcut The same embedding model appears in both rows. That is the single most important detail on this slide, and we will break it on purpose at the end to show you why.
04  ·  Chunking

A chunk is a slice, not a section

Chunking is breaking large documents into small pieces. You choose the size. Set it to 1,000 tokens and 10 million tokens of documents becomes 10,000 chunks.

The word "chunk" is doing work here Chunks are cut by size, not by meaning. They do not respect chapters, headings or arguments. A naive splitter will cut through the middle of a sentence or a table without hesitating.

Why cut at all? Because nobody wants an entire Wikipedia article returned because one sentence in it matched. The chunk is your unit of retrieval — it is the smallest thing the system can hand back.

04  ·  The embedding model

This is not an LLM

Different model, different job. It does not generate text. Text goes in; a list of numbers comes out.

Demo 02 python 02_embedding_shape.py
one word 1 words in -> 1536 numbers out one sentence 8 words in -> 1536 numbers out a paragraph 337 words in -> 1536 numbers out [ -0.0616 +0.0554 +0.0004 +0.0124 +0.0016 -0.0123 ... ]

Fixed length output. One word in or nine hundred words in, the vector that comes out is always the same length.

05  ·  What an embedding is

Similar meaning, similar numbers

A vector embedding is a list of numbers that stands in for a piece of text. Each number is called a dimension.

Text with similar meaning produces similar numbers.
Nothing else about embeddings matters as much as that sentence.
WordDim 1 · sizeDim 2 · domesticatedDim 3 · sound
cat3487.5
kitten3387.1
dog4086.9
elephant210621.2

Real dimensions are not labelled and nobody knows what any single one means. These labels are a teaching device only.

05  ·  Measured, not asserted

Does that actually hold?

Demo 03 python 03_similar_meaning.py
cat -> dog 0.603 ████████████████████████ cat -> kitten 0.570 ███████████████████████ cat -> coffee 0.385 ███████████████ cat -> tea 0.358 ██████████████ cat -> apple 0.357 ██████████████ cat -> elephant 0.319 █████████████ cat -> mango 0.264 ███████████ DOMESTIC ANIMALS inside 0.531 outside 0.316 DRINKS inside 0.611 outside 0.313

Every group is tighter inside than out. That is a neighbourhood — and nobody labelled any of it.

05  ·  Interactive

Try it: the neighbourhood map

Real embeddings · text-embedding-3-small click any word to make it the probe
cat
probe word
closest
furthest

These are genuine 1,536-dimension OpenAI vectors, precomputed by tools/export_slide_data.py. The map is a 2-D projection of all 1,536.

06  ·  Choosing a model

The ones you will meet first

ModelDefault dimensionsNotes
text-embedding-3-small1,536Fine for most projects. Cheap. We use this one.
text-embedding-3-large3,072Best quality of the two. Costs more.
voyage-3-large1,024Strong on technical and code text.
Cohere embed1,024Good multilingual support.
Mistral embed1,024Open weights available.
A cheap win Most of these let you request fewer dimensions than the default. Asking a 3,072 model for 512 cuts your storage by six and usually costs very little accuracy.
Check current pricing and dimension options in the provider docs. These move.
07  ·  Where the vectors live

The vector database stores both

IDVectorOriginal textSource
0001[0.021, -0.884, ...]Refunds are issued within 30 days.policy.pdf
0002[-0.412, 0.663, ...]Q1 revenue reached 4.2 million.sales.xlsx
0003[0.907, 0.115, ...]Guest wifi resets every Monday.itwiki.md
One row per chunk The vector is how it gets found. The original text is what gets used. Store only the numbers and you have nothing to send the model.

Purpose built: Pinecone, Weaviate, Chroma, Qdrant. FAISS is a library rather than a service. Or add pgvector to a Postgres you already run.

Part three

Retrieval, in principle

Ingestion is finished. Nothing above runs again
until your documents change.

08  ·  Retrieval, step by step

The question takes the same road

  1. The question goes through the same embedding model the documents went through.
  2. It comes out as a vector of the same length.
  3. The retriever compares that one vector against every stored vector.
  4. It ranks them by closeness and returns the top k.
THE QUESTION "What were our sales in the first quarter?" [-0.398, 0.671, 1.194, ...] same model, same dimensions 0.91 Q1 revenue reached 4.2 million. 0.88 Quarterly sales by region, Jan to Mar. ├ top 3 to the LLM 0.84 Revenue targets for the first quarter. 0.42 Annual headcount summary. 0.31 Refunds are issued within 30 days. 0.19 Guest wifi resets every Monday.
08  ·  Top k

You choose how many come back

That number is called top k. Ask for the top 5 and you get 5 results — whether or not all 5 are any good.

A retriever always returns something It has no concept of "no good match". If your corpus contains nothing relevant, you still get k results back, ranked. They are just the least-bad of a bad set. Handling weak matches is a later topic — but knowing it happens is not.

So: a retriever that returns 10 chunks of which 4 are irrelevant is not broken. That is the expected shape of the output.

08  ·  The step everyone misreads

Vectors find. Text is what gets sent.

Sent to the model

  • The user's question
  • The original English text of the top chunks

Not sent

  • The vectors
  • Anything numeric at all
Vectors are only used for finding. After retrieval you are back in plain English. The prompt that reaches the model is the question and a few paragraphs of ordinary text.
Part four

Build the ingestion pipeline

Five articles in. A searchable vector database out.
About sixty lines of Python and one API key.

00  ·  Where this sits

You are here

THIS SESSION  ·  INGESTION
5 text filesin docs/
547chunks
OpenAIembeddings
547vectors
Chromaon disk
NEXT LESSON  ·  RETRIEVAL
Question
Embed it
Retrieve top k
Build prompt
Answer
Runs once When this session finishes you will not run this code again unless your documents change.
01  ·  Set up

The project

rag-for-beginners/
├── ingestion_pipeline.py   ← all the code goes here
├── .env                    ← your API key
├── docs/                   ← your source documents
└── venv/                   ← created below
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $
Check this The (venv) prefix on your prompt is how you know it worked. If it is not there, nothing below will behave as expected.
01  ·  Packages

Six packages, one line

$ pip install langchain langchain-community langchain-text-splitters \
      langchain-openai langchain-chroma python-dotenv
PackageWhat it does here
langchainCore abstractions the others build on.
langchain-communityDocument loaders. Reads files off disk.
langchain-text-splittersChunking.
langchain-openaiThe embedding model client.
langchain-chromaThe vector database.
python-dotenvReads your API key out of the .env file.
02  ·  The API key

One secret, in one place

OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx
Two things to do now Add credit. The API is prepaid and separate from any ChatGPT subscription. Minimum top-up is around $5. Embedding these five documents costs well under one cent.

Add .env to .gitignore. Do it before your first commit, not after. A leaked key gets scraped from public repositories within minutes.
03  ·  The documents

Five articles

Demo python3 tools/fetch_docs.py
google.txt 68,012 characters 180 paragraphs microsoft.txt 67,411 characters 173 paragraphs nvidia.txt 62,402 characters 246 paragraphs spacex.txt 55,954 characters 172 paragraphs tesla.txt 93,916 characters 339 paragraphs TOTAL 347,695 characters

Any five text files will work. Company articles are convenient because they are long, factual, and full of specific numbers you can test the retriever against.

04  ·  The shell

Imports first, prove it runs

import os
from dotenv import load_dotenv

from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import CharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

load_dotenv()

DOCS_PATH = "docs"
DB_PATH = "db_chroma"

def main():
    print("main function")

if __name__ == "__main__":
    main()

Run it. You should see main function and nothing else — that confirms your environment, your packages and your file are wired up correctly before you write anything real.

05  ·  Step one

Load the files

def load_documents(docs_path):
    # Fail loudly and early rather than silently loading nothing
    if not os.path.exists(docs_path):
        raise FileNotFoundError(f"Directory not found: {docs_path}")

    loader = DirectoryLoader(
        docs_path,
        glob="*.txt",             # only text files, ignore everything else
        loader_cls=TextLoader,    # how to read each matched file
    )
    documents = loader.load()

    if len(documents) == 0:
        raise ValueError(f"No .txt files found in {docs_path}")

    return documents
Demo 04 python 04_load.py
05  ·  What comes back

Five files in, five Documents out

Loaded 5 documents tesla.txt 93,843 characters microsoft.txt 67,342 characters nvidia.txt 62,261 characters spacex.txt 55,899 characters google.txt 67,987 characters .page_content 'Tesla, Inc. ( TEZ-lə or TESS-lə) is an American...' .metadata {'source': 'docs/tesla.txt'}
Learn these two attributes now .page_content is the entire text of the file as one long string. .metadata is a dictionary, filled in for you by the loader. This object shows up everywhere from here on.
05  ·  Two gotchas

Two things that will bite you

Order is not guaranteed The loader does not read files alphabetically. In that run, index 0 was tesla.txt — not google.txt. Never write code that assumes index 0 is a particular file.
Other file types need other loaders PyPDFLoader for PDFs, CSVLoader for CSVs, WebBaseLoader for pages. Swap loader_cls and change the glob. Leave loader_cls out entirely and LangChain falls back to a loader that needs the heavy unstructured package installed.
06  ·  Step two

Chunk them

def split_documents(documents, chunk_size=800, chunk_overlap=0):
    splitter = CharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separator="\n\n",         # prefer to break at paragraph boundaries
    )
    return splitter.split_documents(documents)
Demo 05 python 05_chunk.py
Note the unit chunk_size=800 means 800 characters, not 800 tokens. Roughly 200 tokens. Different splitters count differently — always check which unit you are in.
06  ·  The result

800 is a target, not a cap

Split 5 documents into 539 chunks chunks 539 smallest 15 characters largest 1,719 characters average 643 characters 79 of 539 chunks came out longer than the 800 target
Why CharacterTextSplitter splits on the separator first, then merges pieces up to chunk_size. It never breaks a paragraph in half to hit the number. So you will see warnings — they are warnings, not errors.
RecursiveCharacterTextSplitter handles this better and is what you would use in a real project. We stay on the simple one so the mechanics show.
06  ·  Overlap

What chunk_overlap actually does

chunk_overlap = 0 [0] Tesla was incorporated in July 2003 by Martin Eberhard and Marc Tarpenning as Tesla Motors. Its name is a tribute to the [1] inventor Nikola Tesla. In February 2004 Elon Musk led Tesla's... chunk_overlap = 40 [0] Tesla was incorporated in July 2003 by Martin Eberhard and Marc Tarpenning as Tesla Motors. Its name is a tribute to the [1] Motors. Its name is a tribute to the inventor Nikola Tesla. In...

With overlap at zero, a sentence that straddles the boundary ends up half in one chunk and half in another — and neither half matches the question well.

Rule of thumb: 10–20% of chunk size. For 800 characters, 100 is a reasonable default.

06  ·  Interactive

Try it: watch the chunks re-cut

Chunking playground overlap is highlighted in each chunk
0
chunks
0
avg chars
0%
duplicated
0
est. tokens
07  ·  Step three

Embed and store — one call does both

def create_vector_store(chunks, db_path):
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    vector_store = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=db_path,
        collection_metadata={"hnsw:space": "cosine"},
    )
    return vector_store
Demo 06 python 06_embed_store.py
This is the step that costs money and takes time Every chunk is a call to OpenAI. Expect thirty seconds to a couple of minutes.
07  ·  What landed on disk

One row per chunk

Stored 547 vectors in db_chroma/ the collection now holds 547 vectors the vector 1,536 numbers [-0.0186, -0.0430, +0.0030, ...] the original text 'Tesla, Inc. ( TEZ-lə or TESS-lə) is an American...' the metadata {'source': 'docs/tesla.txt'}
db_chroma/
├── chroma.sqlite3          text, metadata, ids
└── a1b2c3d4-.../           the collection
    ├── data_level0.bin     the vectors
    └── link_lists.bin      the search index

The original text is not optional. Without it you have numbers and nothing to send an LLM.

07  ·  The three arguments

The three arguments that matter

ArgumentWhy it matters
embedding text-embedding-3-small, 1,536 dimensions. Write that choice down. Next lesson you must embed the user's question with exactly the same model, or retrieval returns nonsense with no error message.
persist_directory Where the database lives on disk. Leave it out and Chroma runs in memory only — everything disappears when the script ends and you pay to embed all over again.
collection_metadata
{"hnsw:space": "cosine"}
The distance measure used to compare vectors. Cosine similarity is the standard choice for text. Set it and move on.
08  ·  Confirm it worked

Ask it something

Demo 07 python 07_query.py "Who founded SpaceX?"
question 'Who founded SpaceX?' collection 547 vectors 0.662 ███████████████ spacex.txt In early 2002, Elon Musk started to look... 0.628 ██████████████ spacex.txt SpaceX was founded in 2002 by Elon Musk... 0.627 ██████████████ spacex.txt Space Exploration Technologies Corp., ... 0.598 █████████████ spacex.txt The company is credited with advances... 0.591 █████████████ spacex.txt In early 2012, approximately two-thirds...

All five from spacex.txt, out of a store containing four other companies. Your ingestion pipeline is finished and correct.

08  ·  The actual prompt

This is what reaches the model

│ Answer the question using only the context below. │ │ CONTEXT: │ In early 2002, Elon Musk started to look for staff for his company, │ soon to be named SpaceX. Musk approached five people for the initial │ positions, including Griffin, who declined the position of Chief │ Engineer, Jim Cantrell and John Garvey ... │ │ SpaceX was founded in 2002 by Elon Musk with the goal of reducing │ spaceflight costs ... │ │ QUESTION: Who founded SpaceX? prompt length: 2,972 characters, built from the top 3 chunks

Sent: the question, plus the original English text.  Not sent: the vectors. Their job finished the moment the matching was done.

08  ·  Interactive

Try it: ask, and watch the prompt build

Retrieval playground highlighted chunks are the ones sent
annual leave laptop budget when is payday what is the capital of France
0
prompt tokens
0
document tokens
0%
sent to model
0.00
top score

Scoring here uses character-trigram vectors so the page runs with no API key. The mechanism is identical; demo 07 does the same thing with real embeddings.

08  ·  The re-run trap

Run it twice, pay twice

Demo 06 python 06_embed_store.py --append
from_documents ADDS, it does not replace Run the script twice and you have 1,094 vectors, half of them duplicates, and you paid to embed everything twice. While you are experimenting, delete db_chroma/ before each run.

The demo script clears the folder by default for exactly this reason — --append opts back in to the mistake so you can see it happen.

Part five

The mistake that breaks
most first builds

Nothing crashes. There is no error message.

09  ·  The consistency rule

The consistency rule

Use the same embedding model and the same dimension count for your documents and for your queries. Every time. No exceptions.

Documents embedded in January

text-embedding-3-large
3,072 dimensions

Queries embedded in March

text-embedding-3-small
1,536 dimensions

Think of them as separate languages A vector written by one model means nothing to another. The two systems cannot understand each other, and neither one will tell you.
09  ·  Break it on purpose

Same store. Same question. Nothing crashes.

Demo 08 python 08_model_mismatch.py
question: 'Who founded SpaceX?' (answer lives in spacex.txt) RIGHT model text-embedding-3-small 0.662 spacex.txt In early 2002, Elon Musk started to look... 0.628 spacex.txt SpaceX was founded in 2002 by Elon Musk... 0.627 spacex.txt Space Exploration Technologies Corp., ... WRONG model text-embedding-3-large@1536 0.064 spacex.txt === Starbase, Texas === SpaceX manufact... 0.055 spacex.txt In December 2022, the U.S. Federal Comm... 0.052 microsoft.txt Microsoft became the third publicly ... ← WRONG FILE

Scores collapse from 0.66 to 0.06, and a Microsoft chunk is returned for a question about SpaceX.

09  ·  Interactive

Try it: flip the model, break the system

Same store · same question · different query model real precomputed scores
0.00
top score
model used for the question
0
errors raised
09  ·  In practice

What this means in practice

  1. Choose your embedding model before you ingest a single document.
  2. Choose your dimension count at the same time, and write it down.
  3. If you switch model later, you re-embed the entire corpus. There is no partial migration.
  4. Changing dimensions within the same model breaks it too.
A cheap defence Store the model name and dimension count in the collection metadata when you build the store, and assert on it when you query. Ten lines of code that turn a silent failure into a loud one.
10  ·  When it does not run

When it does not run

ErrorCause and fix
OpenAIError: api_key must be set No .env, wrong variable name, or load_dotenv() missing. The name must be exactly OPENAI_API_KEY.
RateLimitError: quota exceeded The key works but the account has no credit. Add funds in Billing. This is not a rate limit, despite the name.
ModuleNotFoundError The virtual environment is not active, or you installed into a different one. Check for (venv) in your prompt.
No .txt files found You are running the script from a different directory than the one holding docs/.
Created a chunk of size N,
longer than 800
A warning, not an error. A single paragraph exceeded the target. Expected behaviour.
10  ·  Check yourself

Check yourself

  1. Why does a 2 million token context window not remove the need for RAG? Two reasons.
  2. You have 4 million tokens of documents and a chunk size of 500. How many chunks, and how many vectors?
  3. A retriever returns 10 chunks and 4 are irrelevant. Is the system broken?
  4. What exactly is in the prompt that reaches the LLM?
  5. Which attribute of a Document survives chunking?
  6. chunk_size=800 counts what unit?
  7. You leave out persist_directory. What happens when the script ends?
  8. You embedded documents with model A and queries with model B. What is the symptom?
10  ·  Summary

The whole session in ten lines

  1. Models have a token limit. Real document stores are millions of times larger.
  2. So you retrieve the relevant pieces instead of sending everything.
  3. A RAG system is two pipelines. Ingestion runs once; retrieval runs per question.
  4. Ingestion: cut documents into chunks, embed each chunk, store vectors with their text.
  5. An embedding is a fixed-length list of numbers where similar meaning gives similar numbers.
  6. A vector database stores those numbers and searches them fast.
  7. Retrieval: embed the question the same way, rank by closeness, take the top k.
  8. Send the model the question plus the original text. Never the vectors.
  9. Set chunk_overlap so sentences on the seam survive. Set persist_directory or you lose the work.
  10. One embedding model, one dimension count, everywhere. Breaking this fails silently.

Next: the retrieval
pipeline

You take a question, embed it with that same model, pull the closest chunks out of db_chroma/, and hand them to an LLM to answer.

YOU BUILT  ·  547 vectors on disk, searchable, with sources
YOU LOCKED IN  ·  text-embedding-3-small at 1,536 dimensions
BRING  ·  a set of documents you actually want to ask questions about