Fundamentals

Split Long Text into Chunks for AI APIs

Chunk long documents in plain Python with tiktoken: measure in tokens, split on paragraph and sentence boundaries, add 10-15% overlap, and keep citable metadata.

By the end of this guide you will have a single chunk_text() function, roughly sixty lines of plain Python, that turns any long document into a list of chunk records. Each record carries the text, a token count, the source filename, its position in the sequence, and the character range it came from — enough to feed a model and still cite the original. It takes about thirty minutes to build and needs no framework, only tiktoken and Python's built-in re module.

Chunking is a data-preparation job, so it sits in the Data Cleaning for AI section alongside Cleaning CSV Data with Pandas for AI. Clean your text first — strip HTML, collapse stray whitespace — then chunk it. Chunking messy text just gives you messy chunks.

Prerequisites

You need Python 3.10 or newer inside a virtual environment (an isolated folder holding this project's packages). If you have not made one yet, Create a Python Virtual Environment for AI walks through it. Then install the two packages this guide uses:

pip install "tiktoken>=0.7" "openai>=1.40" "python-dotenv>=1.0"

tiktoken is OpenAI's tokenizer library: it converts text into the exact numeric pieces the model bills you for, so you can count them locally without sending anything. You only need an API key for the final step, where chunks are actually sent somewhere. Put it in a .env file:

OPENAI_API_KEY=sk-your-key-here

Then add that file to .gitignore before your first commit, so the key never reaches a public repository:

echo ".env" >> .gitignore

Finally, save a long plain-text file next to your script as report.txt. Anything over a few thousand words works — a transcript, a policy document, a scraped article.

Step 1 — Measure the text in tokens, not characters

A token is the unit models actually read: a common word is usually one token, a rare or compound word splits into two or three, and punctuation often gets its own. In English prose one token averages about three-quarters of a word, but that average collapses the moment your text contains code, URLs, tables or non-English names, where a single "word" can cost six or seven tokens.

That is why character counts mislead. Two 1,000-character passages can differ by fifty per cent in token cost. Since the model's input limit and your bill are both measured in tokens, measure in tokens too. Start by pinning down a counter you will reuse everywhere:

import tiktoken

try:
    ENC = tiktoken.encoding_for_model("gpt-4o-mini")
except KeyError:
    ENC = tiktoken.get_encoding("o200k_base")


def count_tokens(text: str) -> int:
    """Exact token count for the model family above."""
    return len(ENC.encode(text))


if __name__ == "__main__":
    from pathlib import Path

    document = Path("report.txt").read_text(encoding="utf-8")
    print("characters:", len(document))
    print("tokens:    ", count_tokens(document))
    print("ratio:     ", round(len(document) / count_tokens(document), 2))

Run it and note the ratio for your own material. If it lands near 4.0 characters per token you have ordinary prose; if it drops toward 2.5 your text is dense with symbols and you should budget more conservatively. Keep this number handy — it is the same measurement used in Count Tokens in Python Before You Send and in Estimate OpenAI API Costs with Python.

Step 2 — Split on natural boundaries, not a fixed count

The instinct is to slice every N characters. Resist it. A blind slice cuts words in half, orphans a heading from the paragraph it introduces, and can end a chunk on "The refund policy does not apply when" — a fragment that means the opposite of the full sentence.

Instead, work in three tiers. Split the document on blank lines to get paragraphs, because an author already marked those as complete thoughts. If a paragraph is still too large, split it on sentence endings. If a single sentence somehow exceeds your budget — a wall of comma-separated items, say — fall back to a word-count slice as the last resort. Then pack those pieces back together until each group is as close to your target size as it can get without going over.

The five stages that turn one document into chunk records A data-flow diagram running left to right then down: the raw document is split on blank lines, the pieces are filled into a token budget measured with tiktoken, an overlapping tail is added, and the result is a list of chunk records carrying text plus metadata. Raw document one long string Split on blanks paragraphs first Fill to budget tiktoken counts budget reached Add overlap repeat the tail Chunk records text plus metadata
Every stage is a plain Python function; only the middle two touch tiktoken, and nothing here needs a framework.

Here are the splitting tiers and the packing loop. Add them below count_tokens:

import re


def split_paragraphs(text: str) -> list[str]:
    """Blank lines usually mark a complete thought."""
    return [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]


def split_sentences(paragraph: str) -> list[str]:
    """Break after . ! or ? followed by whitespace."""
    return [s.strip() for s in re.split(r"(?<=[.!?])\s+", paragraph.strip()) if s.strip()]


def to_units(text: str, max_tokens: int) -> list[str]:
    """Smallest pieces that each fit inside max_tokens, in document order."""
    units: list[str] = []
    for para in split_paragraphs(text):
        if count_tokens(para) <= max_tokens:
            units.append(para)
            continue
        for sentence in split_sentences(para):
            if count_tokens(sentence) <= max_tokens:
                units.append(sentence)
            else:
                words = sentence.split()
                for i in range(0, len(words), 40):
                    units.append(" ".join(words[i:i + 40]))
    return units

The sentence pattern is deliberately simple. It will split "Dr. Alvarez" in the wrong place, which costs you nothing in practice: the two halves land in the same chunk almost every time, and even when they do not, the overlap you add next covers the seam.

Step 3 — Add overlap so facts survive the boundary

Suppose a chunk boundary lands between "The warranty covers parts and labour for two years." and "It does not cover accidental damage." Ask "does the warranty cover accidental damage?" and retrieval finds the second chunk, which never names the warranty, or the first, which never mentions damage. The answer is technically present in your document and unreachable in your index.

Overlap fixes this by repeating the tail of each chunk at the head of the next. Ten to fifteen per cent of your chunk size is the useful range: about 50 tokens on a 400-token chunk, or 150 on a 1000-token chunk. Below ten per cent the seam is still exposed; above about twenty per cent you are paying to store and embed the same sentences several times for very little extra recall.

A blind character split compared with a boundary-aware split that overlaps A before-and-after comparison in two columns. The left column shows a blind split cutting mid-sentence so the second chunk opens without context and the answer is lost. The right column shows a split at a paragraph break with the previous tail repeated, so the fact survives the boundary. Blind split Overlap split Chunk 1 stops at character 1000 exactly Chunk 2 opens with half a sentence Neither piece holds the whole fact Chunk 1 ends at a paragraph break Chunk 2 repeats the previous tail The fact lands whole in one chunk
The only difference between the two columns is where the cut falls and whether the tail is repeated, and that difference decides whether a straddling fact is findable at all.

Implement overlap as a helper that hands back the trailing pieces of the chunk you just closed, so the next chunk starts with them already in place:

def overlap_tail(units: list[str], overlap_tokens: int) -> list[str]:
    """Return the last whole units that fit inside overlap_tokens."""
    kept: list[str] = []
    total = 0
    for unit in reversed(units):
        cost = count_tokens(unit)
        if total + cost > overlap_tokens:
            break
        kept.insert(0, unit)
        total += cost
    return kept

Because it keeps only whole paragraphs or sentences, the repeated text always reads as language rather than a severed fragment. That strictness has a catch worth knowing about: if every piece in the chunk is larger than the overlap budget, this function returns nothing and you get no overlap at all. Long-paragraph documents hit that case constantly, so the finished version in the next step adds a fallback that carries the trailing sentences of the last piece instead.

One more side effect: a chunk can finish slightly above max_tokens, since the carried text is already sitting in the buffer when the next piece arrives. Leave ten per cent of headroom below the model's real limit and this never bites.

Step 4 — Keep metadata so you can cite the original

A bare list of strings is almost useless three weeks later. Once chunks are embedded and searched, you need to answer "where did this come from?" — to show a page reference, to re-chunk one file after it changes, or to drop every chunk from a document you deleted. Store four fields alongside the text: the source name, the chunk's position in the sequence, the character range in the original file, and the token count.

The character range is the one people skip and later regret. With it, you can highlight the exact passage in the source document instead of showing a paraphrase. Here is the complete function. Each piece is now a (text, start, end) tuple, and carry_over is the Step 3 helper with the sentence fallback added:

from dataclasses import dataclass

Piece = tuple[str, int, int]  # text, start character, end character


@dataclass
class Chunk:
    text: str
    index: int
    source: str
    start_char: int
    end_char: int
    n_tokens: int


def locate(units: list[str], document: str) -> list[Piece]:
    """Attach each unit's character range, scanning forward through the source."""
    located, cursor = [], 0
    for unit in units:
        start = document.find(unit, cursor)
        if start == -1:
            start = cursor
        end = start + len(unit)
        cursor = end
        located.append((unit, start, end))
    return located


def carry_over(pieces: list[Piece], overlap_tokens: int) -> list[Piece]:
    """Trailing pieces that fit the overlap budget, or the last piece's final sentences."""
    kept, total = [], 0
    for piece in reversed(pieces):
        cost = count_tokens(piece[0])
        if total + cost > overlap_tokens:
            break
        kept.insert(0, piece)
        total += cost
    if kept:
        return kept

    text, start, end = pieces[-1]
    sentences, total = [], 0
    for sentence in reversed(split_sentences(text)):
        cost = count_tokens(sentence)
        if total + cost > overlap_tokens:
            break
        sentences.insert(0, sentence)
        total += cost
    if not sentences:
        return []
    offset = text.rfind(sentences[0])
    return [] if offset == -1 else [(text[offset:], start + offset, end)]


def build_chunk(pieces: list[Piece], index: int, source: str) -> Chunk:
    text = "\n\n".join(p[0] for p in pieces)
    return Chunk(
        text=text,
        index=index,
        source=source,
        start_char=pieces[0][1],
        end_char=pieces[-1][2],
        n_tokens=count_tokens(text),
    )


def chunk_text(document: str, source: str, max_tokens: int = 500,
               overlap_ratio: float = 0.12) -> list[Chunk]:
    """Split a document into overlapping, boundary-aware, metadata-carrying chunks."""
    overlap_tokens = int(max_tokens * overlap_ratio)
    pieces = locate(to_units(document, max_tokens), document)

    chunks: list[Chunk] = []
    current: list[Piece] = []
    running = 0

    for piece in pieces:
        cost = count_tokens(piece[0])
        if current and running + cost > max_tokens:
            chunks.append(build_chunk(current, len(chunks), source))
            current = carry_over(current, overlap_tokens)
            running = sum(count_tokens(p[0]) for p in current)
        current.append(piece)
        running += cost

    if current:
        chunks.append(build_chunk(current, len(chunks), source))
    return chunks


if __name__ == "__main__":
    from pathlib import Path

    doc = Path("report.txt").read_text(encoding="utf-8")
    for chunk in chunk_text(doc, source="report.txt", max_tokens=500):
        preview = chunk.text[:60].replace("\n", " ")
        print(f"[{chunk.index:>3}] {chunk.n_tokens:>4} tok "
              f"chars {chunk.start_char}-{chunk.end_char}  {preview}...")

Run it and read the output. Token counts should sit in a tight band just under your target rather than swinging wildly, and each chunk's start_char should be a little lower than the previous chunk's end_char — that gap is your overlap, visible as a number.

Step 5 — Match chunk size to the model and the task

Two separate limits pull chunk size in opposite directions. The context window is the total tokens a model will accept in one request, and it caps how many chunks you can send at once: with a 500-token chunk and ten retrieved results, you are spending 5,000 tokens before the question is even asked. That pressure pushes chunks smaller.

Retrieval quality pushes the other way, but not linearly. Small chunks are precise — one idea each, so a matching search hits it cleanly — yet they lose the surrounding argument, and an answer that needs two paragraphs of reasoning now has to reassemble it from fragments. Large chunks preserve reasoning but dilute the match, because the one relevant sentence is averaged in with a thousand tokens of neighbours.

Choosing a chunk size from the question your chunks must answer A decision tree. The root asks whether one passage can answer the query. The yes branch leads to small chunks of 300 to 500 tokens with 10 per cent overlap split on sentences. The no branch leads to larger chunks of 1000 to 1500 tokens with 15 per cent overlap split on headings. Does one passage answer the query? yes no Small chunks 300-500 tokens Large chunks 1000-1500 tokens Overlap 10% split on sentences Overlap 15% split on headings
Ask what a single chunk has to prove on its own; the size and the overlap setting both follow from that one answer.

Once the size is settled, sending chunks is ordinary API work. This loop summarises each one and keeps the metadata attached to the result:

import os
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

doc = Path("report.txt").read_text(encoding="utf-8")
summaries = []

for chunk in chunk_text(doc, source="report.txt", max_tokens=500):
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Summarise the passage in one sentence."},
            {"role": "user", "content": chunk.text},
        ],
    )
    summaries.append({
        "index": chunk.index,
        "chars": (chunk.start_char, chunk.end_char),
        "summary": response.choices[0].message.content,
    })

