RAGStackGuide
A stack of paper, a guillotine cutter with a glowing blue blade slicing sheets into chunks, and stacked trays with a magnifying glass on a dark navy background.
retrieval-quality

How to Chunk Text for RAG: Methods, Code, and Metrics

Chunk text for RAG by splitting on headings, capping chunks with the embedding model's tokenizer, and scoring recall at a fixed token budget.

By RAGStackGuide Editorial · · 8 min read

To chunk text for RAG, convert each document to structured text, split on its headings, then cap every section at 256 to 512 tokens counted with the embedding model’s own tokenizer. Prepend the heading path to each chunk, keep overlap small, and judge the result by evidence recall at a fixed context-token budget, not by reading answers.

The failure this prevents is quiet. Splitters often count characters; embedding models count word pieces. The all-MiniLM-L6-v2 model card states that input longer than 256 word pieces is truncated by default, so the tail of any longer chunk never reaches the vector. Indexing succeeds, nothing alerts, recall sags, and the generator improvises over the gap.

Picking the size and overlap numbers is covered in the chunking strategy guide. This post is the implementation side: which splitter, what code, and what to measure so a re-chunk is a controlled experiment rather than a guess.

Which chunking method should you pick?

Pick structure-aware recursive splitting as the default: split on Markdown or HTML headings, then recursively on paragraphs and sentences under a token cap. It costs one tokenizer pass. Add contextual prefixes when recall plateaus. Semantic and LLM-driven chunkers cost far more at index time, and the evidence that they beat fixed-size splitting on real documents is thin.

MethodWhere it cutsIndex-time costUse when
Fixed token windowEvery N tokensTokenizer onlyBaselines, homogeneous text
RecursiveParagraph, then line, then wordTokenizer onlyGeneral prose
Structure-awareHeadings first, then recursiveParser plus tokenizerDocs, wikis, knowledge bases
SemanticWhere adjacent-sentence embeddings divergeOne embedding per sentenceUnstructured, topic-mixed text
LLM-drivenModel picks boundariesLLM call per windowSmall, high-value corpora
Contextual prefixAny method, plus an LLM-written context lineLLM call per chunkRecall has plateaued
Late chunkingEmbed whole document, pool per chunk spanLong-context embedding modelText heavy with cross-references

One detail matters in the recursive row. LangChain’s recursive splitter defaults to ["\n\n", "\n", " ", ""]. There is no sentence level, so an oversized paragraph gets cut at a word boundary mid-sentence. Add ". " to the list.

What do chunking benchmarks actually show?

They show modest gaps, no universal winner, and nearly all of them are vendor benchmarks. Use them as a shortlist of configurations to run on your own golden set, not as a setting to copy. The consistent signal is that small, structure-respecting chunks hold up well and heavy overlap buys little.

  • Chroma (vendor benchmark). Chroma’s evaluation ran 472 queries over five corpora with text-embedding-3-large, retrieving five chunks. RecursiveCharacterTextSplitter at 200 tokens, no overlap, reached 88.1 recall. Chroma’s own ClusterSemanticChunker reached 91.3 recall at 400 tokens and its GPT-4o-driven LLM chunker 91.9, at similar precision (4.5 and 3.9); the cluster chunker’s best precision, 8.0, came at 200 tokens, where its recall was 87.3. OpenAI’s Assistants default, run as a token splitter at 800 tokens with 400 overlap, scored 87.9 recall and 1.4 precision. Chroma built both top chunkers; weigh accordingly.
  • NVIDIA (vendor benchmark). NVIDIA’s study tested 128 to 2,048 token chunks at 15 percent overlap against page-level chunking. Page-level had the best average end-to-end accuracy (0.648) and the lowest standard deviation (0.107), yet the best fixed size varied by dataset, from 512 to 1,024 tokens. Factoid queries favoured 256 to 512 tokens; analytical ones, 1,024 or full pages.
  • Qu, Tu and Bao (preprint). Is Semantic Chunking Worth the Computational Cost? found semantic chunking won on artificially stitched, topic-mixed documents, while fixed-size chunking often did better on realistic ones. Two of the three authors are at Vectara, a RAG vendor, so call it semi-independent. It is not a product benchmark.
  • Anthropic (vendor benchmark). Contextual Retrieval prepends a 50 to 100 token, LLM-written note situating each chunk in its document. Top-20 retrieval failure fell from 5.7 to 3.7 percent with contextual embeddings, 2.9 percent adding contextual BM25, and 1.9 percent with reranking on top.

