Hey everyone, welcome to the thirtieth issue of The Main Thread. In this article, we will talk about various ways we can chunk data to be ingested in a RAG system.

Our RAG system is only as good as our chunks. Most tutorials on the web skip this part. They explain how to wire up an embedding model, load it into a vector store, and run a similarity search. “Just 50 lines of code, impressive demo, ship it”.

Shit gets real when production happens. Users ask questions that get answered with fragments that don’t have proper context. A perfect relevant passage is missed because it was split across two chunks. The LLM confidently answers from a chunk that’s adjacent to the right information but doesn’t contain it. The system doesn’t work reliably enough to matter.

Most of the time, the failure is in the chunking.

Chunking is a step where we take a raw document and divide it into retrievable fragments. Every decision we make here - how big, where to split, how much overlap, what metadata to attach - directly determines what our retrieval system can and cannot find.

After having built a few production-grade RAG systems, I wish this guide existed when I built the first RAG system.

TL; DR

Fixed Size Chunking

This is fast and predictable but semantically careless. This is fine for dense and uniform content, but bad for structured content.

Recursive Chunking

This approach is the practical default and it respects natural boundaries and degrades gracefully.

Semantic Chunking

This is the ceiling on quality but expensive computationally. We should use it only at places where precision matters the most.

Overlap

This prevents boundary loss but inflates the index. 10-20% overlap is usually the right range.

Chunk Size

This is a retrieval precision vs context coverage tradeoff. Smaller means more precise, larger means more coherent.

Metadata Enrichment

This is often the highest leverage improvement after we have a working chunking baseline working

Evaluate with recall, not just precision

A chunk we don’t retrieve is a failure we will never see in our logs.

Why Chunking is Hard

Before diving into the strategies, we must understand the problem.

Embedding models have a token limit (usually 512 to 8192 tokens) depending on the model. We cannot embed an entire document in one vector. We must split it. But splitting is a lossy operation because the meaning of a sentence often depends on what came before it. Similarly, a paragraph’s significance often depends on the section it’s in.

There is no universally optimal chunk size. In fact, the right size depends on the embedding model, query patterns, document types, and what a “good answer” looks like in a specific domain.

What follows are the strategies to navigate these tradeoffs deliberately rather than accidentally.

Strategy 1: Fixed size chunking

This is the simplest approach: split the document every N characters or N tokens, regardless of content.

def fixed_size_chunks(
    text: str, 
    chunk_size: int = 512, 
    overlap: int = 50
) -> list[str]:
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start += chunk_size - overlap
    return chunks

This approach works with dense, uniform content such as transcripts, logs, financial filings, data-heavy reports, where every sentence is equal in information density and there are no meaningful structural boundaries.

This approach fails for anything with structure, such as a technical blog post with headers, code blocks, and paragraphs. Fixed-size splitting will cut mid-sentence, across code blocks, through headers. The chunk boundary is arbitrary w.r.t. meaning.

There is one more problem: fixed size splitting is semantically careless. It doesn’t know that “Therefore” starts a conclusion that references previous three paragraphs. It doesn’t know that a code snippet and its explanation belong together. It just counts characters.

We can use this as a baseline to beat but not a production default.

Strategy 2: Recursive Character Text Splitting

This approach is the practical default for most RAG systems and the right starting point for new projects.

The idea is to define a priority-ordered list of separators. We try to split on the highest priority separator first (example: double new line \n\n for paragraphs). If a resulting chunk is still too large, we split on the next separator in the priority list (single new line, then space, then character). We recurse until all chunks are within the size limit.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,         # Target chunk size in characters
    chunk_overlap=200,       # Overlap between chunks
    separators=[
        "\n\n",              # Paragraph break (top priority)
        "\n",                # Line break
        ". ",                # Sentence end
        ", ",                # Clause break
        " ",                 # Word break
        ""                   # Character (last resort)
    ]
)