for item in summaries:
    print(item["index"], item["chars"], item["summary"])

Because every summary still knows its character range, you can trace any sentence in the final output back to the paragraph that produced it. That is the same pattern used in Summarize Long PDFs with Python and Chunking.

Quick reference: chunk size and what it suits

Chunk sizeOverlapSuits
150-300 tokens10%FAQ entries, product specs, chat logs where one line is the answer
300-500 tokens10-12%Documentation and support articles searched for a single fact
800-1200 tokens12-15%Reports and contracts where a whole argument must stay together
1500+ tokens15%Map-reduce summarising, where every chunk is read in full anyway

The first three rows assume you will retrieve several chunks per question; the last assumes you process every chunk exactly once.

Troubleshooting

ModuleNotFoundError: No module named 'tiktoken' — the package went into a different interpreter than the one running your script. Confirm your virtual environment is active, then reinstall with python -m pip install tiktoken so pip and Python are guaranteed to be the same installation.

KeyError: 'Could not automatically map ... to a tokeniser' — your tiktoken version predates the model name you passed. The try/except KeyError in Step 1 already handles this by falling back to a named encoding; if you removed it, either restore it or upgrade with pip install -U tiktoken.

This model's maximum context length is ... tokens — your chunk plus the system prompt plus the reply exceeded the window. Overlap can push a chunk above max_tokens, so lower the target and leave headroom for the model's own output. Fix the Context-Length-Exceeded Error in Python covers the arithmetic in detail.