Late chunking attacks the same context-loss problem from the embedding side: run the whole document through a long-context model, then mean-pool token embeddings per chunk span, with no extra training.

The metric that matters: recall at a fixed token budget

Each golden question carries a short evidence quote copied verbatim from the source document. Score a hit when that quote sits inside a retrieved chunk, counting only the top-ranked results that fit in budget B:

recall@B = (1/|Q|) * sum over q of
           1[ evidence_q is contained in a chunk within the highest-ranked
              results whose cumulative size is <= B tokens ]

Plain recall@k rewards big chunks for being big. Five 800-token chunks carry four times the text of five 200-token chunks, so they contain the evidence more often by volume, then spend the generator’s context on padding. Chroma’s 800/400 result shows the cost side: recall within four points of the leaders, precision about a third of theirs. Set B to the retrieval context you actually send the generator, and every configuration competes for the same budget.

Evidence quotes beat chunk-ID labels for a second reason: chunk IDs change on every re-chunk, so an ID-keyed golden set needs relabelling per experiment, which nobody does. A quote survives any re-chunk and scores boundary failures correctly. If the split lands mid-quote, no chunk contains it, and that counts as a miss.

Wiring it up

The chunker below splits Markdown on headings, caps each section using the embedding model’s tokenizer, re-attaches the heading path, and refuses to emit anything the model would truncate.

from langchain_text_splitters import (
    MarkdownHeaderTextSplitter,
    RecursiveCharacterTextSplitter,
)
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
tok = model.tokenizer
MAX_TOKENS = model.max_seq_length       # 256 for this model
TARGET = min(400, MAX_TOKENS) - 32      # headroom: heading prefix + special tokens

header_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
    strip_headers=True,                 # headings move to metadata; re-added below
)
body_splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
    tok,
    chunk_size=TARGET,
    chunk_overlap=TARGET // 10,
    separators=["\n\n", "\n", ". ", " ", ""],
)

def chunk_markdown(doc_id: str, markdown: str) -> list[dict]:
    sections = header_splitter.split_text(markdown)
    pieces = body_splitter.split_documents(sections)
    chunks = []
    for i, piece in enumerate(pieces):
        path = " > ".join(
            piece.metadata[h] for h in ("h1", "h2", "h3") if h in piece.metadata
        )
        text = f"{path}\n{piece.page_content}" if path else piece.page_content
        n_tokens = len(tok(text)["input_ids"])
        if n_tokens > MAX_TOKENS:
            raise ValueError(f"{doc_id}:{i} is {n_tokens} tokens, cap {MAX_TOKENS}")
        chunks.append({
            "id": f"{doc_id}:{i:05d}",
            "doc_id": doc_id,
            "heading_path": path,
            "text": text,
            "n_tokens": n_tokens,
        })
    return chunks

Three choices are deliberate. from_huggingface_tokenizer makes chunk_size count the embedder’s tokens; LangChain’s token-splitting docs warn that CharacterTextSplitter.from_tiktoken_encoder can emit chunks larger than chunk_size, and a tiktoken count is not a WordPiece count anyway. The heading path goes into the embedded text, because the header splitter strips headings into metadata by default. And over-length chunks raise instead of truncating.

Evaluation then logs one MLflow run per configuration. retrieve is your vector store query, returning chunks ranked by score with n_tokens in the payload.

import mlflow
import numpy as np

def norm(s: str) -> str:
    return " ".join(s.split()).lower()

def recall_at_budget(golden, retrieve, budget: int) -> float:
    hits = 0
    for q in golden:                      # {"question": str, "evidence": str}
        needle, used = norm(q["evidence"]), 0
        for c in retrieve(q["question"], k=50):
            used += c["n_tokens"]
            if used > budget:
                break
            if needle in norm(c["text"]):
                hits += 1
                break
    return hits / len(golden)

sizes = np.array([c["n_tokens"] for c in chunks])
with mlflow.start_run(run_name=f"md-headers-{TARGET}"):
    mlflow.log_params({
        "splitter": "md_header+recursive",
        "chunk_size": TARGET,
        "chunk_overlap": TARGET // 10,
        "embed_model": "all-MiniLM-L6-v2",
    })
    mlflow.log_metrics({
        "recall_at_2k_tokens": recall_at_budget(golden, retrieve, 2000),
        "recall_at_4k_tokens": recall_at_budget(golden, retrieve, 4000),
        "chunk_tokens_p50": float(np.percentile(sizes, 50)),
        "chunk_tokens_p99": float(np.percentile(sizes, 99)),
        "runt_ratio": float((sizes < 32).mean()),
        "n_chunks": len(chunks),
    })

