n4nAI

Chunking strategies for Haystack RAG pipelines

Practical guide to chunking strategies for Haystack RAG pipelines with code examples, tradeoffs, and evaluation methods.

n4n Team4 min read914 words

Audio narration

Coming soon — every post will get a voice note here.

Chunking is the single most impactful preprocessing decision in a Haystack RAG pipeline. Get it wrong and you either drown the model in noise or starve it of context. This guide walks through the major strategies, shows working Haystack code for each, and gives you a framework for choosing and validating the right approach for your corpus.

Why chunking strategy matters

Retrieval quality is bounded by chunk quality. If a chunk splits a logical unit — a code function, a legal clause, a product spec — the embedding captures a fractured idea. If chunks are too large, you waste context window on irrelevant text and dilute the signal. If they’re too small, you lose the relationships that make the answer coherent.

Haystack’s DocumentSplitter component handles the mechanics, but the strategy is yours. The right choice depends on document type, query patterns, and your tolerance for latency versus recall. We’ll cover four approaches in order of increasing sophistication.

Fixed-size chunking with overlap

The baseline. Split by character or token count with a sliding window. Simple, deterministic, and fast. Works surprisingly well for homogeneous prose — blog posts, news articles, documentation with consistent structure.

from haystack.components.preprocessors import DocumentSplitter

splitter = DocumentSplitter(
    split_by="word",
    split_length=250,
    split_overlap=50,
    split_threshold=10  # don't create tiny trailing chunks
)

# In a pipeline
from haystack import Pipeline

indexing = Pipeline()
indexing.add_component("converter", PyPDFToDocument())
indexing.add_component("splitter", splitter)
indexing.add_component("embedder", SentenceTransformersDocumentEmbedder())
indexing.add_component("writer", DocumentWriter(document_store))

indexing.connect("converter.documents", "splitter.documents")
indexing.connect("splitter.documents", "embedder.documents")
indexing.connect("embedder.documents", "writer.documents")

Tradeoffs: Overlap mitigates boundary cuts but duplicates content — increasing index size and embedding cost by roughly overlap / (length - overlap). For 250/50 that’s 25% overhead. No semantic awareness means you’ll split mid-sentence, mid-table, mid-code-block. Acceptable for prototyping; rarely optimal for production.

Pitfall: split_by="word" uses whitespace tokenization, not model tokenization. A 250-word chunk can exceed 512 tokens for technical text. Use split_by="token" with a tokenizer if you’re pushing context limits:

from haystack.components.preprocessors import DocumentSplitter
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-small-en-v1.5")

splitter = DocumentSplitter(
    split_by="token",
    split_length=256,
    split_overlap=32,
    tokenizer=tokenizer
)

Semantic chunking

Instead of fixed windows, split where meaning shifts. Haystack’s DocumentSplitter supports split_by="sentence" combined with embedding-based boundary detection via SemanticDocumentSplitter (available in haystack-experimental or as a custom component).

The algorithm: embed sentences, compute cosine similarity between adjacent sentences, split where similarity drops below a threshold.

# Custom semantic splitter component
from haystack import component, Document
from typing import List
import numpy as np
from sentence_transformers import SentenceTransformer

@component
class SemanticDocumentSplitter:
    def __init__(
        self,
        model_name: str = "BAAI/bge-small-en-v1.5",
        threshold: float = 0.65,
        min_chunk_sentences: int = 3,
        max_chunk_sentences: int = 20
    ):
        self.model = SentenceTransformer(model_name)
        self.threshold = threshold
        self.min_chunk_sentences = min_chunk_sentences
        self.max_chunk_sentences = max_chunk_sentences

    @component.output_types(documents=List[Document])
    def run(self, documents: List[Document]):
        output_docs = []
        for doc in documents:
            sentences = self._split_sentences(doc.content)
            if len(sentences) <= self.min_chunk_sentences:
                output_docs.append(doc)
                continue

            embeddings = self.model.encode(sentences)
            chunks = self._group_by_similarity(sentences, embeddings)
            
            for i, chunk_sentences in enumerate(chunks):
                chunk_content = " ".join(chunk_sentences)
                meta = {**doc.meta, "chunk_id": i, "chunk_strategy": "semantic"}
                output_docs.append(Document(content=chunk_content, meta=meta))
        
        return {"documents": output_docs}

    def _split_sentences(self, text: str) -> List[str]:
        # Use a proper sentence tokenizer in production
        import re
        return [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if s.strip()]

    def _group_by_similarity(self, sentences: List[str], embeddings: np.ndarray) -> List[List[str]]:
        chunks = []
        current_chunk = [sentences[0]]
        
        for i in range(1, len(sentences)):
            sim = np.dot(embeddings[i-1], embeddings[i]) / (
                np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i])
            )
            
            if sim < self.threshold and len(current_chunk) >= self.min_chunk_sentences:
                chunks.append(current_chunk)
                current_chunk = [sentences[i]]
            elif len(current_chunk) >= self.max_chunk_sentences:
                chunks.append(current_chunk)
                current_chunk = [sentences[i]]
            else:
                current_chunk.append(sentences[i])
        
        if current_chunk:
            chunks.append(current_chunk)
        
        return chunks