chunks = splitter.split_text(document_text)

This approach works well in practice because it respects natural language boundaries in priority order. A paragraph split is always preferred over a sentence split, which is preferred over a word split. The chunk size constraint is a ceiling - chunks can be smaller if the document structure allows it.

However, the graceful degradation property is key. If the document is well-structured with clear paragraph breaks, most chunks will be coherent paragraphs. If it is not well-structured, the splitter falls back to sentence breaks. It never does anything outright wrong.

Customizing separators for content type

# For Markdown documents
markdown_separators = ["## ", "### ", "\n\n", "\n", ". ", " ", ""]

# For code files
code_separators = ["\nclass ", "\ndef ", "\n\n", "\n", " ", ""]

# For legal documents
legal_separators = ["\nSection ", "\nArticle ", "\n\n", "\n", ". ", " ", ""]

We must always tune separators to our content type. Defaults can take you only so far; domain-specific chunks are better.

Strategy 3: Semantic Chunking

Semantic chunking splits the text on meaning boundaries, unlike fixed-size and recursive chunking which split on syntactic (whitespace, punctuation, delimiters, etc.) boundaries.

Semantic chunking works like this:

  • Embed each sentence separately

  • Then measure the cosine similarity between consecutive sentence embeddings

  • Wherever similarity drops sharply, we insert a chunk boundary because those sentences are about different topics.

from sentence_transformers import SentenceTransformer
import numpy as np

def semantic_chunks(
    text: str,
    model: SentenceTransformer,
    breakpoint_threshold: float = 0.3
) -> list[str]:
    sentences = text.split(". ")
    embeddings = model.encode(sentences)

    chunks = []
    current_chunk = [sentences[0]]

    for i in range(1, len(sentences)):
        # Cosine similarity between consecutive sentences
        sim = np.dot(embeddings[i-1], embeddings[i]) / (
            np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i])
        )
        if sim < breakpoint_threshold:
            # Semantic shift detected — close current chunk
            chunks.append(". ".join(current_chunk))
            current_chunk = [sentences[i]]
        else:
            current_chunk.append(sentences[i])

    if current_chunk:
        chunks.append(". ".join(current_chunk))

    return chunks

This approach works better for documents with variable topic density, such as blog posts that transition between concepts, research papers with distinct methodology and result sections, support documentation that mixes problem description with resolution steps. Semantic chunking finds natural topic boundaries that syntactic splitting misses.

But this approach has drawbacks too - we are running an embedding model twice (once to chunk, once to index). For large document corpora, this is expensive. Semantic chunking at index time is often practical but doing it at query time is not.

If the threshold is too low, we over-segment (every sentence becomes its own chunk). If it is too high, we under-segment (the whole document becomes one chunk). We need to tune breakpoint_threshold empirically on a sample of actual documents.

Overlap Strategies and Boundary Handling

Information gets lost at chunk boundaries. The sentence that starts chunk N + 1 might be incomprehensible without the sentence that ended chunk N. Overlap is the mitigation.

Overlap means the tail of each chunk is repeated as the head of the next.

Document: [A][B][C][D][E][F][G][H]
Chunk 1:  [A][B][C][D][E]
Chunk 2:        [C][D][E][F][G]   ← C,D,E repeated
Chunk 3:              [E][F][G][H]  ← E,F,G repeated

This ensures that if a relevant sentence falls near a boundary, it appears in two chunks. Retrieval has two choices to find it.

How much overlap?

A practical band is 10-20% of chunk size. For a 1000-character chunk, 100-200 characters of overlap. Beyond 20%, we are inflating index size significantly without proportional retrieval gains as the duplicate content starts showing up as near duplicate results in the similarity search.

Sentence-boundary overlap is better than character-boundary overlap. If we are going to repeat content, it’s better to repeat sentences rather than fragments:

