How to give a language model access to a hundred thousand documents
without ever putting a hundred thousand documents in the prompt.
WilA decade in AI · incentivised data training with NEAR AI
· retrieval and ingestion systems in production
Follow along atragverse.diy
— these slides, five playgrounds you can drive yourself, and all the code
RETRIEVAL LESSON · the theory, then 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
00 · Outcomes
What you will be able to do
Explain why a bigger context window does not remove the need for RAG.
Define token, context window, chunk, embedding,
dimension, vector database, retriever and top k.
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.
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.
This is not a niche technique
Almost every language-model product you have used is doing this underneath — a model
that searches the web, an assistant that answers from your own files, a support bot
that cites 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 else in this session is mechanics.
01 · Why anyone builds one
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.
The question everyone asks: why not fine-tune?
Fine-tuning teaches a model a style — a format, a voice, a way of
reasoning. It is an expensive way to teach it a fact, and a hopeless way to
teach it a fact that changes on Tuesday. Retrieval gives a model facts it can point at.
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.
Counted with the cl100k_base tokenizer
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
As of 2026 the major models have converged on 1 million tokens — GPT-5.5,
Gemini 3.1 Pro, Claude Opus 4.8 and Sonnet 5 all sit there. A few advertise up to
10 million, though no published benchmark shows quality holding near that.
Verify before you quote it. These figures move 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,169
A frontier model window ███████████ 1,000,000
The largest advertised ██████████████ 10,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 explorerdrag the slider
0
tokens
0
chunks @800
$0
to embed once
0
of a 1M 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.
Measured with text-embedding-3-small
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.
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.
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.
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.
05 · Eight real words
Does that actually hold?
Real embeddings · cosine similarity · text-embedding-3-small
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-smallclick 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
Model
Default dimensions
Notes
text-embedding-3-small
1,536
Fine for most projects. Cheap. We use this one.
text-embedding-3-large
3,072
Best quality of the two. Costs more.
voyage-3-large
1,024
Strong on technical and code text.
Cohere embed
1,024
Good multilingual support.
Mistral embed
1,024
Open 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
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
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
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.
— worked example, not a program run —
THE QUESTION "What were our sales in the first quarter?"
↓
[-0.398, 0.671, 1.194, ...] same model, same dimensions0.91 Q1 revenue reached 4.2 million. ┐0.88 Quarterly sales by region, Jan to Mar. ├ top 3 to the LLM0.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
What actually happens to your documents
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.
09 · 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.
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.
11 · Step one: reading the documents
A document is its text, plus where it came from
Loaded 5 documents
google.txt 68,012 characters
microsoft.txt 67,411 characters
nvidia.txt 62,402 characters
spacex.txt 55,954 characters
tesla.txt 93,916 characters
.page_contentthe text 'Google LLC ( , GOO-gəl) is an American multinational...'
.metadataits source{'source': 'docs/google.txt'}docs/google.txt
Learn these two attributes now.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 bothEvery 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.
12 · Two gotchas
Two things worth knowing
Why sorted() is not decorationDirectoryLoader walked the directory in whatever order the filesystem
returned — so index 0 changed between machines, and chunk order changed with it.
Sorting makes the run reproducible. Either way,
never write code that assumes index 0 is a particular file; filter on
the metadata.
Text is the easy case
PDFs, spreadsheets and HTML need a real parser, and that is worth a dependency:
langchain-unstructured, or pypdf directly. What comes out
is the same thing — Document objects — so everything downstream is unchanged.
13 · Step two: cutting them up
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
WhyCharacterTextSplitter 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.
13 · Overlap
What overlap actually does
chunk_overlap = 0overlap = 0 characters
[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 = 40overlap = 40 characters
[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.
14 · Interactive
Try it: watch the chunks re-cut
Chunking playgroundoverlap is highlighted in each chunk
0
chunks
0
avg chars
0%
duplicated
0
est. tokens
15 · Step three: turning chunks into vectors
Every chunk becomes numbers, text and a source
Stored 547 vectors in db_chroma/
the collection now holds 547 vectors
the vector 1,536 numbers [+0.0223, -0.0135, -0.0015, ...]
the original text 'Google LLC ( , GOO-gəl) is an American multinational...'
the metadata {'source': 'docs/google.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.
16 · Retrieval, for real
Ask it who founded SpaceX
Searched across 547 chunks of five real articles
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.
17 · 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.
17 · Interactive
Try it: ask, and watch the prompt build
Retrieval playgroundpick a question
0
prompt tokens
0
document tokens
0%
sent to model
0.00
top score
The four preset questions were embedded ahead of time with
text-embedding-3-small, so those scores are real. A question you
type cannot be embedded in a browser — that needs an API call — so it falls back to a rough
local measure, and the panel says which one you are looking at.
17 · The re-run trap
Run it twice, pay twice
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.
18 · 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.
18 · Break it on purpose
Same store. Same question. Nothing crashes.
The same 547 chunks · only the query model changed
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.
18 · Interactive
Try it: flip the model, break the system
Same store · same question · different query modelreal precomputed scores
0.00
top score
—
model used for the question
0
errors raised
18 · In practice
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.
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.
19 · When it does not run
When it does not run
Error
Cause 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.
19 · Check yourself
Check yourself
Why does a 2 million token context window not remove the need for RAG? Two reasons.
You have 4 million tokens of documents and a chunk size of 500. How many chunks, and how many vectors?
A retriever returns 10 chunks and 4 are irrelevant. Is the system broken?
What exactly is in the prompt that reaches the LLM?
Which attribute of a Document survives chunking?
chunk_size=800 counts what unit?
You leave out persist_directory. What happens when the script ends?
You embedded documents with model A and queries with model B. What is the symptom?
19 · Summary
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_overlap so sentences on the seam survive. Set persist_directory or 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.
That is Retrieval Augmented Generation
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.
THE RESULT · 547 vectors on disk, searchable, with sources 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