Change one variable per run. Splitter, size, overlap and embedding model interact, so a combined change tells you nothing.

What you’ll see

Chunk-size histogram. Healthy looks like a cluster just under the cap, from long sections the recursive splitter packed, plus a spread of short whole sections. Unhealthy looks like a tall bar of runts, usually heading-plus-one-line sections produced by header splitting, which belong merged into a neighbour. A p99 creeping toward MAX_TOKENS means the prefix headroom is too thin and the raise will start firing on long heading paths.

Run comparison in MLflow. A good change lifts recall_at_2k_tokens and narrows its gap to recall_at_4k_tokens. If you also log plain recall@10, the bad pattern is recall@10 climbing with chunk size while recall at 2k falls: big chunks winning on volume. A wide, stable gap between 2k and 4k means evidence is retrieved but ranked low, a job for hybrid search or a reranker rather than the splitter; the retrieval debugging checklist separates the two. Log per-document-type recall as well, since tables and code tend to regress first under splitters tuned on prose.

Caveats

  • Golden set leakage. Questions written by reading the current chunks share their vocabulary and boundaries, inflating recall and favouring the configuration you already have. Source questions from support tickets and search logs, and pick evidence quotes from the source document, not from chunks.
  • Quote length bias. Long evidence quotes straddle boundaries more often, which penalises small chunks. Keep each quote to one sentence.
  • Re-chunking means re-embedding. Every run re-embeds the whole corpus. Contextual prefixes add an LLM call per chunk; Anthropic quotes $1.02 per million document tokens with prompt caching, a vendor figure tied to its pricing at publication.
  • Late chunking changes the embedder. It needs a long-context embedding model, so it is a model decision as much as a chunking one; see the embedding model guide before committing.
  • Extraction comes first. A splitter cannot recover headings that PDF extraction destroyed; fix parsing before tuning anything here, in the order the RAG pipeline build guide sets out.

FAQ

What chunk size should a RAG pipeline start with?

There is no universal best, but 256 to 512 tokens is a sound starting range for factual lookups. NVIDIA’s vendor benchmark found factoid queries favoured that range while analytical queries did better at 1,024 tokens or full pages. Your embedding model’s input limit is the hard ceiling, so check it before picking any number. LlamaIndex users can map the number onto its settings with the LlamaIndex chunk size guide, and sizes by content type are in the chunking strategy guide.

Should RAG chunks overlap?

A small overlap, one or two sentences, is enough when you split on headings and sentences. Chroma’s vendor benchmark found reducing overlap improved IoU, a score that penalises redundant text, and OpenAI’s 800-token, 400-overlap default scored poorly on it. If heavy overlap measurably helps, the splitter is probably cutting mid-sentence and needs better separators. The overlap range and its trade-offs are covered in the chunking strategy guide.

Is semantic chunking better than fixed-size chunking?

Usually not on real documents. A 2024 preprint by Qu, Tu and Bao found semantic chunking won mainly on artificially stitched, topic-mixed text, while fixed-size chunking often matched or beat it on realistic documents. Semantic chunking also costs one embedding per sentence at index time, so measure structure-aware splitting first.

What happens if a chunk exceeds the embedding model’s input limit?

Depending on the stack, it is either rejected or silently truncated, and truncation is the dangerous case: only the opening tokens are embedded and the rest of the chunk becomes unsearchable. The all-MiniLM-L6-v2 model card says input past 256 word pieces is truncated by default. Count tokens with the model’s own tokenizer and fail loudly.

Sources

  1. Anthropic: Introducing Contextual Retrieval
  2. Chroma Research: Evaluating Chunking Strategies for Retrieval
  3. NVIDIA Technical Blog: Finding the Best Chunking Strategy for Accurate AI Responses
  4. Qu, Tu, Bao: Is Semantic Chunking Worth the Computational Cost? (arXiv 2410.13070)
  5. Günther et al.: Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models (arXiv 2409.04701)
  6. LangChain docs: Recursive text splitter
  7. LangChain docs: Markdown header text splitter
  8. LangChain docs: Split by tokens
  9. Hugging Face: sentence-transformers/all-MiniLM-L6-v2 model card
  10. MLflow: ML Experiment Tracking

Related