def sentence_aware_overlap(
    sentences: list[str], 
    chunk_size: int, 
    overlap_sentences: int = 2
):
    chunks = []
    i = 0
    while i < len(sentences):
        chunk_sentences = []
        total_len = 0
        j = i
        while j < len(sentences) and total_len + len(sentences[j]) < chunk_size:
            chunk_sentences.append(sentences[j])
            total_len += len(sentences[j])
            j += 1
        chunks.append(" ".join(chunk_sentences))
        i = j - overlap_sentences  # Step back by overlap_sentences before next chunk
    return chunks

Document Structure Preservation

For structured documents like PDFs with headers, markdown files, HTML pages, the structure itself is a semantic signal. Losing it degrades retrieval quality in ways that are hard to diagnose.

Hierarchical chunking preserves document structure by chunking within structural boundaries:

import re

def hierarchical_chunks(
    markdown_text: str, 
    max_chunk_size: int = 1000
) -> list[dict]:
    # Split on headers first, preserving hierarchy
    sections = re.split(r'(#{1,3} .+)', markdown_text)
    chunks = []
    current_header = ""

    for section in sections:
        if re.match(r'#{1,3} ', section):
            current_header = section.strip()
        else:
            # Further split large sections
            if len(section) > max_chunk_size:
                sub_chunks = recursive_split(section, max_chunk_size)
                for sub in sub_chunks:
                    chunks.append({
                        "content": sub,
                        "section": current_header,
                    })
            elif section.strip():
                chunks.append({
                    "content": section.strip(),
                    "section": current_header,
                })
    return chunks

This approach gives us chunks that are always within a coherent section. A chunk from ## Installation will never contain content from ## Configuration even if they are adjacent.

Code blocks deserve special treatment. We should never split a code block mid-block. Embed the entire code block as one chunk (even if it exceeds usual chunk size), paired with its surrounding explanation:

def extract_code_aware_chunks(markdown: str) -> list[str]:
    code_block_pattern = re.compile(r'```[\s\S]*?```', re.MULTILINE)
    chunks = []
    last_end = 0

    for match in code_block_pattern.finditer(markdown):
        # Chunk the text before this code block
        preceding = markdown[last_end:match.start()]
        if preceding.strip():
            chunks.extend(recursive_split(preceding))

        # Keep code block intact, paired with its preceding sentence for context
        context_start = max(0, match.start() - 200)  # ~2 sentences of context
        chunks.append(markdown[context_start:match.end()])
        last_end = match.end()

    # Chunk remaining text
    remaining = markdown[last_end:]
    if remaining.strip():
        chunks.extend(recursive_split(remaining))

    return chunks

Chunk Size Tradeoffs

There is a direct trade-off between retrieval precision and context coverage.

Smaller chunks (256–512 tokens):

  • Higher retrieval precision: the chunk is more likely to be entirely relevant to the query

  • Lower semantic coherence: a 256-token chunk often lacks sufficient context to be self-explanatory

  • Better for factual Q&A: where we need a specific fact, not an explanation

Larger chunks (1,024–2,048 tokens):

  • Better semantic coherence: more context per chunk

  • Lower retrieval precision: the chunk contains both relevant and irrelevant content, diluting the embedding

  • Better for summarization tasks or open-ended questions that need more context

The practical calibration heuristic:

We should match chunk size to the typical query scope. If users ask narrow factual questions ("What is the default timeout for X?"), use smaller chunks. If users ask conceptual questions ("How does X work?"), use larger chunks.

Small-to-big retrieval

This is a pattern that gets the best of both worlds: we should index small chunks for precision, but retrieve the parent chunk (or surrounding context window) for response generation.

LangChain’s ParentDocumentRetriever implements this pattern. An important thing to notice is: the unit of retrieval doesn’t have to be the unit of generation.

Metadata Enrichment

Metadata is often the most important improvement after we have a working chunking baseline.

Every chunk should carry structured metadata beyond its content vector. Metadata enables filtered retrieval → constraining the search space before similarity matching - and post-retrieval ranking based on signals that embeddings don’t capture.

