n4nAI

Fixed-size chunking vs semantic chunking for RAG

Compare fixed-size vs semantic chunking for RAG: trade-offs on retrieval quality, latency, cost, and implementation complexity with code examples.

n4n Team5 min read1,110 words

Audio narration

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

Fixed-size chunking splits documents into equal token windows with a sliding overlap. Semantic chunking uses an embedding model or heuristic to cut at topic boundaries. The choice determines whether your retrieval surface is predictable or meaningful — and it cascades into every downstream metric from latency to hallucination rate.

What fixed-size chunking does

Fixed-size chunking is deterministic. You pick a token budget (say 512 tokens) and an overlap (say 50 tokens), then slice sequentially. No external dependencies, no model calls, no nondeterminism.

def fixed_chunks(text: str, tokenizer, chunk_size: int = 512, overlap: int = 50) -> list[str]:
    tokens = tokenizer.encode(text)
    chunks = []
    for i in range(0, len(tokens), chunk_size - overlap):
        chunk_tokens = tokens[i:i + chunk_size]
        chunks.append(tokenizer.decode(chunk_tokens))
    return chunks

The overlap mitigates boundary cuts — a sentence split across chunks appears in both. But the strategy is blind to document structure. A code block, a table, and a paragraph all get the same treatment. If your chunk size is smaller than a typical semantic unit, you fragment context. If it’s larger, you dilute signal with noise.

What semantic chunking does

Semantic chunking attempts to cut where meaning shifts. The most common production approach: embed sentences (or paragraphs), compute cosine similarity between adjacent windows, and split where similarity drops below a threshold.

def semantic_chunks(
    text: str,
    embedder,
    threshold: float = 0.75,
    min_chunk_tokens: int = 100,
    max_chunk_tokens: int = 1000
) -> list[str]:
    sentences = split_sentences(text)  # your sentence splitter
    embeddings = embedder.embed(sentences)
    
    chunks = []
    current = [sentences[0]]
    current_tokens = count_tokens(sentences[0])
    
    for i in range(1, len(sentences)):
        sim = cosine(embeddings[i-1], embeddings[i])
        next_tokens = count_tokens(sentences[i])
        
        if sim < threshold and current_tokens >= min_chunk_tokens:
            chunks.append(" ".join(current))
            current = [sentences[i]]
            current_tokens = next_tokens
        elif current_tokens + next_tokens > max_chunk_tokens:
            chunks.append(" ".join(current))
            current = [sentences[i]]
            current_tokens = next_tokens
        else:
            current.append(sentences[i])
            current_tokens += next_tokens
    
    if current:
        chunks.append(" ".join(current))
    return chunks

Variants exist: some use hierarchical clustering, others use an LLM to propose boundaries, others rely on document markup (headings, HTML tags). The common thread: chunk boundaries correlate with semantic boundaries.

Comparison across dimensions

Retrieval quality

Fixed-size chunking produces uniform vectors. Every chunk has the same dimensional footprint, which makes ANN index behavior predictable. But a query about “authentication flow” might match the middle of a chunk that starts with “logging configuration” and ends with “rate limiting” — the relevant signal is diluted.

Semantic chunking aligns chunks with concepts. A query about authentication retrieves the authentication chunk, not a fragment. In practice this lifts recall@k for concept-oriented queries by 10-20% on benchmarks like HotpotQA and NarrativeQA. The trade-off: chunk lengths vary, so vector norms vary, which can bias similarity scores unless you normalize.

Latency and throughput

Fixed-size chunking is effectively free at ingestion. Tokenization is fast; slicing is O(n). You can chunk 10,000 documents per second on a single CPU core.

Semantic chunking requires embedding every sentence (or window). A typical BGE-small or MiniLM call runs ~2-5 ms per sentence on CPU, ~0.5 ms on GPU. For a 50-page PDF (~2,000 sentences), that’s 4-10 seconds of pure embedding time. Batch inference helps, but ingestion latency becomes a real pipeline concern. If you’re indexing millions of documents, you need a GPU fleet or you accept hours of lag.

Cost model

Fixed-size: near-zero compute cost. Storage cost is predictable — chunk count = total_tokens / (chunk_size - overlap).

Semantic: embedding compute dominates. At current prices, embedding 1M tokens with a small model costs ~$0.01-0.02 on managed APIs, or GPU-hours self-hosted. For a 100GB corpus, that’s hundreds of dollars per re-index. Variable chunk counts also make storage forecasting harder.

Ergonomics and debugging

Fixed-size is trivial to debug. Chunk 47 is tokens 23,500-24,000. You can grep the source, count tokens, verify overlap. When retrieval fails, you know exactly what the model saw.

