Chunking documents
Split long documents so the right piece can be retrieved.
Create a free account Try part of it here
Why it matters
Picture a 40-page brand book, or a year of campaign reports. If you search it as one big blob, the search gets one blurry summary of every topic in it. It can't point to the page you need. Chunking fixes this. You cut each document into short passages before search ever sees it. Those cuts decide what your search can find. Good chunks make answers precise. Bad chunks cut facts in half. It's quiet, unglamorous work, and it's where most of the quality in a document-answering app is won.
What you'll be able to do
- Explain why long documents are split into chunks before embedding
- Split text into fixed-size chunks with overlap
- Split text along paragraphs, merging small ones
- Keep each chunk's source and position so answers can cite it
What it covers
- Why one embedding per document fails
- Three ways to cut
- How big?
- Keep track of where each chunk came from
- Worked example: paragraph chunks with metadata
Why one embedding per document fails
Think of a cookbook with only one line in its index: "Recipes, page 1." It's true, but it can't help you find the pancakes. A good index lists each recipe on its own line.
Search over documents works the same way. It uses embeddings. An embedding is a list of numbers that captures what a piece of text is about. Texts about similar things get similar lists. To search, you embed the question too. Then you compare it with each passage and pick the closest.
The practice shop has a brand voice guide. It covers headlines, pricing language, sustainability claims, customer-service tone and the audience. Embed the whole guide and you get one list of numbers that's a bit about everything. It matches many questions weakly and none strongly.
Split the guide into its paragraphs instead. Now each paragraph gets its own sharp embedding. The code below asks a question about discounts. It scores the whole guide, then each paragraph.
import numpy as np import requests API = "https://api.gradient.test/v1" guide = requests.get(f"{API}/docs/doc_voice").json()["text"] def embed(texts): return np.array([d["embedding"] for d in requests.post(f"{API}/embeddings", json={"input": texts}).json()["data"]]) paragraphs = [p for p in guide.split("\n\n") if p.strip()] query = embed(["how do we talk about discounts and sale end dates?"])[0] whole = float(embed([guide])[0] @ query) parts = embed(paragraphs) @ query print(f"whole guide: {whole:.2f}") for score, p in sorted(zip(parts, paragraphs), reverse=True)[:2]: print(f"{score:.2f} {p[:70]}...")
Here's what the code does:
guide.split("\n\n")cuts the guide wherever there's a blank line. A blank line is two newline characters in a row, and it marks the end of a paragraph.- The
@sign compares two embeddings and gives a similarity score, as in the Embeddings lesson. Higher means a closer match. - The last two lines print the score for the whole guide, then the two best paragraphs.
The pricing paragraph scores far higher than the whole guide does. That helps a second time, too. After search, the model gets the text it found. A focused paragraph is a better source than a whole document full of unrelated rules.
Note: finding passages and handing them to a model with the question has a name. It's called retrieval-augmented generation, or RAG. You'll build it later in this phase. Chunking is the first step.
Show the answer
5, one for each paragraph
Each paragraph has its own embedding, so the question is scored against all 5. The best-scoring paragraphs win. A single score for the whole guide is the blurry approach chunking replaces.
Three ways to cut
There are three common ways to cut a document into chunks.
1. Fixed size with overlap. Take a set number of words (or tokens) for each chunk. Then start the next chunk a little before the last one ended. So each chunk repeats the end of the one before.
Think of taking a panorama on your phone. Each photo overlaps the last one a little, so nothing falls in a gap between shots. Overlap does the same for text. A sentence cut at a chunk boundary still appears whole in one of the two chunks.
Two numbers control this. The size is how many words go in each chunk. The overlap is how many words neighbors share. From those you get the step, how far each chunk starts after the one before:
step = size − overlap
In plain words: move forward by the chunk size, then back up by the overlap. That's where the next chunk starts.
Here's a small example. Take ten words, a to j. Use a size of 4 and an overlap of 1. The step is 4 − 1 = 3, so chunks start at word 0, then 3, then 6. (Python counts positions from 0.)
- Start at 0:
a b c d - Start at 3:
d e f g - Start at 6:
g h i j
That third chunk reaches the last word, so you stop. Neighbors share one word: d, then g.
In Python, range(0, len(words), step) gives the start positions 0, 3, 6 and so on. The slice words[start:start + size] takes the chunk. Step through it below.
Each chunk starts 3 words after the one before (size minus overlap), so neighbors share one word. The loop stops once a chunk reaches the end.
Show the answer
So a sentence cut at a chunk boundary still appears complete in one chunk
Overlap protects facts that fall across a boundary. Like the overlapping photos in a panorama, it makes sure nothing is lost in the gap between two chunks.
words = "a b c d e f g h i j".split() size, overlap = 5, 2
Show the answer
['a b c d e', 'd e f g h', 'g h i j']
The step is 5 − 2 = 3, so chunks start at 0, 3 and 6. The chunk at 6 has only four words left, g to j, and it reaches the end, so the loop stops. The last chunk can be shorter than the rest. The second option has no overlap at all.
Go deeper Why the loop needs that break
Without the break, range(0, 10, 3) would also give a start of 9. The slice at 9 is only j. That word is already in the chunk g h i j, so the extra chunk adds nothing but a tiny, useless passage to search. The check start + size >= len(words) notices that the current chunk already reaches the last word. Then it stops the loop.
2. Along the document's structure. Cut where the writer already made breaks: at headings, or at blank lines between paragraphs. Each chunk is then a natural unit, like one recipe in a cookbook. Paragraphs vary in size, though. So you merge paragraphs that are too small and split ones that are too big.
3. Recursive splitting. "Recursive" means the same rule is applied again on smaller and smaller pieces. Think of cutting a book. You try chapters first. If a chapter is still too long, you cut it into sections. Then paragraphs, then sentences, then words, only as far as you need. Most RAG libraries do this by default.
How big?
There's no single right size. Common starting points are 200 to 500 words per chunk. That's roughly 250 to 700 tokens. Use 10 to 20% overlap.
In plain words: the overlap is a slice of the chunk size. For 15% overlap, multiply the size by 0.15.
For example, take 300-word chunks with 15% overlap. The overlap is 300 × 0.15 = 45 words. So the step is 300 − 45 = 255 words.
size = 300 # words per chunk overlap = round(size * 0.15) # 15% overlap print("overlap:", overlap, "words") print("step:", size - overlap, "words")
This prints an overlap of 45 words and a step of 255 words. round turns the result into a whole number of words.
Size is a trade-off. Think of a map app. Zoom in and you see exact streets, but not the town around them. Zoom out and you see the whole town, but the details blur.
- Smaller chunks give sharper search, because each embedding is about one thing. But each passage carries less context, and a fact can get split.
- Bigger chunks carry more context. But their embeddings blur, because they mix many topics.
The best size depends on your documents and your questions. Test it with real questions, as you'll learn in the evals lesson.
Show the answer
Smaller chunks, such as 300 words, so each embedding is about one topic
Vague matches are the sign of blurry embeddings. A 1,500-word chunk mixes many topics. Smaller chunks give sharper embeddings. Then test with real questions to check the answers improve.
Keep track of where each chunk came from
A library book has a label on its spine. It tells you which shelf it belongs on. Each chunk needs a label like that too. Extra information stored with a chunk is called metadata. At the very least, record which document it came from and its position in that document. The title or section heading helps too.
You'll use metadata in three ways:
- To cite sources. An answer can say "from Returns and refunds".
- To filter. You can search only this year's reports.
- To rebuild context. When a chunk matches, you can fetch the chunks next to it.
The worked example below does this for every article in the practice help center.
Worked example: paragraph chunks with metadata
Chunk every help-center article by paragraph. Attach metadata to each chunk, then count the results.
import requests docs = requests.get("https://api.gradient.test/v1/docs").json()["data"] chunks = [] for doc in docs: for position, paragraph in enumerate(p for p in doc["text"].split("\n\n") if p.strip()): chunks.append({ "id": f"{doc['id']}#{position}", "doc_id": doc["id"], "title": doc["title"], "text": paragraph.strip(), }) print(len(docs), "documents became", len(chunks), "chunks") print(chunks[-1]["id"], "|", chunks[-1]["title"], "|", chunks[-1]["text"][:60])
Most articles are a single paragraph, so they stay whole. The long voice guide becomes several chunks.
enumerate counts the paragraphs as it goes, starting at 0. That count is the position. Each chunk's id joins the document id and its position with a #. So a citation like doc_voice#1 points at one exact paragraph: the second one in the voice guide.
Each chunk is a dictionary. It keeps the text next to its labels, so the labels can't get lost.
Show the answer
doc_voice#2
Positions come from enumerate, which starts counting at 0. So the first paragraph is #0, the second is #1 and the third is #2.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Search keeps returning a 30-page report for every question, but answers are vague. What's the likely fix?