RAG, the short way.
The spine of the lesson: every demo, the two strongest playgrounds, and none of the asides. Same depth, fewer detours.
Presenting instead? Open the deck →
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.
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
Welcome. Over the next hour or so we are going to build a Retrieval Augmented Generation system from nothing, and more importantly, we are going to understand every piece of it.
Here is the one-line version of what RAG is for. How do you give a language model access to a hundred thousand documents, without ever putting a hundred thousand documents into the prompt? That is the problem. Everything else is mechanics.
This is a work-along session. I am going to switch between these slides and a terminal, and every number you see on a slide is something we actually run. Nothing here is asserted and left hanging — if I claim two pieces of text have similar embeddings, we measure it live.
You will need Python three point ten or newer, an editor, and one OpenAI API key. The whole thing costs well under ten cents in embeddings.
Let's start with why this problem exists at all.
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.
./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.
A word on how this runs, so you can follow along rather than just watch.
I have two windows: these slides, and a terminal. Whenever an orange bar appears on a slide, that is my cue to stop talking and run the command written in it. The output you then see on the slide is the genuine output of that command — I have not typed nice numbers into a slide anywhere.
Everything lives in the repository, behind one entry point. Run dot s h setup once, and then run dot s h slides for this deck, or run dot s h demo five for any demo. There is a check command too, which is what I am running now — it verifies the environment, the packages, the documents and the API key before we start, so nothing surprises us on camera.
The demo folder has eight numbered scripts, one per concept, and ingestion underscore pipeline dot py is the finished sixty-line file that all of this builds toward.
If you are working along, pause whenever you need to. The scripts are all independent except that six, seven and eight need the database that six builds.
Let me run the check, and we will begin.
Why RAG exists
The problem is not that models are stupid.
The problem is that they are small.
Part one. Why RAG exists at all.
I want to frame this carefully, because the framing matters. The problem RAG solves is not that language models are stupid. Modern models are extraordinarily capable. The problem is that they are small — not in intelligence, but in how much they can look at in one go.
Let's make that concrete.
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.
Picture a company with several hundred internal documents. Policy guidelines, technical specifications, customer support logs, contracts. Ordinary business documents.
Somebody asks a question, and exactly one of those documents contains the answer. The obvious move — and this is genuinely what everyone tries first — is to paste all of them into the model and ask the question.
That does not work. And the reason it does not work is the whole reason this lesson exists. So let me give you the definition to hold on to, and then we will take apart why the obvious approach fails.
Retrieval Augmented Generation
The model does not get everything. It gets the right few pages.
That is the entire idea. Everything else in this session is mechanics.
Here is the definition. Retrieval Augmented Generation is a language model combined with a retrieval system. The retrieval system searches external sources — documents, databases, knowledge bases — and it pulls only the relevant pieces into the prompt, at the moment the model needs them.
Read that last part again, because it is the part people skip. At the moment the model needs them. Nothing is loaded in advance into the model. Nothing is trained into the model. The relevant text is fetched, per question, and placed in the prompt.
So the model does not get everything. It gets the right few pages.
That is the entire idea. I mean that literally — everything else in this session is mechanics for how you find the right few pages quickly. If you leave with only one sentence, leave with that one.
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.
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.
First piece of vocabulary: the token.
A token is the unit of text a model processes. Sometimes it is a whole word. Often it is a fragment of one. Models do not read letters, and they do not read words — they read tokens. This matters because every limit and every price you will ever meet is denominated in tokens, not words.
Let me run demo one and show you.
[RUN DEMO 01]
Look at that. The sentence "Retrieval augmented generation is powerful" is five words. The model counts seven tokens. And look at how it split: "Retrieval" — a fairly ordinary English word — got broken into three pieces, R-e-t, r-i-e, v-a-l. Whereas "augmented", "generation", "powerful" each survived as a single token.
That is the rule in action. Common words are one token. Rarer or longer words get split into several. And notice the underscores in that output — those are spaces. Spaces travel with the token that follows them, which is why it is "underscore augmented" rather than "augmented".
The rule of thumb worth memorising: one token is roughly three quarters of an English word. So a thousand words is roughly thirteen hundred tokens.
Read the scale carefully
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.
This is the same demo still running — the second half of demo one.
Now, this is a logarithmic scale, and I need you to read it carefully, because logarithmic scales are quietly deceptive. Each step to the right is ten times larger than the last, not one step larger. The bars look comparable. The numbers are not.
Start at the top. One chunk — that is a single retrievable piece of text, and we will define it properly in a moment — is about a thousand tokens.
The five Wikipedia articles we are about to ingest come to seventy-two thousand tokens. That is our whole corpus for today, and notice, it comfortably fits inside a frontier model. For five documents you genuinely do not need RAG.
A frontier model window: two million.
Now watch what happens. A mid-sized company with one terabyte of documents: two hundred and fifty billion tokens. That is a hundred and twenty-five thousand times larger than the model window.
An enterprise archive at one petabyte: two hundred and fifty trillion.
So here is the thing to take away. That gap is not a gap you close by waiting for bigger models. If context windows got a thousand times bigger tomorrow — which they will not — you would still be three orders of magnitude short of the mid-sized company. This is a structural problem, not a temporary one.
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
There is a second reason, and it matters just as much as the first, but people forget it because the first one is so dramatic.
Even when everything does fit — even in the case where your documents are small enough — sending all of it is still the wrong move.
Three reasons. One, you pay per token, so you are paying for every irrelevant word. Two, it is slow; latency scales with how much you send. And three — this is the one that surprises people — you get worse answers.
That third point is counterintuitive so let me be explicit about it. If you bury the one relevant paragraph inside five hundred thousand tokens of unrelated material, the model has a harder job finding it than if you had simply handed it the paragraph. More context is not more helpful. Relevant context is helpful.
So: sending five hundred thousand tokens of irrelevant context to answer one question is expensive, slow, and worse. Sending the five right paragraphs is cheap, fast, and better.
That is the case for RAG, complete. Now let's look at how it is actually built.
The shape of the system
Two pipelines, not one.
Learn them separately and the rest is easy.
Part two. The shape of the system.
If there is one slide in this whole session to photograph, it is the next one. A RAG system is two pipelines, not one. People collapse them into one in their heads, and that is precisely where the mental model breaks and the questions get confused.
So: two pipelines. Learn them separately, and everything after this is easy.
Two pipelines
Here it is. The whole system in one picture.
The top row is the ingestion pipeline, and the thing to understand about it is that it runs once, ahead of time, before anybody asks anything. Source documents go in. They get chunked — cut into small pieces. Each piece goes through an embedding model, which turns it into a vector, a list of numbers. Those vectors get stored in a vector database. Done. That pipeline does not run again until your documents change.
The bottom row is the retrieval pipeline, and it runs every single time someone asks a question. The question comes in. It goes through an embedding model and becomes a vector. A retriever compares that vector against everything in the database and ranks by closeness. The top matching chunks come back. And those chunks, plus the question, go to the LLM, which writes the answer.
Now look at the two orange boxes. The embedding model appears in both rows. That is not me being lazy with the drawing. It is the same model, and it has to be the same model. If those two boxes ever contain different models, the entire system fails — and it fails silently, with no error message. We are going to break that rule deliberately in the last demo so you know exactly what it looks like.
Keep this diagram. Every RAG system you will ever build, from a weekend project to production, is this diagram with more engineering around each box.
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.
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.
Vocabulary word three: the chunk.
Chunking is breaking large documents into small pieces. You choose the size. If you set it to a thousand tokens, then ten million tokens of documents becomes ten thousand chunks. Simple arithmetic.
Now, the word "chunk" is doing some work here and I want to be precise. A chunk is a slice, not a section. Chunks are cut by size, not by meaning. They do not respect chapter boundaries, headings, or the structure of an argument. A naive splitter will cut straight through the middle of a sentence, or a table, without hesitating. We are going to watch that happen live in demo five.
So why cut at all? Why not keep whole documents? Because the chunk is your unit of retrieval — it is the smallest thing the system is able to hand back. If your chunks are whole Wikipedia articles, then a question about one sentence returns the entire article, and you are back to stuffing the context window with mostly-irrelevant text. Which is the problem we started with.
Chunk size is therefore a real design decision, and there are techniques for cutting more intelligently. Those come later in the track. For today we cut simply, and I will show you exactly what that costs.
This is not an LLM
Different model, different job. It does not generate text. Text goes in; a list of numbers comes out.
Fixed length output. One word in or nine hundred words in, the vector that comes out is always the same length.
Next box in the diagram: the embedding model. And the first thing to say, because it trips everybody up, is that this is not an LLM. Different model, different job. It does not generate text. It does not chat. You give it text, and it gives you back a list of numbers. That is all it does.
Let me run demo two.
[RUN DEMO 02]
Look at the three rows. I sent it one word. I sent it an eight-word sentence. I sent it a three-hundred-and-thirty-seven-word paragraph. And every single time, what came back was fifteen hundred and thirty-six numbers. Not more for the paragraph. Not fewer for the single word. Always exactly fifteen thirty-six.
Underneath you can see the first few actual numbers. They are small, they are signed, and — this is worth saying plainly — no individual number there means anything you can name. Nobody knows what dimension four hundred and twelve represents. That is fine. It is not how they are used.
The property that matters is the fixed length. One word in or nine hundred words in, the vector is the same size. And that is the property the entire system is built on, because it means any two pieces of text — however different in length — become two lists of the same size, and two lists of the same size can be compared with simple arithmetic.
That is the trick. Everything downstream depends on it.
Does that actually hold?
Every group is tighter inside than out. That is a neighbourhood — and nobody labelled any of it.
Let's measure it. Demo three.
[RUN DEMO 03]
This takes eight words, embeds each one with a real OpenAI model, and measures how close every pair actually is. One point zero would be identical.
Top of the list: cat to dog, zero point six oh three. Then cat to kitten at zero point five seven. Then a clear drop — coffee, tea, apple, elephant, mango, all down in the three-hundreds and two-hundreds.
Now, I want to point at something honest here, because it contradicts the tidy table on the previous slide. Our made-up table said kitten should be closest to cat. The real model puts dog first, with kitten second. So the toy intuition was directionally right and specifically wrong.
Why? Because bare single words are a weak signal. "Cat" and "dog" co-occur in text constantly — they are the two canonical pets — whereas "kitten" is a narrower, less common word. Real systems almost never embed single words; they embed whole paragraphs, where there is far more meaning to work with. So do not over-read the ordering within a cluster.
But look at the bottom two lines, because that is where the claim really holds. Domestic animals: the average similarity inside that group is zero point five three, and to everything outside it, zero point three two. Drinks: zero point six one inside, zero point three one outside. Every group is meaningfully tighter inside than out.
That is a neighbourhood. And the crucial part — nobody labelled any of this. No human told the model that coffee and tea are related. It came out of arithmetic over fifteen hundred numbers.
This drawing would have two dimensions. Real embeddings have fifteen hundred and thirty-six. You cannot picture that, and you do not need to — the arithmetic of distance works identically no matter how many dimensions there are.
The vector database stores both
| ID | Vector | Original text | Source |
|---|---|---|---|
| 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 |
Purpose built: Pinecone, Weaviate, Chroma, Qdrant.
FAISS is a library rather than a service. Or add pgvector to a Postgres you already run.
Last box in the ingestion row: the vector database.
A vector database stores embeddings and finds the closest ones to a given vector, fast. That is its specialty — not storing things, but searching them by proximity across a huge number of dimensions.
But here is the detail people miss, and it is the reason I put this table up. Crucially, it stores the original text alongside each vector. Look at the columns: an ID, the vector, the original text, and the source file. One row per chunk.
The vector is how it gets found. The original text is what gets used. If you store only the numbers, you have a beautiful search index and absolutely nothing to send to a language model — because you cannot turn a vector back into English. It is a one-way trip.
The fourth column, source, is metadata. That is what lets you tell a user "this answer came from policy dot pdf, page four". We will watch that column survive all the way through the pipeline.
For options: Pinecone, Weaviate, Chroma, and Qdrant are purpose-built vector databases. We are using Chroma today because it runs locally, on disk, with no account and no server. FAISS is a library from Meta rather than a hosted service. And if you already run Postgres, the pgvector extension turns it into a vector database, which is often the right answer in a real company.
Retrieval, in principle
Ingestion is finished. Nothing above runs again
until your documents change.
Part three. Retrieval, in principle.
We have walked the whole top row of the diagram now — documents, chunks, embedding model, vectors, database. Ingestion is finished. And I want to stress that word finished, because it is genuinely done: nothing in that top row runs again until your documents change.
Now someone asks a question. This is the bottom row.
The question takes the same road
- The question goes through the same embedding model the documents went through.
- It comes out as a vector of the same length.
- The retriever compares that one vector against every stored vector.
- It ranks them by closeness and returns the top k.
Here is the whole retrieval pipeline in four steps.
Step one. The question goes through the same embedding model the documents went through. Same model. I am going to keep repeating that until it is annoying, because it is the thing that breaks.
Step two. It comes out as a vector of the same length — fifteen hundred and thirty-six numbers, exactly like every chunk in the database.
Step three. The retriever compares that one vector against every stored vector. Conceptually every one; in practice the database uses an index to avoid a brute-force scan, but the effect is the same.
Step four. It ranks them by closeness and returns the top k.
Look at the example. The question is "what were our sales in the first quarter". It becomes a vector. And then every chunk gets a score. Q1 revenue reached four point two million: zero point nine one. Quarterly sales by region: zero point eight eight. Revenue targets for the first quarter: zero point eight four. Those three go to the model.
And then look at what did not make it. Annual headcount summary at zero point four two — related to business, not to the question. Refunds at zero point three one. Guest wifi at zero point one nine, which is about as unrelated as it gets.
Notice something about that top result. The question says "sales". The winning chunk says "revenue". Those are different words. A keyword search for "sales" would have missed it entirely. That is what embeddings buy you — matching on meaning rather than on spelling.
How closeness is actually calculated is a later topic. For now: it is distance between two points, measured with arithmetic.
Build the ingestion pipeline
Five articles in. A searchable vector database out.
About sixty lines of Python and one API key.
Part four. Now we build it.
Everything up to here has been the top half of the diagram explained. Now it is the top half of the diagram in code.
Five Wikipedia articles go in. A searchable vector database comes out. About sixty lines of Python and one API key. Budget sixty to ninety minutes if you are typing along, and well under ten cents in embedding costs.
By the end of this you will have a folder on disk holding the vector representation of every paragraph in your documents.
One secret, in one place
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxx
Add
.env to .gitignore. Do it before your
first commit, not after. A leaked key gets scraped from public repositories
within minutes.
The API key. Go to the OpenAI platform, open Settings, then API keys, and create one. Call it something you will recognise later. Copy it immediately — you cannot view it again.
It goes in a file called dot env in the project root, as OPENAI underscore API underscore KEY equals your key. That name has to be exact, because that is the variable the OpenAI client looks for.
Two things to do right now, and I mean now rather than later.
First, add credit. The API is prepaid, and it is completely separate from any ChatGPT subscription you might have. Paying for ChatGPT Plus does not give you API credit — that catches a lot of people. The minimum top-up is around five US dollars. Embedding these five documents costs well under one cent, so that balance will last you months of learning.
Second, add dot env to your gitignore. Do it before your first commit, not after. A leaked API key gets scraped off a public repository within minutes — there are bots doing nothing else. And once it is in your git history, removing it from the working tree does not remove it from the history.
In this repo, dot env is already gitignored and there is a dot env dot example to copy.
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
Step one. Load the files.
DirectoryLoader takes three things: a path, a glob, and a loader class. The glob is star dot txt, which means only text files — everything else in that folder is ignored. The loader class is TextLoader, which is how each matched file actually gets read.
Notice the two guard clauses, and notice they are doing different jobs. The first one checks the directory exists. The second checks we actually loaded something. Without that second check, pointing at an empty folder gives you a pipeline that runs happily all the way through, embeds nothing, stores nothing, and reports success. Fail loudly and early rather than silently loading nothing — that principle will save you more debugging time in RAG than almost anything else, because so much of this stack fails quietly.
Let me run it.
[RUN DEMO 04]
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)
chunk_size=800 means 800 characters, not 800 tokens.
Roughly 200 tokens. Different splitters count differently — always check which unit you are in.
Step two. Chunk them.
Five documents of sixty to ninety thousand characters each are far too large to be useful units of retrieval. Nobody wants an entire Wikipedia article returned because one sentence in it matched.
Three arguments. Chunk size, chunk overlap — which we will come to in a second, and which is set to zero here deliberately so you can see what goes wrong — and separator, which is set to a blank line so we prefer to break at paragraph boundaries.
Now, the thing I want to flag hardest on this slide. Chunk size equals eight hundred means eight hundred characters. Not eight hundred tokens. Roughly two hundred tokens. Different splitters in different libraries count in different units, and getting this wrong by a factor of four is a very easy mistake to make. Always check which unit you are in.
Let me run it.
[RUN DEMO 05]
What chunk_overlap actually does
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.
This is the part of demo five I actually care about, and it is shown on a short passage with a small chunk size so the whole thing fits on one screen. The mechanism is identical at eight hundred characters.
Top block, overlap zero. Read the seam between chunk zero and chunk one. Chunk zero ends with "its name is a tribute to the". Chunk one begins with "inventor Nikola Tesla". The sentence is cut clean in half.
Think about what that costs you. Neither half carries the whole fact. If someone asks "who is Tesla named after", chunk zero has the question's subject but not the answer, and chunk one has the answer but has lost the subject. Neither one is a good match. The fact is in your database and it is unreachable.
Bottom block, overlap forty. Now chunk one begins by repeating the tail of chunk zero — "Motors, its name is a tribute to the" — and then continues into "inventor Nikola Tesla". The sentence on the seam survives intact inside one chunk. It is retrievable again.
That is all overlap does. Each chunk repeats the last n characters of the one before it.
Rule of thumb: set overlap to roughly ten to twenty percent of chunk size. For eight hundred characters, one hundred is a reasonable default. You pay for that duplication in storage and in embedding cost — the same text gets embedded twice — and it is almost always worth it.
So we change the default to a hundred, and re-run.
Try it: watch the chunks re-cut
This is the same two numbers you just saw in code, except now you can move them and watch what they cost you.
The document is the opening of the Tesla article — the same text demo five used.
[DRAG chunk_size DOWN TO ~150] Small chunks. Look at the count climbing and the average dropping. These are precise — each one is about a single thing — but read them. They have lost their surroundings. A chunk that says "in 2008 he was named chief executive officer" does not say who "he" is. Retrieve that on its own and it is useless.
[DRAG chunk_size UP TO ~1200] Now large chunks. Only a couple of them. Each one carries plenty of context — but now a question about the Roadster matches a chunk that is mostly about something else, and you are sending three times as many tokens to answer it. The match gets diluted.
[DRAG chunk_overlap UP TO ~100] Now watch the highlight. That green text at the start of each chunk is repeated from the one before it. Look at the "duplicated" figure climbing — that is text you are storing twice and paying to embed twice.
[SET overlap TO 0, THEN BACK TO 100] And that is the trade. At zero you pay nothing extra and sentences on the seam get cut in half. At a hundred you pay maybe fifteen percent more and they survive.
There is no correct answer on this slide. There is a shape of answer: chunks big enough to stand alone, small enough to be about one thing, with enough overlap that the seams do not eat your sentences.
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
Step three. Embed and store.
These are two conceptual steps — two separate boxes in the diagram — and one line of code. Chroma dot from underscore documents embeds every chunk and writes the results to disk in a single call. It is doing a lot more than it looks like it is doing.
This is also the one step in the whole pipeline that costs money and takes real time. Every chunk is a call to OpenAI. For five hundred and forty-seven chunks, expect thirty seconds to a couple of minutes.
One thing about the demo script as opposed to the lesson file: my demo caches the embeddings on disk, keyed by the text and the model. The vectors are completely real — it is calling the actual API — but if I run this demo a second time while rehearsing, it does not pay twice. The finished ingestion pipeline dot py in the repo has no caching; it is the plain version from the lesson.
Let me run it.
[RUN DEMO 06]
The three arguments that matter
| Argument | Why 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. |
Three arguments in that call matter, and each one has a failure mode attached to it.
Embedding is the model. We are using text-embedding-3-small, which returns fifteen hundred and thirty-six dimensions. Write that choice down. I am serious about that — write it in a comment, put it in your README. Next lesson you must embed the user's question with exactly the same model, or retrieval returns nonsense with no error message. That is the whole finale of today.
Persist directory is where the database lives on disk. Leave it out and Chroma runs in memory only, which means everything disappears when the script ends and you pay to embed all over again. It is a quietly expensive mistake — the code looks like it worked, it printed success, and there is simply nothing on disk.
Collection metadata, hnsw colon space, cosine. That sets the distance measure used to compare vectors. Cosine similarity is the standard choice for text, because it measures the angle between two vectors rather than their magnitude — which means a long document and a short one about the same topic still score as similar. Set it and move on; how it works is a later topic.
HNSW, by the way, stands for hierarchical navigable small world, which is the indexing algorithm. That is the thing making the search fast.
Ask it something
All five from spacex.txt, out of a store containing four other
companies. Your ingestion pipeline is finished and correct.
Let's confirm it worked. This is the top of the next lesson really, but we need to prove the store is queryable.
[RUN DEMO 07]
The question is "who founded SpaceX". The collection has five hundred and forty-seven vectors in it, covering five different companies.
And every single result comes from spacex dot txt. Not one Tesla chunk, not one Microsoft chunk, despite Elon Musk appearing prominently in the Tesla article — which is exactly the kind of near-miss that would trip a keyword search.
Look at the top hit: "In early 2002, Elon Musk started to look for staff for his company, soon to be named SpaceX." That answers the question. Score zero point six six two.
If your results come back from the right file, your ingestion pipeline is finished and correct. That is the test.
One thing to note about the scores. Zero point six six is the top match, and that might feel low if you were expecting something near one. It is not low. Cosine similarity between a short question and a long paragraph rarely goes above zero point seven, because they are different shapes of text. What matters is the gap between the top results and everything else, not the absolute number. Do not go hunting for a universal threshold — calibrate against your own corpus.
This is what reaches the model
Sent: the question, plus the original English text. Not sent: the vectors. Their job finished the moment the matching was done.
And here it is. The actual prompt. Every character of it, printed by the demo.
I promised earlier we would look at this, because it is the step everyone misreads. Read what is on that screen. An instruction: "answer the question using only the context below." Then the word CONTEXT, and then three paragraphs of completely ordinary English lifted straight out of the Wikipedia article. Then the word QUESTION, and the user's question.
That is it. That is the whole thing. Just under three thousand characters.
There is nothing numeric anywhere in it. No vectors, no embeddings, no similarity scores. The model has no idea a vector database was involved. As far as it is concerned, somebody pasted three paragraphs and asked a question.
Vectors are only used for finding. Their job finished the moment the matching was done, and then we went back to plain English.
I think this is the single most clarifying thing in the whole lesson. All the machinery — the chunking, the embedding, the fifteen hundred dimensions, the HNSW index — exists to produce those three paragraphs. The clever part is the search. The prompt at the end is boring, and it is supposed to be.
The mistake that breaks
most first builds
Nothing crashes. There is no error message.
Part five. And this is the one I most want you to leave with.
The mistake that breaks most first RAG builds. I have been foreshadowing it all session — the two orange boxes in the diagram, "write that choice down", "the same model, every time".
Here is what makes it dangerous. Nothing crashes. There is no error message. Let me show you the rule, and then we will break it on purpose.
The consistency rule
Documents embedded in January
text-embedding-3-large
3,072 dimensions
Queries embedded in March
text-embedding-3-small
1,536 dimensions
The consistency rule. Use the same embedding model and the same dimension count for your documents and for your queries. Every time. No exceptions.
The scenario that produces the failure is completely ordinary. You embed your documents in January with one model. Two months later you come back, you are writing the query side, and you reach for a model — maybe a cheaper one, maybe you just do not remember which you used. Different model. Now your documents and your questions are in two different systems.
The way to hold this in your head: think of embedding models as separate languages. A vector written by one model means nothing to another. The numbers are the same shape, they are in the same range, they look completely normal — and they encode meaning in a totally different arrangement.
The two systems cannot understand each other. And neither one will tell you.
Let's break it and watch.
Same store. Same question. Nothing crashes.
Scores collapse from 0.66 to 0.06, and a Microsoft chunk is returned for a question about SpaceX.
Demo eight. This builds a store the right way, then queries it the wrong way.
One detail so you know this is a fair test. The wrong model is text-embedding-3-large, but I have asked it for fifteen hundred and thirty-six dimensions instead of its default three thousand. So the vector is exactly the same shape as the ones in the database. Chroma has no dimension mismatch to complain about. It has no way whatsoever to know it is being handed a different language.
[RUN DEMO 08]
Top block, the right model. Zero point six six, zero point six three, zero point six three. All from spacex dot txt. That is our known-good result from a few minutes ago.
Bottom block. Same database. Same question. Same number of results. The only thing that changed is which model embedded the question.
Look at the scores. Zero point zero six four. Zero point zero five five. Zero point zero five two. They have collapsed by a factor of ten — the retriever is finding nothing it considers a good match, because every stored vector is effectively noise to it now.
And look at the third result. Microsoft dot txt. "Microsoft became the third publicly traded U.S. company..." — returned as a top-three answer to "who founded SpaceX".
Now notice what did not happen. Nothing crashed. No exception. No warning. No log line. The retriever returned exactly three results, ranked, formatted identically to the correct run. If you were not printing the source filenames — and most people do not, in a first build — this looks completely healthy.
And then an LLM takes those three chunks and writes a fluent, confident, sourced-looking, wrong answer.
That is why this is the mistake that breaks most first builds. Not because it is subtle to fix, but because it is invisible until someone checks an answer by hand.
Try it: flip the model, break the system
And finally, the failure, as a switch you can flip.
The documents on the left were embedded once, correctly, with text-embedding-3-small. The only thing this toggle changes is which model embeds the question.
[CLICK 'SAME model'] Same model. Top score zero point five five, and the right answer — Q1 revenue reached four point two million — is clearly first. Healthy.
[CLICK 'DIFFERENT model'] Different model. Same store, same question, same six facts.
Top score, zero point zero two six. The signal has collapsed by a factor of twenty. The ordering is now essentially arbitrary — on six facts it happens to keep the right one near the top, which is exactly why this is so dangerous on a small test set. In demo eight, on five hundred and forty-seven real chunks, this same flip returned a Microsoft paragraph for a question about SpaceX.
And now look at the bottom-right number, which is the whole point of this slide.
Errors raised: zero.
Not one exception. Not one warning. The retriever returned six results, ranked, formatted identically. Every piece of monitoring you have says the system is fine.
[FLIP BACK AND FORTH A FEW TIMES] This is what a silent failure looks like. The difference between a working RAG system and a broken one, from the outside, is a number getting smaller. Nothing else changes.
That is why the rule is absolute. One embedding model, one dimension count, everywhere.
What this means in practice
- Choose your embedding model before you ingest a single document.
- Choose your dimension count at the same time, and write it down.
- If you switch model later, you re-embed the entire corpus. There is no partial migration.
- Changing dimensions within the same model breaks it too.
So what does that mean in practice? Four rules.
One. Choose your embedding model before you ingest a single document. This is an architectural decision, not an implementation detail you get to defer.
Two. Choose your dimension count at the same time, and write it down. In the README, in a config file, in a comment at the top of the script. Somewhere your future self will actually look.
Three. If you switch model later, you re-embed the entire corpus. There is no partial migration. You cannot have half your documents in the new model and half in the old, because they cannot be compared. For a large corpus that is a real cost, which is why rule one matters.
Four, and this one catches people who think they are being careful: changing dimensions within the same model breaks it too. Text-embedding-3-large at three thousand and text-embedding-3-large at fifteen hundred are not compatible with each other. Same model, different language.
And then a suggestion that is not in the lesson but which I would put in any real build. Store the model name and the dimension count in the collection metadata when you create the store, and assert on them when you query. That is about ten lines of code, and it converts this entire category of silent failure into a loud one that fails on the first query. Given what we just watched, that is an extremely good trade.
The whole session in ten lines
- Models have a token limit. Real document stores are millions of times larger.
- So you retrieve the relevant pieces instead of sending everything.
- A RAG system is two pipelines. Ingestion runs once; retrieval runs per question.
- Ingestion: cut documents into chunks, embed each chunk, store vectors with their text.
- An embedding is a fixed-length list of numbers where similar meaning gives similar numbers.
- A vector database stores those numbers and searches them fast.
- Retrieval: embed the question the same way, rank by closeness, take the top k.
- Send the model the question plus the original text. Never the vectors.
- Set
chunk_overlapso sentences on the seam survive. Setpersist_directoryor you lose the work. - One embedding model, one dimension count, everywhere. Breaking this fails silently.
The whole session in ten lines. This is your revision card.
Models have a token limit, and real document stores are millions of times larger. So you retrieve the relevant pieces instead of sending everything.
A RAG system is two pipelines. Ingestion runs once. Retrieval runs per question.
Ingestion: cut documents into chunks, embed each chunk, store the vectors with their text.
An embedding is a fixed-length list of numbers where similar meaning gives similar numbers.
A vector database stores those numbers and searches them fast.
Retrieval: embed the question the same way, rank stored vectors by closeness, take the top k.
Send the model the question plus the original text of those chunks. Never the vectors.
Set chunk overlap so sentences on the seam survive, and set persist directory or you lose the work.
And the last one, which is the one that will actually cost you an afternoon: 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 LOCKED IN · text-embedding-3-small at 1,536 dimensions
BRING · a set of documents you actually want to ask questions about
That is the ingestion pipeline. You have a folder on disk holding the vector representation of every paragraph in five documents — five hundred and forty-seven of them, searchable, each one carrying the file it came from.
And you have locked in a decision: text-embedding-3-small at fifteen hundred and thirty-six dimensions. That choice now applies to the whole project.
Next lesson is the retrieval pipeline — the bottom row of the diagram, properly this time. You take a question, embed it with that same model, pull the closest chunks out of db_chroma, build a prompt, and hand it to an LLM to answer. We got a preview of it today in demo seven; next time we build it properly, including what to do when the retriever comes back with nothing good.
If you are working along, the best thing you can do before then is swap my five Wikipedia articles for a set of documents you actually want to ask questions about. Everything we built today works unchanged — point DOCS_PATH at your folder, delete db_chroma, and re-run. The pipeline does not care what the documents are.
Thanks for working through it.