High-value metadata fields:

chunk = {
    "content": "...",
    "metadata": {
        # Provenance
        "source_document": "user-guide-v2.pdf",
        "source_url": "https://docs.example.com/guide",
        "document_type": "documentation",

        # Structure
        "section_header": "Installation",
        "page_number": 14,
        "chunk_index": 3,

        # Temporal
        "created_at": "2025-11-01",
        "updated_at": "2026-01-15",

        # Domain-specific
        "product_version": "3.2",
        "audience": "developer",
        "language": "en",
    }
}

Document level summary injection is an underused technique. Prepend a brief document summary to each chunk before embedding. This gives the embedding model signal about the overall document context, improving retrieval for queries that match document’s topic but not a specific chunk’s literal text.

def enrich_with_summary(
    chunk_text: str, 
    doc_summary: str
) -> str:
    return f"Document context: {doc_summary}\n\n{chunk_text}"

This adds ~50 tokens per chunk but materially improves recall for broad queries. The tradeoff is worth it for most use cases.

Evaluating Chunk Quality

Usually we evaluate RAG by looking at final answer quality. This confuses chunking quality, retrieval quality, and generation quality into a single signal. When answers are bad, we don’t know which layer to fix.

We must evaluate chunking independently.

Recall@K

This means for a set of test queries with known relevant passages, what fraction of relevant passages appear in the top K retrieved chunks. Low recall means good information exists in the corpus but is not being retrieved. This is almost always a chunking or embedding issue.

def recall_at_k(
    queries, 
    relevant_passages, 
    retriever, k=5
):
    recalls = []
    for query, relevant in zip(queries, relevant_passages):
        retrieved = retriever.retrieve(query, k=k)
        retrieved_contents = {c.content for c in retrieved}
        hit = any(passage in content for passage in relevant
                  for content in retrieved_contents)
        recalls.append(1.0 if hit else 0.0)
    return sum(recalls) / len(recalls)

Chunk Coherence Check

We sample 50 random chunks and read them cold. Would these make sense to someone without document context? If more than 20% are confusing in isolation, the boundaries are wrong

Boundary Audit

We find chunks where the first sentence starts with a pronoun (“It”, “This”, “They”) or a transitional word (“However”, “Therefore”, “Additionally”). These chunks likely start mid-thought - symbols of missed boundaries.

import re

def boundary_audit(chunks: list[str]) -> float:
    boundary_words = re.compile(
      r'^(It|This|These|They|He|She|However|Therefore|Additionally|Furthermore|Moreover)\b'
    )
    bad_starts = sum(1 for c in chunks if boundary_words.match(c.strip()))
    return bad_starts / len(chunks)  # Target: < 0.10

A boundary audit score more than 10% is a red flag. We need to adjust our separators and overlap until it drops.

Decision Framework

We should start with recursive splitting, then measure recall@k. If it is below the target, we move up the complexity ladder → semantic chunking → hierarchical splitting → metadata enrichment. We should add complexity only where the metrics justify it.

The best chunking strategy is the simplest one that meets our recall target on real queries from real users. We must build that benchmark first. Everything else is optimization.

Mental Model

Embeddings encode meaning in vectors (covered in the Understanding Embeddings series). But embeddings can only encode meaning that is present in the text they are given. If the chunk contains half a thought, the embedding encodes half a thought. If the chunk mixes three topics, the embedding is a blurry average of three topics.

Chunking is the art of giving the embedding model coherent, self-contained units of meaning to work with. Every split we make is a decision about what constitutes a retrievable unit of knowledge in your corpus.

Get that decision right, and our retrieval is precise and reliable. Get it wrong, and we are explaining to stakeholders why the RAG system can't find the answer that's clearly in the documents.

Would love to know your thoughts on this!

Namaste!

— Anirudh

Reply

Avatar

or to participate

Keep Reading