RAG · Explainer
Chunking Strategies for Retrieval-Augmented Generation
How you split documents decides what your retriever can find. This explainer compares fixed-size, recursive, and structure-aware chunking, and shows how to evaluate the choice.
- Author
- Priya Raman (sample)
- Published
- Reading time
- 9 min read
- ## 4. Refunds
- Annual plans can be refunded within
- 30 days of purchase. Refunds go to
- the original payment method.
- ### 4.1 Duplicate charges
- If a card is charged twice, the
- duplicate is refunded automatically
- within 5 business days.
- c-041 · lines 1–4
- c-042 · lines 4–8
- overlap · line 4
In a RAG system, the retriever never sees your documents — it sees chunks. Each chunk is embedded, indexed, and returned (or not) as a unit. If the answer to a question is split across two chunks, or buried in a chunk dominated by unrelated text, retrieval quality suffers no matter how good the embedding model or the generator is.
Chunking is one of the most underrated levers in RAG. This explainer covers the main strategies, how overlap and metadata fit in, and how to choose with measurements rather than defaults.
Why chunk boundaries matter
A chunk has two jobs that pull in opposite directions. For retrieval, it should be focused: one topic per chunk produces an embedding that clearly represents that topic. For generation, it should be complete: the model needs enough surrounding context to understand and use the passage correctly.
- Too small: chunks lose context. A sentence like “This limit does not apply to enterprise plans” is useless without knowing which limit.
- Too large: chunks mix topics, embeddings blur, and each retrieved chunk consumes more of the context window.
- Badly placed boundaries: splitting mid-table, mid-list, or mid-code-block produces fragments that neither retrieve nor read well.
The right balance also depends on the questions users ask. Narrow factual lookups (“What is the API rate limit?”) favor smaller, focused chunks. Questions that need explanation or comparison (“How does billing work when a team downgrades?”) favor larger chunks or retrieving neighboring chunks together. Look at real queries before settling on a size.
Fixed-size chunking
The simplest strategy cuts text every N characters or tokens. It's predictable and easy to implement, and for uniform prose it can be a reasonable baseline. Its weakness is that it ignores meaning: boundaries land wherever the counter runs out, often mid-sentence.
Fixed-size chunking is still worth building first, because it gives you a baseline to beat. If a more sophisticated strategy doesn't measurably improve retrieval on your own questions, the extra parsing code isn't earning its keep.
Recursive chunking
Recursive chunking tries a list of separators from coarsest to finest — paragraph breaks, then line breaks, then sentence ends, then spaces — and only falls back to a finer separator when a piece is still too large. Boundaries tend to land on natural breaks, while chunk size stays within a budget. It's a strong general-purpose default.
Structure-aware chunking
Many corpora carry explicit structure: Markdown headings, HTML sections, API reference entries, numbered policy clauses. Structure-aware chunking splits on those boundaries first, so each chunk belongs to exactly one section, and then applies recursive splitting within sections that are too long. It also lets you attach the section path (for example, Billing > Refunds > Eligibility) as metadata.
| Strategy | Strengths | Weaknesses | Good fit |
|---|---|---|---|
| Fixed-size | Simple, predictable sizes | Ignores meaning; splits sentences | Quick baselines, uniform prose |
| Recursive | Respects natural breaks; size-bounded | Unaware of document hierarchy | Mixed prose without reliable structure |
| Structure-aware | Clean topical boundaries; rich metadata | Needs a parser per format | Docs, wikis, policies, API references |
Overlap and metadata
Overlap repeats the tail of one chunk at the start of the next, so a fact near a boundary appears in both. It helps when boundaries are imperfect, but it isn't free: overlapping chunks increase index size and can return near-duplicate results that waste context. Keep overlap modest relative to chunk size, and deduplicate results before building the prompt.
Metadata often matters as much as the text. Store the source document, section heading, URL or path, last-updated date, and access-control labels with each chunk. You can filter on them at query time, show them as citations, and prepend the section heading to the chunk text before embedding so short passages carry their context.
A structure-aware chunker with overlap
The chunker below splits Markdown on headings first, then recursively splits oversized sections on progressively finer separators. Sizes are measured in characters for simplicity; in production you'd typically measure in tokens using the same tokenizer as your embedding model.
import refrom dataclasses import dataclass SEPARATORS = ["\n\n", "\n", ". ", " "] # coarsest to finest @dataclassclass Chunk: text: str section: str # heading the chunk came from; useful for citations and filters def split_recursive(text: str, max_chars: int, seps: list[str] = SEPARATORS) -> list[str]: if len(text) <= max_chars: return [text] if not seps: # no natural boundary left: hard cut return [text[i : i + max_chars] for i in range(0, len(text), max_chars)] sep, finer = seps[0], seps[1:] pieces: list[str] = [] buffer = "" for part in text.split(sep): candidate = f"{buffer}{sep}{part}" if buffer else part if len(candidate) <= max_chars: buffer = candidate continue if buffer: pieces.append(buffer) if len(part) <= max_chars: buffer = part else: # a single part is still too big: retry with finer separators pieces.extend(split_recursive(part, max_chars, finer)) buffer = "" if buffer: pieces.append(buffer) return pieces def chunk_markdown(doc: str, max_chars: int = 1200, overlap: int = 150) -> list[Chunk]: chunks: list[Chunk] = [] for section in re.split(r"(?m)^(?=#{1,3} )", doc): # split before each heading if not section.strip(): continue first_line = section.split("\n", 1)[0] title = first_line.lstrip("#").strip() if first_line.startswith("#") else "" pieces = split_recursive(section.strip(), max_chars - overlap) for i, piece in enumerate(pieces): prefix = pieces[i - 1][-overlap:] if i > 0 else "" # overlap stays within a section chunks.append(Chunk(text=prefix + piece, section=title)) return chunksEvaluate chunking with retrieval metrics
Chunking choices are empirical. Build a set of questions where you know which passage contains the answer, then measure retrieval directly, separately from the generated answer.
- Recall at k: how often the chunk containing the answer appears in the top k results.
- Answer completeness: whether the retrieved chunks, taken together, contain everything needed to answer.
- Context efficiency: how much of the retrieved text is actually relevant, which affects cost and answer focus.
You don't need a large labeled set to start. A few dozen questions written by someone who knows the corpus, each paired with the ID of the passage that answers it, is enough to compare strategies and catch obvious regressions. Expand the set over time with real user queries that retrieval got wrong.
Change one variable at a time — strategy, size, overlap, or heading prefixing — and re-index between runs. When you compare configurations, read the misses: they usually point to a specific structural problem, like FAQ answers separated from their questions, that a targeted rule can fix.
Examples and figures in this article are illustrative. Adapt thresholds and tooling to your own system and data.