Index Ai Academy
Phase 1 · Building with LLMs · Context & retrieval

Chunking documents

Split long documents so the right piece can be retrieved.

About 25 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Why one embedding per document fails
  2. Three ways to cut
  3. How big?
  4. Keep track of where each chunk came from
  5. 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]}...")
It runs right here in your browser, with nothing to install.

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.

Quick check
You split the voice guide into its 5 paragraphs and embed each one. A question comes in. How many paragraph scores does the search compare?
  1. 5, one for each paragraph
  2. 10, two for each paragraph
  3. 1, for the whole guide
  4. It depends on how long the question is
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.

Try it now

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.

Quick check
What's the main purpose of overlap between chunks?
  1. To make every chunk the same length
  2. So the model sees each word twice
  3. To make the index bigger
  4. So a sentence cut at a chunk boundary still appears complete in one chunk
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.

Quick check
In the stepper code, change the second line to size, overlap = 5, 2. What does it print?
words = "a b c d e f g h i j".split()
size, overlap = 5, 2
  1. ['a b c d e', 'd e f g h', 'g h i j']
  2. ['a b c d e', 'd e f g h', 'g h i j k']
  3. ['a b c d e', 'c d e f g', 'e f g h i', 'g h i j']
  4. ['a b c d e', 'f g h i j']
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.

Quick check
Your chunks are 1,500 words each, and search results feel vague. Which change should you test first?
  1. Smaller chunks, such as 300 words, so each embedding is about one topic
  2. Remove the overlap, so chunks don't repeat words
  3. Embed each chunk twice
  4. Bigger chunks, such as 3,000 words, so each one has more context
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

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])
In the lesson you can change this and run it.

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.

Quick check
Using the worked example's id scheme, what is the id of the third paragraph of the voice guide (doc_voice)?
  1. doc_voice#3
  2. doc_voice#2
  3. doc_voice#1
  4. voice#3
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

Create a free account

Try a quiz question

Search keeps returning a 30-page report for every question, but answers are vague. What's the likely fix?

  1. Chunk the report into passages and embed each one
  2. Use a bigger top-k, so that more of the report comes back
  3. Embed the report twice, then average the two sets of numbers
  4. Lower the minimum score, so weaker matches can come through

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson