RAG, the fast way.
The spine plus the material that makes it stick — the gotchas, the API key, the practical rules — at the same tight pace as the essentials.
Also available as the essentials or the full lesson.
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.
Follow along at ragverse.diy — these slides, five playgrounds you can drive yourself, and all the code
YOU NEED · Python 3.10+, an editor, one OpenAI API key
COST · Well under a cent of embeddings
EIGHT DEMOS · one per concept, each one runnable
Quick word on who is talking. I am Wil.
I have been working in AI for about a decade — which means I started well before GPTs made any of this fashionable, back when you built your own models and your own data pipelines.
More recently I have been working on incentivised data training structures with NEAR AI. And running through all of it, I have built a lot of retrieval and ingestion systems, on a lot of different stacks.
I mention that for one reason. This is not a lesson I read and repeated. It is a system I have built more than once, and every mistake in it is one I have made.
Here is the problem in one line. How do you give a language model access to a hundred thousand documents, without ever putting a hundred thousand documents in the prompt?
That is the problem. Everything after it is mechanics.
So here is where we are going. Why RAG exists, and why waiting for a bigger model will not fix it. The shape of the system — two pipelines, one diagram. What an embedding really is, tested against a real model.
Then five real documents pushed all the way through, so you see what each step produces. And at the end, the mistake that breaks most first builds — the one that raises no error at all.
One thing before we start. Everything is at ragverse dot d i y — these slides, five playgrounds you can drive yourself, and all the code. Open it in another tab now if you want to follow along.
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.
One word on how this runs. Wherever a terminal block appears on a slide, the line above it names the command that printed it.
Behind them is a repository: eight numbered scripts, one per concept, and a finished sixty-line pipeline, all behind a single entry point. Whether the demos get run here or you run them afterwards, the output is the same. The address is on the last slide.
The scripts stand alone, except that seven and eight need the database the sixth one builds.
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.
And it is worth framing this carefully, because people get it backwards.
The problem is not that language models are stupid. Modern models are extraordinarily capable.
The problem is that they are small. Not in intelligence — in how much they can look at in one go.
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 a few hundred internal documents. Policies, specs, support logs, contracts. Ordinary things, none of them written for a machine to read.
Nobody has read all of them. Whoever wrote any one of them has probably left. And the answer to a question somebody asks this afternoon is sitting in exactly one of them, and no one knows which.
That is the situation this is built for. Hold onto it — the next slide is the whole idea in a sentence.
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.
Picture this. You work somewhere with a few hundred internal documents, and somebody asks a question that exactly one of them answers.
The obvious move is to paste them all in and ask. That does not work — and why it does not work is the whole lesson.
So here is the definition. Retrieval Augmented Generation is a language model plus a retrieval system.
The retrieval system searches your documents 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.
And I want to plant something early here, because people hear "RAG" and file it away as one technique among many. It is not.
Almost every language-model product you have used is doing this underneath. A model that searches the web and cites what it found. An assistant that answers from your own files. A support bot that links the article it used.
Retrieval is the default way a model is given anything it was not trained on.
That is the entire idea. Everything after this is mechanics.
Where you have met one — and why
Where you have met it
- The support assistant that answers and links the help article.
- A model that searches the web and cites what it found.
- A coding assistant that answers about your repository.
- Internal Q&A over policy, security questionnaires, runbooks.
Why teams build it
- Your data. No model was trained on your contracts or your tickets.
- Today's data. A policy changed this morning. Index it; done.
- Receipts. The retriever knows which chunks it used, so the answer can point at them.
Let me make that concrete, and then answer the obvious question.
You have met these. The support chatbot that answered you and then linked the exact help article. A coding assistant that answers about your repository rather than a generic one. Internal question-and-answer over a company's own policies.
So why do teams build them? Three reasons.
The first is your data. Nothing was trained on your contracts or your support tickets. Ask anyway, and a model will cheerfully invent something plausible.
The second is today's data. A policy changed this morning. You are not going to retrain a model over that.
And the third is receipts. The retriever knows exactly which chunks it used, so the answer can point at them. An answer nobody can check is worth close to nothing.
Which answers the question I get every single time. Why not just fine-tune?
Fine-tuning teaches a model a style — a format, a voice. It is an expensive way to teach it a fact, a hopeless way to teach it one that changes on Tuesday, and it gives you nothing to cite.
Fine-tuning changes how a model speaks. Retrieval changes what it knows.
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.
The first word you need is token.
A token is the unit of text a model actually processes. Sometimes that is a whole word. Often it is a fragment of one.
Models do not read letters. They do not read words. They read tokens.
And that matters for a very practical reason. Every limit you will ever hit, and every price you will ever pay, is counted in tokens.
Look at the output. Five words went in. Seven tokens came back.
"Retrieval" split into three pieces. "Augmented" and "powerful" survived whole. Common words are one token; rarer ones fragment.
So here is the rule of thumb to carry: one token is about three quarters of an English word.
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.
Verify before you quote it. These figures move every few months.
Second word: the context window.
That is the total number of tokens a model can hold in one request. Everything you send, and everything it writes back, sharing one budget.
Past that limit, the information is simply not there.
In 2026 the major models have converged on one million tokens. A few advertise ten million — though no benchmark shows quality holding near the top of those windows.
A million sounds like plenty. So let us see how it compares to what a company actually holds.
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.
Read this one carefully, because it is a logarithmic scale. Each step to the right is ten times the last — not one step.
Our five articles come to seventy-two thousand tokens. That fits inside a frontier model easily.
And I will say the honest thing here: for five documents you genuinely do not need RAG. I would rather tell you that than pretend otherwise.
Now watch what happens. A mid-sized company with a terabyte of documents: two hundred and fifty billion tokens. That is two hundred and fifty thousand times a model window. An enterprise archive is a thousand times larger again.
This is not a gap you close by waiting for bigger models.
And there is a second reason, which matters just as much. You pay per token — and burying the one relevant paragraph in half a million irrelevant ones gives you worse answers, not better.
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 people forget it because the first one is so dramatic.
Even when everything does fit, sending all of it is still wrong. You pay per token, so you pay for every irrelevant word. Latency scales with what you send. And — this is the counterintuitive one — you get worse answers.
Bury the one relevant paragraph inside five hundred thousand tokens and the model has a harder job than if you had handed it the paragraph. More context is not more helpful. Relevant context is.
The shape of the system
Two pipelines, not one.
Learn them separately and the rest is easy.
Part two. The shape of the system.
Everything so far has been about why. This is the what — and it is one diagram.
Two pipelines
This is the slide to photograph.
A RAG system is two pipelines, not one. People collapse them into one in their heads, and that is exactly where the mental model breaks.
The top row is ingestion, and it runs once — before anybody asks anything. Documents go in, they get chunked into small pieces, each piece goes through an embedding model and becomes a vector, and the vectors land in a database. Then it stops.
The bottom row is retrieval, and it runs on every single question. The question becomes a vector. A retriever ranks every stored vector by closeness. The top matches come back. And those chunks, plus the question, go to the model.
Now look at the two orange boxes. The same embedding model appears in both rows.
That is not a drawing shortcut. We are going to break it deliberately at the end to show you why it matters.
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.
Next word: the chunk.
Chunking is cutting documents into small pieces, and you choose the size.
Here is the nuance that matters. A chunk is a slice, not a section. Chunks are cut by size, not by meaning. They do not respect headings or arguments — a naive splitter will cut through the middle of a sentence without hesitating.
So why cut at all? Because the chunk is your unit of retrieval — the smallest thing the system can hand back.
Make a whole document one chunk, and a question about one line drags the entire forty-page employee handbook into the prompt, because one sentence in it matched.
And now you are back to stuffing the context window — which is the thing you were trying to avoid.
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.
Why it is always the same length
The model reads the text once and produces one vector per token, then pools them into a single vector. Pooling is what throws the length away.
Three properties worth knowing
- Unit length. Every vector comes back normalised, so cosine similarity is just a dot product.
- 8,192 token cap. Longer input is truncated silently — another reason to chunk.
- The dimensions are ordered. Keep the first 512 and renormalise and it still works — a third of the storage.
Next box: the embedding model. And here is the thing that trips everybody up.
This is not an LLM. Different model, different job. Nothing is generated. There is no vocabulary to sample from. It is far smaller and far cheaper. Text goes in, a list of numbers comes out. That is the whole interface.
Look at the three rows. One word in. Eight words in. Three hundred and thirty-seven words in. And every single time, exactly fifteen hundred and thirty-six numbers come back.
So why is it always the same length? Because of what the model actually does. It reads the text and produces one vector per token, the way any transformer does. Then it pools them — averages them, roughly — into a single vector. Pooling is the step that throws the length away.
And that fixed length is the property the whole system rests on. 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 arithmetic.
Three things about those numbers that are worth carrying.
They come back unit length. I checked — the norm is one, to four decimal places. Which means cosine similarity and a plain dot product give you the same answer. Cosine is not a preference here. It is the cheapest thing that works.
There is a ceiling — about eight thousand tokens. Send more than that and the tail is silently truncated. No error, no warning. Another reason to chunk.
And the dimensions are ordered by importance. Keep the first five hundred and twelve, renormalise, and the rankings still hold — a third of the storage. That is also how we are going to fake a convincing failure later on.
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.
Nothing else about embeddings matters as much as that sentence.
| Word | Dim 1 · size | Dim 2 · domesticated | Dim 3 · sound |
|---|---|---|---|
| cat | 34 | 8 | 7.5 |
| kitten | 33 | 8 | 7.1 |
| dog | 40 | 8 | 6.9 |
| elephant | 210 | 62 | 1.2 |
Real dimensions are not labelled and nobody knows what any single one means. These labels are a teaching device only.
So what is an embedding? A list of numbers standing in for a piece of text. Each number is called a dimension — that is two more words for your list.
And here is the sentence that matters more than any other today.
Text with similar meaning produces similar numbers.
That one sentence is why any of this works. Somebody types "my card got declined." The help article says "payment failures."
Not a single word in common. A keyword search returns nothing at all. An embedding puts those two right next to each other, because they mean the same thing.
That is what you are buying.
Now, this table is a teaching device, and I want to be straight with you: the numbers in it are invented. Cat and kitten sit almost on top of each other. Dog shares the domestication value but differs on size. Elephant is far from all three.
Real embeddings are not labelled, and nobody knows what any single dimension means. So let us check whether that intuition survives contact with a real model.
Does that actually hold?
Every group is tighter inside than out. That is a neighbourhood — and nobody labelled any of it.
Eight ordinary words, embedded with a real model, every pair measured. One point zero would mean identical.
Cat to dog: zero point six. Cat to kitten: zero point five seven. Then a clear drop to the fruit and the drinks.
Now — honestly, that contradicts the tidy table. The table said kitten should be closest.
The real model puts dog first, because cat and dog co-occur in text constantly, while "kitten" is a much narrower word. So do not over-read the ordering inside a cluster. And remember that real systems embed paragraphs, not single words.
But look at the bottom two lines, because that is where the claim holds. Every group is tighter inside than outside.
That is a neighbourhood — and nobody labelled any of it.
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 top row: the vector database. It stores embeddings, and it finds the closest ones fast.
But the detail people miss is right there in the table. It stores the original text alongside every vector.
The vector is how a chunk gets found. The original text is what actually gets used.
Store only the numbers, and you have a beautiful index and nothing to send a model — because you cannot turn a vector back into English.
That fourth column, the source, is metadata. And that is what lets you tell a user which file an answer came from.
If you are wondering about options: Pinecone, Weaviate, Chroma, Qdrant. We use Chroma here because it runs locally with no account. And if you already run Postgres, pgvector is often the right answer.
Retrieval, in principle
Ingestion is finished. Nothing above runs again
until your documents change.
Part three. Retrieval.
All of that was preparation. Nothing so far has actually answered a question.
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.
A question arrives, and it takes the same road the documents took.
It goes through the same embedding model — and I am going to keep repeating that — and it becomes a vector of the same length.
The retriever then scores every stored vector by closeness, and returns the top matches.
How many come back is called top k. That is the last word worth collecting, and you choose it.
But here is the half that people miss. Ask for five and you get five — whether or not any of them are any good.
A retriever has no concept of "nothing here fits." It always returns k results, neatly ranked.
And notice the winning chunk says "revenue" while the question says "sales." Different words entirely. A keyword search misses that. Matching on meaning is what embeddings buy you.
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.
So: a retriever that returns 10 chunks of which 4 are irrelevant is not broken. That is the expected shape of the output.
One more beat on top k, because this is the thing that catches people building their first system.
They ask something the documents genuinely do not answer, get five confident-looking chunks back, and conclude the retriever is broken. It is not. Ten chunks of which four are irrelevant is the expected shape of the output, not a bug. They are the least-bad of a bad set, and they look exactly like good ones.
What you do about it — filter on a score threshold, or re-rank — is a later topic. Knowing that it happens is not.
Build the ingestion pipeline
Five articles in. A searchable vector database out.
About sixty lines of Python and one API key.
Five articles go in. A searchable database comes out.
Three steps — and what each one actually produces.
Part four. That is the theory — now let us follow five real articles all the way through it.
Text in one end. A searchable database out the other.
Three steps. Read the documents in. Cut them up. Turn the pieces into numbers and file them away.
And at each step I am going to show you what actually comes out, because the numbers are more interesting than the diagram.
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. Create one on the OpenAI platform, copy it immediately — you cannot view it again — and put it in a file called dot env.
Two things that catch people out, and both are worth doing before you write a line.
Add credit. The API is prepaid and entirely separate from a ChatGPT subscription; paying for Plus gives you no API credit, which catches people constantly. Embedding these five documents costs well under a cent.
And add dot env to your gitignore before your first commit. A leaked key gets scraped off a public repository within minutes, and removing it from the working tree does not remove it from your history.
Load the files
def load_documents(docs_path):
# Fail loudly and early rather than silently loading nothing
folder = Path(docs_path)
if not folder.is_dir():
raise FileNotFoundError(f"Directory not found: {docs_path}")
documents = [
Document(
page_content=path.read_text(encoding="utf-8"),
metadata={"source": f"{docs_path}/{path.name}"},
)
for path in sorted(folder.glob("*.txt")) # sorted = reproducible
]
if len(documents) == 0:
raise ValueError(f"No .txt files found in {docs_path}")
return documents
Step one: load the files. And here I depart from most tutorials deliberately.
Every RAG tutorial uses DirectoryLoader from langchain-community. That package was archived in May twenty twenty-six. And look at what it actually did for text files: glob a pattern, read each file, wrap the text in a Document with the path as metadata. That is the whole job — so we write it directly, in six lines.
Same objects out, one fewer dead dependency, and nothing hidden behind the word "loader".
Note the two guard clauses. One checks the folder exists; the other checks we actually loaded something. Without the second, an empty folder gives you a pipeline that runs happily, embeds nothing, and reports success.
Five files in, five Documents out
.page_content is the entire text of the file as one long string.
.metadata is a dictionary — here, the file we read it from.
This object shows up everywhere from here on.
Two halves, and you need both
Every record is the text itself — the whole file as one
long run of characters — and where it came from. The second half is what
lets an answer cite its source.
Step one. Read the documents in.
Five files go in, and five records come out. And each record has two halves.
The first half is the text itself — the whole file, as one long run of characters. Sixty-eight thousand of them for Google. Ninety-four thousand for Tesla.
The second half is where it came from. Right now that is just the filename — and we put it there ourselves, which means you can put anything you like alongside it. Page numbers. Authors. Who is allowed to see it.
Remember that second half, because in a moment we are going to cut these five documents into hundreds of pieces — and every single piece will carry it.
That is how a system can tell you which file an answer came from, when the thing it actually retrieved was a four-hundred-character fragment.
So. Five documents. Ninety thousand characters each, give or take. Far too big to be useful. Let us cut them up.
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. Articles of sixty thousand characters are useless units of retrieval — nobody wants a whole article back because one sentence matched.
Three arguments: chunk size, chunk overlap, and a separator set to a blank line so we prefer paragraph boundaries.
And the thing to flag hardest: chunk size of eight hundred means eight hundred characters, not tokens. Roughly two hundred tokens. Different splitters count in different units, and getting that wrong by a factor of four is easy.
You will also see the chunks are not all eight hundred. The splitter cuts on the separator first, then merges up to the target — it never breaks a paragraph to hit the number. So it is a target, not a cap.
800 is a target, not a cap
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.
The splitter breaks on blank lines first, then glues the
pieces back together up towards your target. What it will never do is cut a paragraph in
half to hit the number — so a long paragraph gives you a long chunk.
Step two. Cut them up.
I have asked for chunks of eight hundred characters, and with overlap switched off for the moment — I will come back to that in a second.
Five documents in, five hundred and thirty-nine chunks out.
Now look at the spread, because this is where the mental model is usually wrong.
Smallest: fifteen characters. Largest: seventeen hundred. Average: six hundred and forty-three. We asked for eight hundred, and got almost nothing that is actually eight hundred.
Here is why. The splitter breaks on blank lines first, and then glues the pieces back up towards your target.
What it will never do is cut a paragraph in half to hit the number. So a long paragraph gives you a long chunk.
Chunk size is a target, not a cap.
Now — about that overlap setting I switched off. It is the one that decides whether this whole system actually works. Let me show you why.
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.
Here is the same splitter on a small piece of the Tesla article, blown up so you can read the seam.
Overlap zero — that is what produced the five hundred and thirty-nine chunks a moment ago. Look at the seam. A sentence is cut clean in half.
And here is the consequence. Neither half carries the whole fact, so neither one is a good match for a question about it. The fact is sitting in your database, and it is unreachable.
Now overlap forty. Chunk one begins by repeating the tail of chunk zero — so the sentence on the seam survives intact somewhere.
And that is all overlap does. Each chunk repeats the last n characters of the one before it.
Rule of thumb: ten to twenty percent of your chunk size. These example chunks are small, so forty characters is enough to show it. On the real run I use a hundred, against a chunk size of eight hundred.
And that is what takes us from five hundred and thirty-nine chunks to five hundred and forty-seven. Eight extra pieces, because the seams now repeat a little text. That is the whole price — a bit more storage, a few more embedding calls — and it is almost always worth paying.
Try it: watch the chunks re-cut
Same two numbers you just saw in code, except now you can move them.
Drag chunk size down and the count climbs while the average drops. These are precise, but read them — they have lost their surroundings. A chunk saying "in 2008 he was named chief executive" does not say who "he" is.
Drag it up and each chunk carries plenty of context, but a question about one detail now matches a chunk that is mostly about something else, and you send three times the tokens.
Then drag the overlap up and watch the highlight: that repeated text is what you are storing and paying to embed twice.
There is no correct answer here — only a shape. Big enough to stand alone, small enough to be about one thing.
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. Two conceptual boxes, one line of code — Chroma dot from underscore documents embeds every chunk and writes it to disk in a single call.
This is also the only step that costs money and takes time. Every chunk is an API call. Five hundred and forty-seven chunks, thirty seconds to a couple of minutes, a fraction of a cent.
Three arguments matter. The embedding model — write that choice down, because the next lesson must use exactly the same one. Persist directory, or Chroma runs in memory and everything vanishes when the script ends. And cosine as the distance measure, which is the standard for text.
Look at what is stored per chunk: the vector, the original text, and the metadata — the source surviving all the way from the loader.
One row per chunk
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.
Step three. Turn those five hundred and forty-seven chunks into numbers, and file them away.
And those two things happen together, in one pass — the embedding and the storing.
Five hundred and forty-seven vectors on disk.
Now look at what is kept for each one, because this is that vector database table from earlier — now real.
The vector: fifteen hundred and thirty-six numbers. The original text. And the source, still saying docs slash google dot txt.
There it is — the thing I asked you to remember two slides ago. It went in at the loader, came through the splitter untouched, and it is now sitting in the database attached to a chunk.
And the line to leave with: the original text is not optional. Without it you have numbers, and nothing to send a model.
That is the ingestion pipeline finished. Five articles went in; five hundred and forty-seven searchable vectors came out. So let us ask it something.
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 matter here, and each has a failure attached.
The embedding model. Write that choice down — the query side must use exactly the same one, and that is the finale of this lesson.
Persist directory. Leave it out and Chroma runs in memory: everything vanishes when the script ends, and you pay to embed again. The code looks like it worked, printed success, and there is simply nothing on disk.
And the distance measure, cosine, which is the standard for text because it compares the angle rather than the magnitude — so a long document and a short one about the same topic still score as similar.
Ask it something
All five from spacex.txt, out of a store containing four other
companies. Your ingestion pipeline is finished and correct.
The store has five hundred and forty-seven vectors covering five companies. And the question is "who founded SpaceX."
Every single result comes from spacex dot txt.
Not one Tesla chunk — despite Elon Musk appearing all over the Tesla article. That is exactly the near-miss that would trip a keyword search.
One note on the scores, because I know what you are thinking. Zero point six six is the top match, and that might feel low. It is not.
Cosine similarity between a short question and a long paragraph rarely exceeds zero point seven.
What matters is the gap between the top results and the rest — not the absolute number.
But the retrieved chunks are not the answer. They are what gets sent. So let us look at exactly what reaches the model.
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 is the prompt. Every character of it.
An instruction. The word CONTEXT. Three paragraphs of ordinary English lifted straight out of the article. And the question. Just under three thousand characters.
Now notice what is not in it. There is nothing numeric here. No vectors. No embeddings. No scores. The model has no idea a vector database was ever involved.
And this is the step everybody misreads. Vectors are only used for finding. Their job ends the instant the matching is done — and then you are back in plain English.
All of the machinery — the chunking, the embeddings, the fifteen hundred dimensions — exists to produce those three paragraphs.
The prompt at the end is boring. And it is meant to be.
So — that is a complete, working system, end to end. Documents in, a real answer out, with sources. Which makes this exactly the right moment to show you the one way it falls apart without telling you.
The mistake that breaks
most first builds
Nothing crashes. There is no error message.
Part five — and this is the one to leave with.
Everything else that goes wrong in this stack tells you.
This one does not.
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
Here is the rule. Use the same embedding model, and the same dimension count, for your documents and for your queries. Every time. No exceptions.
I have been foreshadowing this all the way through. The two orange boxes on the diagram — the same model in both rows. And when the question took the same road as the documents, I said I would keep repeating it. This is why.
And the way it goes wrong is completely ordinary. You embed your documents in January. Two months later you write the query side, and you reach for a model — maybe a cheaper one, maybe you just do not remember which one you used.
Think of embedding models as separate languages. A vector written by one means nothing at all to another.
And here is the sting. The numbers are the same shape. The same range. They look entirely normal. And neither system will tell you.
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.
So let us break it on purpose.
And let us make it a fair test. The wrong model is asked for the same fifteen hundred and thirty-six dimensions, so the vector is exactly the same shape. The database has nothing to complain about.
Top block, the right model: all three results from spacex dot txt.
Bottom block — same database, same question. Only the model that embedded the question changed.
The scores collapsed by a factor of ten. And the third result is from microsoft dot txt, returned for a question about SpaceX.
Now notice what did not happen.
Nothing crashed. No exception. No warning. No log line. Three results, ranked, formatted identically to the correct run.
Every piece of monitoring you have says the system is fine — and a model handed those chunks writes a confident, wrong answer.
And that is why this breaks first builds. Not because it is hard to fix — because it is invisible until somebody checks an answer by hand.
Try it: flip the model, break the system
And here is the failure as a switch you can flip.
The documents were embedded once, correctly. The only thing this toggle changes is which model embeds the question.
Same model: top score around zero point five five, and the right answer clearly first.
Different model: same store, same question, same six facts — and the top score collapses to zero point zero two six. 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. On five hundred chunks it returns a Microsoft paragraph for a question about SpaceX.
Now look at the errors-raised number. Zero. That is what a silent failure looks like.
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.
Choose your embedding model before you ingest a single document. This is architecture, not an implementation detail you defer.
Choose the dimension count at the same time and write it down somewhere your future self will look.
If you switch model later, you re-embed everything. There is no partial migration — you cannot have half your corpus in each, because they cannot be compared.
And this one catches the careful: changing dimensions within the same model breaks it too. Same model, different language.
One suggestion not in the lesson: store the model name and dimension count in the collection metadata and assert on it when you query. Ten lines that turn a silent failure into a loud one.
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.Overlap your chunks so sentences on the seam survive. Tell the database where to save, or you lose the work. - One embedding model, one dimension count, everywhere. Breaking this fails silently.
The whole lesson, on one slide. 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 on every question.
Ingestion: chunk, embed, and store the vectors with their text.
An embedding is a fixed-length list of numbers, where similar meaning gives similar numbers.
Retrieval: embed the question the same way, rank by closeness, and take the top k.
Send the model the question plus the original text — never the vectors.
Overlap your chunks so sentences on the seam survive. And tell the database where to save, or the work is gone when the process ends.
And the one that will actually cost you an afternoon: one embedding model, one dimension count, everywhere. Breaking that 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.
Two pipelines. Chunk, embed, store the text with the numbers. Ask with the same model, rank by closeness, send the paragraphs — never the vectors.
LOCKED IN · text-embedding-3-small at 1,536 dimensions
BRING · a set of documents you actually want to ask questions about
YOU CAN NOW · draw both pipelines and say what each box is for
YOU KNOW THE TRAP · one embedding model, one dimension count, everywhere
TO GO FURTHER · the playgrounds, the code, and the long version
ALL OF IT · ragverse.diy
So — that is Retrieval Augmented Generation.
Not a niche technique. The default way a language model is given anything it was not trained on — and now you know what is happening underneath every product that does it.
You can draw both pipelines from memory. You can say what every box is for. You can predict roughly what a retriever will hand back, and you know the one failure that will not announce itself. That is genuinely most of it.
If you want to go further, it is all at ragverse dot d i y — five playgrounds you can take apart in the browser, the code if you want to build it yourself, and a longer version if you want every aside.
And the most useful thing you can do with it is point it at documents you actually care about. Everything you have seen today works unchanged.
Thanks for watching.