Chunks come back with wildly different token counts — your document has no blank lines, so split_paragraphs returns one giant piece and everything falls through to the sentence tier. Normalise line endings first with document.replace("\r\n", "\n"), and if the source is HTML, convert it to text with real paragraph breaks before chunking.

When to use this vs. alternatives

  • Use this hand-written function when you want to see and tune the boundaries, when you need custom metadata fields, or when you are chunking a format with structure worth respecting, such as Markdown headings or transcript timestamps. Sixty lines you understand beat a black box you do not.
  • Use a framework's built-in splitter when chunking is incidental to a larger pipeline you are already building on that framework, and its defaults are good enough. The trade is convenience for control: you inherit its boundary rules and its metadata shape.
  • Skip chunking entirely when your whole document comfortably fits the model's context window and you only ask one question of it. Chunking exists to work around a limit; if you are not near the limit, sending the full text is simpler and often gives better answers.

Chunking is the step that decides how good every later answer can be, so it is worth the half hour. Once your chunks are clean and consistent, the natural next moves are removing near-identical pieces — see Remove Duplicate Records with Embeddings in Python — and wiring the result into a retrieval system, as in Connect a Chatbot to Your Docs with RAG. Back to Data Cleaning for AI.

Frequently asked questions

What is text chunking and why do AI APIs need it?

Chunking is cutting a long document into smaller pieces that each fit comfortably inside a model's input limit. APIs reject requests that exceed that limit, and search over a whole book returns vague answers. Small, self-contained pieces are cheaper to send and far easier to match against a question.

How big should each chunk be?

Between 300 and 500 tokens when a single fact should answer the question, and 800 to 1500 tokens when the model needs a whole argument or section. A token is roughly three-quarters of an English word. Measure with tiktoken rather than guessing from character counts.

Why should chunks overlap?

A sentence that straddles a boundary ends up half in one chunk and half in the next, so neither piece contains the complete fact. Repeating the last 10 to 15 percent of a chunk at the start of the next one means the full statement survives somewhere, at a small cost in storage and tokens.

Can I just split every 1000 characters?

You can, and it will work badly. A blind character count cuts words in half, separates a heading from the paragraph it introduces, and produces pieces of wildly different token lengths. Splitting on blank lines first, then sentences, costs a few extra lines of code and produces far better answers.

Do I need LangChain or another framework to chunk text?

No. A framework saves you perhaps thirty lines, and hides the two decisions that actually matter: where the boundaries fall and how much overlap you keep. Writing the function yourself with tiktoken and the re module keeps both decisions visible and gives you exact control over metadata.