Breaking documents into small pieces so embeddings stay focused and retrieval doesn't drown in irrelevant text
A whole 50-page document is rarely all relevant to a specific question. If you embed the whole document as one vector, that vector represents an average — a blur — of every topic in it, so it won't match well against a narrow question. Chunking also respects the context limit: small, focused chunks let you send only the 3-5 most relevant pieces instead of entire documents. Chunking strategy is consistently one of the highest-leverage levers in a RAG system — teams frequently see bigger quality gains from fixing chunking than from swapping the LLM.
Imagine trying to find one paragraph in a 500-page book by asking "does this whole book match my question?" versus asking "which of these 500 individual paragraphs matches my question?" The second approach is far more precise — and that's exactly what chunking sets up for the embedding and retrieval steps that follow.
"Chunk size" can mean characters, words, or tokens, and these are not interchangeable — an embedding model has a maximum token input length, not a character length, and English averages roughly 4 characters per token. A naive character-based splitter can silently overshoot an embedding model's token limit on token-dense text (code, non-English text, or heavy punctuation), causing the embedding call to truncate the chunk without warning. Production splitters typically count length with the actual tokenizer of the target embedding model (e.g., tiktoken for OpenAI models) rather than raw character count, precisely to avoid this mismatch.
Just cuts the text every N characters, based on a separator like a space.
Why chunk_overlap? If you cut text at exactly character 100 with zero overlap, you might slice a sentence right in half — chunk 1 ends with "...the claim will be processed with" and chunk 2 starts with "in 30 days if...". Neither chunk alone makes sense. A small overlap re-includes the last few words of the previous chunk at the start of the next one, so each chunk still reads sensibly on its own.
The problem: it cuts blindly based on character count, with no awareness of sentence or paragraph boundaries. It can — and often does — split mid-sentence or mid-word, producing chunks that read like garbage fragments and embed poorly. That's why it performs poorly in most RAG pipelines despite being the simplest option.
This splitter tries to split along the natural structure of the text, in priority order:
It tries the first separator. If a resulting piece is still too big, it recursively tries the next separator on that piece, and so on, until every chunk fits within chunk_size.
Each chunk is a complete, sensible thought — much better than a random character cutoff.
This is the default go-to splitter for most RAG projects — a good balance of simplicity and quality.
When the source format has explicit structure, splitting along that structure beats guessing at separators:
MarkdownHeaderTextSplitter) splits on #/##/### boundaries first, and attaches the header hierarchy itself as metadata on each chunk (so a chunk under "## Refund Policy > ### Exceptions" carries that path even if the words "refund policy" never appear in the chunk's own text).RecursiveCharacterTextSplitter.from_language()) uses language-specific separators (function and class boundaries) so a chunk doesn't end mid-function — critical for code-search RAG, since a truncated function body is often worse than no context at all.A table split by any generic text splitter loses the row/column relationship — a row of numbers with no header context is meaningless once separated from the header row above it. Two common fixes: keep each table as a single, unsplit chunk (with its header row always included, even if this means an oversized chunk), or convert the table to a compact markdown or key-value textual representation per row (e.g., "Region: West, Q1: $120k, Q2: $135k") so each row is independently meaningful when retrieved in isolation.
Even recursive splitting has a blind spot: a single paragraph can still cover two different topics, and recursive splitting has no way to detect that — it only looks at punctuation and structure, not meaning. Semantic chunking works differently:
The breakpoint_threshold_type controls how a "sharp drop" is defined: percentile cuts wherever the similarity drop is in, say, the top 5% of all drops observed in the document; standard_deviation cuts wherever the drop exceeds some number of standard deviations from the mean drop; interquartile uses the interquartile range instead, which is more robust to a few extreme outlier sentences skewing the threshold. There's no universally correct choice — it's another parameter to validate against an eval set, same as chunk size.
Trade-off: this requires embedding every sentence just to decide where to cut — more embedding calls and more time and cost at indexing. Worth it for high-value, complex documents; overkill for simple or short ones.
Two techniques address the same underlying problem — a chunk taken out of its document loses context a human reader would have had:
Both trade extra indexing-time compute for retrieval-time precision — reasonable when the corpus is stable and re-indexed infrequently, harder to justify for a corpus that changes constantly.
There's no universal magic number — it depends on your documents and questions. The correct approach is empirical, not guesswork:
Rough starting points: 300-500 tokens for question-answering RAG, 800-1200 tokens if the task is closer to summarization. Overlap is typically 10-20% of chunk size. These are starting points for the search, not a substitute for it.