Semantic chunking is opaque. A chunk boundary might fall in the middle of a code example because the embedder thought “def authenticate” and “return token” were dissimilar. You need tooling to visualize boundaries, inspect similarity scores, and trace why a query matched (or didn’t). Most teams underinvest here and pay in debugging time.

Ecosystem and tooling

LangChain, LlamaIndex, and Haystack all ship fixed-size splitters as defaults. They’re battle-tested, configurable, and work with any tokenizer.

Semantic splitters exist in the same libraries but are younger. LangChain’s SemanticChunker uses embedding similarity; LlamaIndex has SemanticSplitterNodeParser. Both require you to provide an embedder and tune thresholds. Community knowledge is thinner — fewer Stack Overflow answers, fewer GitHub issues to search.

Limits and failure modes

Fixed-size fails on:

  • Structured data (tables, code, JSON) where a split breaks parseability
  • Long coherent passages where overlap isn’t enough to preserve context
  • Queries needing multi-hop reasoning across artificial boundaries

Semantic chunking fails on:

  • Short documents where there aren’t enough sentences to detect boundaries
  • Domain-specific language where general-purpose embedders misjudge similarity
  • Adversarial formatting (bullet lists, FAQs) where every sentence looks dissimilar
  • Drift: if you switch embedders, all boundaries change — re-indexing is mandatory

Comparison table

Dimension Fixed-size chunking Semantic chunking
Ingestion latency ~ms per doc ~seconds per doc (embedding-bound)
Compute cost Negligible Embedding API calls or GPU-hours
Retrieval recall (concept queries) Baseline +10-20% typical
Chunk length variance Zero High (depends on threshold)
Debuggability Trivial — deterministic offsets Requires visualization tooling
Re-indexing on embedder change Not needed Mandatory
Structured data handling Poor — breaks tables/code Better if embedder respects structure
Default library support Mature, all frameworks Available, less battle-tested
Storage predictability Exact formula Variable, depends on corpus

Which to choose

Choose fixed-size when

You’re prototyping or iterating fast. Fixed-size gets you a working RAG pipeline in minutes. Swap to semantic later if retrieval quality demands it.

Your corpus is code, logs, or structured data. Semantic chunkers trained on natural language butcher source code. Fixed-size with a code-aware tokenizer (tree-sitter, Pygments) preserves function boundaries better than a general embedder.

Ingestion throughput matters more than recall. High-volume pipelines (millions of docs/day) can’t absorb embedding latency per document. Fixed-size scales horizontally with zero GPU budget.

You need reproducible, auditable chunking. Compliance, legal, or forensic workflows often require deterministic mapping from chunk to source offset.

Choose semantic when

Queries are concept-oriented, not keyword-oriented. Users ask “how does authentication work?” not “authentication function.” Semantic chunks match the mental model.

Documents are long-form prose: manuals, research papers, legal contracts. These have clear topic shifts. Fixed-size chops arguments mid-paragraph; semantic chunking keeps sections intact.

You have GPU budget and can tolerate ingestion latency. If you’re indexing nightly, the embedding cost is amortized. The recall gain compounds across every query.

You’re building a customer-facing product where retrieval quality is a differentiator. The 15% recall lift translates to fewer “I couldn’t find that” support tickets.

Hybrid approach (what most production systems converge to)

Use document structure first, then fixed-size within sections.

def hybrid_chunks(doc: Document, tokenizer, chunk_size: int = 512, overlap: int = 50) -> list[str]:
    # Split by headings, code fences, tables — whatever your parser exposes
    sections = doc.split_by_structure()
    chunks = []
    for section in sections:
        if section.is_structured:  # code, table, list
            chunks.append(section.text)  # keep atomic
        else:
            chunks.extend(fixed_chunks(section.text, tokenizer, chunk_size, overlap))
    return chunks

This preserves semantic boundaries where markup exists (headings, <table>, python fences) and falls back to fixed-size for plain prose. No embedding calls at ingestion. You get 80% of the semantic benefit for 5% of the cost.

If you later need finer granularity inside prose sections, run semantic chunking as a second pass on just the unstructured text — but only after you’ve measured that fixed-size within sections is actually hurting recall.


The honest answer: start with fixed-size. Measure recall on your actual query log. If concept queries miss, add structure-aware splitting. Only reach for embedding-based semantic chunking when you have evidence that structure-aware fixed-size isn’t enough, and you have the GPU budget to pay for it.

Tagschunkingsemantic-chunkingragcomparison

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 chunking strategies for rag posts →