Tradeoffs: Adapts to content. Keeps related sentences together, separates topic shifts. But: requires an embedding model at index time (extra latency), threshold is corpus-sensitive, and sentence splitting is brittle on messy text (PDFs, scanned docs). The min_chunk_sentences floor prevents over-fragmentation on low-similarity transitions like bullet lists.

When to use: Heterogeneous documents where fixed windows clearly break logical units — research papers, mixed-format knowledge bases, legal contracts with distinct clauses.

Document-structure-aware chunking

The highest signal comes from respecting the author’s structure: headings, sections, tables, code blocks. Haystack’s MarkdownDocumentSplitter (via haystack-experimental) or a custom HTML/PDF parser can preserve hierarchy.

from haystack.components.preprocessors import MarkdownDocumentSplitter

# Splits on markdown headers, preserves header path in metadata
splitter = MarkdownDocumentSplitter(
    split_by="header",
    split_level=2,  # H2 boundaries
    respect_code_blocks=True,
    respect_tables=True
)

# Resulting chunks carry header hierarchy
# doc.meta = {"headers": {"h1": "API Reference", "h2": "Authentication"}, ...}

For PDFs, combine a layout-aware extractor (like PyMuPDF or pdfplumber) with structure detection:

import pdfplumber
from haystack import Document

def extract_structured_chunks(pdf_path: str) -> List[Document]:
    docs = []
    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages):
            # Extract tables separately
            tables = page.extract_tables()
            for t_idx, table in enumerate(tables):
                table_md = table_to_markdown(table)
                docs.append(Document(
                    content=table_md,
                    meta={"page": page_num, "type": "table", "table_index": t_idx}
                ))
            
            # Extract text with font/size info for heading detection
            chars = page.chars
            sections = detect_sections_from_fonts(chars)
            for section in sections:
                docs.append(Document(
                    content=section["text"],
                    meta={"page": page_num, "heading": section["heading"], "level": section["level"]}
                ))
    return docs

Tradeoffs: Maximum retrieval precision for structured docs. Chunks align with how humans navigate the content. But: extraction is fragile — PDF structure is not semantic structure. Header detection fails on scanned docs, weird fonts, or generated PDFs. Maintenance burden is high.

When to use: Technical documentation, API specs, regulatory documents, any corpus where section boundaries are meaningful and extractable.

Hybrid approaches

Production systems often combine strategies. A common pattern: structure-aware first pass, then semantic or fixed-size fallback for unstructured sections.

@component
class HybridDocumentSplitter:
    def __init__(self):
        self.markdown_splitter = MarkdownDocumentSplitter(split_level=2)
        self.fallback_splitter = DocumentSplitter(split_by="token", split_length=300, split_overlap=50)
        self.semantic_splitter = SemanticDocumentSplitter(threshold=0.7)

    @component.output_types(documents=List[Document])
    def run(self, documents: List[Document]):
        all_chunks = []
        for doc in documents:
            # Try structure-aware first
            if doc.meta.get("format") == "markdown" or self._has_headers(doc.content):
                result = self.markdown_splitter.run([doc])
                chunks = result["documents"]
                # Further split oversized chunks
                final_chunks = []
                for chunk in chunks:
                    if self._estimate_tokens(chunk.content) > 400:
                        sub = self.semantic_splitter.run([chunk])
                        final_chunks.extend(sub["documents"])
                    else:
                        final_chunks.append(chunk)
                all_chunks.extend(final_chunks)
            else:
                # Unstructured: semantic with fixed fallback
                result = self.semantic_splitter.run([doc])
                all_chunks.extend(result["documents"])
        return {"documents": all_chunks}

    def _has_headers(self, text: str) -> bool:
        return bool(re.search(r'^#{1,3}\s', text, re.MULTILINE))

    def _estimate_tokens(self, text: str) -> int:
        return len(text) // 4  # rough heuristic

This gives you header-aligned chunks where possible, semantic coherence elsewhere, and a hard token ceiling everywhere.

Evaluating your chunking strategy

Don’t guess. Measure. Two evaluation axes: retrieval metrics and end-to-end answer quality.

Retrieval evaluation

Build a small labeled set: (query, relevant_doc_ids). Compare chunking strategies on recall@k and nDCG.

from haystack import Pipeline
from haystack.components.retrievers import InMemoryEmbeddingRetriever
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack.evaluation import Evaluator
from haystack.evaluation.metrics import Recall, NDCG

def evaluate_chunking_strategy(documents, queries, relevant_docs, document_store):
    # Index with current strategy
    indexing_pipeline = build_indexing_pipeline(document_store)
    indexing_pipeline.run({"documents": documents})
    
    # Retrieval pipeline
    retrieval = Pipeline()
    retrieval.add_component("embedder", SentenceTransformersTextEmbedder())
    retrieval.add_component("retriever", InMemoryEmbeddingRetriever(document_store, top_k=10))
    retrieval.connect("embedder.embedding", "retriever.query_embedding")
    
    # Evaluate
    evaluator = Evaluator(
        pipeline=retrieval,
        metrics=[Recall(k=5), NDCG(k=5)],
        inputs=[{"query": q, "relevant_documents": rel} for q, rel in zip(queries, relevant_docs)]
    )
    return evaluator.run()

Run this for each strategy. You’ll often find semantic chunking wins on recall@5 for conceptual queries, while structure-aware wins on nDCG for navigational queries (“show me the authentication section”).

End-to-end RAG evaluation

Retrieval metrics don’t capture generation quality. Use a small golden set of (query, expected_answer) pairs and judge with an LLM evaluator.

from haystack.components.generators import OpenAIGenerator
from haystack.components.builders import PromptBuilder

rag_pipeline = Pipeline()
rag_pipeline.add_component("embedder", SentenceTransformersTextEmbedder())
rag_pipeline.add_component("retriever", InMemoryEmbeddingRetriever(document_store, top_k=5))
rag_pipeline.add_component("prompt", PromptBuilder(template=RAG_TEMPLATE))
rag_pipeline.add_component("generator", OpenAIGenerator(model="gpt-4o-mini"))
rag_pipeline.connect("embedder.embedding", "retriever.query_embedding")
rag_pipeline.connect("retriever.documents", "prompt.documents")
rag_pipeline.connect("prompt.prompt", "generator.prompt")

# LLM-as-judge evaluation
judge_prompt = """
Score the answer 1-5 on: accuracy, completeness, conciseness.
Query: {{query}}
Expected: {{expected}}
Actual: {{actual}}
Return JSON: {"accuracy": int, "completeness": int, "conciseness": int}
"""

judge = OpenAIGenerator(model="gpt-4o", generation_kwargs={"response_format": {"type": "json_object"}})

Compare average scores across chunking strategies. This catches cases where retrieval looks good but chunks are too fragmented for coherent synthesis.

Common pitfalls and tradeoffs

Pitfall Symptom Fix
No overlap Answers cut off mid-thought Always use 10-20% overlap
Fixed token count on word-split Chunks exceed model context Use split_by="token" with the retriever’s tokenizer
Semantic threshold too high Over-fragmentation, thousands of tiny chunks Set min_chunk_sentences=3-5, tune threshold on dev set
Ignoring metadata Can’t filter by section, source, date Propagate doc.meta through every splitter; add chunk_id, parent_id
Chunking tables as text Unretrievable tabular data Extract tables separately, embed as markdown + structured JSON
One strategy for all docs Poor recall on heterogeneous corpus Route by mime-type / structure detection to specialized splitters

Latency budget: Semantic splitting adds ~50-200ms per document at index time (embedding sentences). For high-volume pipelines, pre-compute embeddings offline or use a lighter model (MiniLM) for splitting, heavier for retrieval.

Index size: Overlap and semantic fragmentation increase document count. A 10K doc corpus at 250 tokens/50 overlap → ~60K chunks. At 512 tokens/100 overlap → ~25K chunks. Plan your vector store capacity accordingly.

Re-chunking is expensive: Changing strategy means re-embedding the full corpus. Design for this: version your chunking config, store chunking_version in document metadata, and build a re-indexing pipeline from day one.

Choosing a starting point

  1. Prototype fast: Fixed token chunking (256/32) with your retriever’s tokenizer. Baseline in 15 minutes.
  2. Diagnose failures: Run retrieval eval. Look at false negatives — are they split across chunks? Truncated? Buried in noise?
  3. Match strategy to failure mode:
    • Split concepts → semantic
    • Navigational queries fail → structure-aware
    • Tables/lists broken → specialized extractors
  4. Hybridize: Route by document type. Most corpora are mixed.
  5. Automate eval: CI gate on recall@5 and LLM-judge score. Prevent regressions when you tweak thresholds.

The best chunking strategy is the one you can measure, explain, and change. Start simple enough to debug when the 2 AM page comes in.

Tagshaystackragchunkingdocument-processing

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All haystack rag pipelines posts →