The Chunk That Broke RAG: How 16 Tokens Wrecked Our Retrieval Pipeline

by
The Chunk That Broke RAG: How 16 Tokens Wrecked Our Retrieval Pipeline

We spent three months building a retrieval-augmented generation pipeline that could answer questions about our internal API documentation. The demo was flawless. Then we deployed to production and watched precision drop from 0.89 to 0.51 overnight.

The culprit? A 16-token chunk containing nothing but 'end of file' metadata. It matched the query 'EOF' with cosine similarity 0.97, drowning out every relevant result.

This is the story of how a single chunk broke our RAG pipeline, and the semantic chunking overhaul that fixed it.

The Setup: Naive Fixed-Size Chunking

Our initial implementation used a straightforward approach: split documents into 512-token chunks with 64-token overlap, feed them into a sentence-transformer model (all-MiniLM-L6-v2), and store embeddings in a vector database (Qdrant).

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", ".", " "]
)

chunks = text_splitter.split_documents(documents)

This worked well on our test set of 100 hand-picked questions. But the test set didn't include edge cases like 'What does EOF mean?' or 'How do I handle file endings?'

The 16-Token Chunk

During indexing, one of our documents — a 12KB markdown file about file I/O — ended with a code block:

...

The `RecursiveCharacterTextSplitter` treated the HTML comment as regular text. The last chunk ended up being:

That's exactly 16 tokens. We stored it in the vector database alongside the other 23 chunks from that document.

When a user queried 'What is the EOF function in Python?', the embedding of 'EOF' in the query aligned almost perfectly with the embedding of 'EOF' in that comment chunk. The cosine similarity was 0.97. The actual relevant chunk — which described Python's `file.read()` and how it signals EOF — had a similarity of 0.72.

The retrieval returned the 16-token chunk as the top result. The LLM got '<!-- EOF: file_io.md -->' as context. It hallucinated an answer about a function called `EOF` that didn't exist.

## The Impact: Precision Collapse

We tracked retrieval precision@5 over a week. Before the fix: 0.89. After the bad chunk was indexed: 0.51. The drop was immediate and persistent.

| Metric | Before | After |
|--------|--------|-------|
| Precision@1 | 0.94 | 0.42 |
| Precision@5 | 0.89 | 0.51 |
| Recall@10 | 0.92 | 0.63 |

Every query that contained 'EOF', 'end of file', 'file end', or similar keywords was polluted.

## Root Cause Analysis

Three factors conspired:

1. **Semantically empty chunks**: The comment chunk had no meaningful content, only metadata. It was pure noise.
2. **High similarity from sparse embeddings**: The embedding model produced a vector dominated by the token 'EOF' because the chunk was so short. The rest of the vector was near-zero.
3. **No chunk validation**: We didn't filter chunks by content length or semantic density.

## The Fix: Semantic Chunking

We replaced the recursive character splitter with a semantic chunking approach that respects document structure and discards trivial chunks.

### Step 1: Structure-Aware Splitting

Instead of splitting by character count, we split by markdown headings and code blocks. Each section becomes a chunk, and code blocks are kept intact.

```python
from markdown_it import MarkdownIt
from mdit_plain.renderer import RendererPlain

md = MarkdownIt()
tokens = md.parse(document)

chunks = []
current_chunk = []
for token in tokens:
    if token.type == 'heading_open':
        if current_chunk:
            chunks.append(' '.join(current_chunk))
            current_chunk = []
    elif token.type == 'fence':
        if current_chunk:
            chunks.append(' '.join(current_chunk))
            current_chunk = []
        chunks.append(token.content)  # code block as separate chunk
    else:
        current_chunk.append(token.content)
if current_chunk:
    chunks.append(' '.join(current_chunk))

Step 2: Filter Trivial Chunks

After splitting, we filter out chunks with fewer than 30 tokens or that consist entirely of non-alphanumeric characters.

def is_trivial(chunk_text: str) -> bool:
    tokens = tokenizer.encode(chunk_text)
    if len(tokens) < 30:
        return True
    # Check if >80% of characters are non-alphanumeric
    non_alnum = sum(1 for c in chunk_text if not c.isalnum())
    if non_alnum / len(chunk_text) > 0.8:
        return True
    return False

chunks = [c for c in chunks if not is_trivial(c)]

Step 3: Overlap with Semantic Boundaries

We still need overlap to preserve context across sections. But instead of a fixed token overlap, we overlap by including the previous section's summary or title.

def create_chunk_with_context(section_text, prev_section_title):
    if prev_section_title:
        return f"Context: {prev_section_title}\n\n{section_text}"
    return section_text

Results After Fix

After re-indexing with semantic chunking:

  • Precision@1 returned to 0.92
  • The 16-token chunk was filtered out entirely
  • Queries about 'EOF' now correctly retrieved the section on file reading
  • Retrieval latency increased by 8% due to more complex splitting, but precision gains were worth it

Lessons Learned

  1. Chunking is not a detail — it's the core of RAG. Naive fixed-size splitting is brittle. Respect document structure.
  2. Filter aggressively. Any chunk that could be metadata, a comment, or a footer should be removed. A 30-token minimum saved us.
  3. Test with adversarial queries. Include edge cases like single-word queries that match noise chunks.
  4. Monitor embedding distribution. We now track the norm and sparsity of each chunk's embedding. Trivial chunks tend to have near-zero norms or extreme sparsity.

Implementation Details

# Full semantic chunking pipeline
from typing import List, Tuple
import re

def semantic_chunk(
    document: str,
    min_tokens: int = 30,
    max_tokens: int = 512
) -> List[Tuple[str, str]]:
    """
    Returns list of (chunk_text, section_title) tuples.
    """
    # Parse markdown
    md = MarkdownIt()
    tokens = md.parse(document)
    
    chunks = []
    current_section = ""
    current_text = []
    current_title = ""
    
    for token in tokens:
        if token.type == 'heading_open':
            # Flush current section
            if current_text:
                text = ' '.join(current_text)
                if not is_trivial(text):
                    chunks.append((text, current_title))
                current_text = []
            # Update current title
            # Next token is inline with heading text
        elif token.type == 'inline' and token.level == 0:
            # Could be heading text
            if current_section == "heading":
                current_title = token.content
                current_section = ""
        elif token.type == 'fence':
            if current_text:
                text = ' '.join(current_text)
                if not is_trivial(text):
                    chunks.append((text, current_title))
                current_text = []
            if not is_trivial(token.content):
                chunks.append((token.content, current_title))
        else:
            current_text.append(token.content)
    
    # Flush remaining
    if current_text:
        text = ' '.join(current_text)
        if not is_trivial(text):
            chunks.append((text, current_title))
    
    # Now create final chunks with context prefix
    final_chunks = []
    prev_title = ""
    for text, title in chunks:
        if prev_title:
            text = f"Context: {prev_title}\n\n{text}"
        final_chunks.append(text)
        if title:
            prev_title = title
    
    return final_chunks

Conclusion

A 16-token chunk of garbage nearly killed our RAG pipeline. But it forced us to rethink chunking from the ground up. Now, every chunk in our system is semantically meaningful, structurally sound, and filtered for noise.

If you're building RAG, don't trust your chunker. Verify every chunk. Filter every edge case. And never underestimate the power of a tiny piece of trash.

#chunking#embedding#engineering-postmortem#rag#retrieval#semantic-boundaries
Share — X / Twitter · LinkedIn · HN · Email
Damir Radulić
Founder of RiNET. On the Croatian internet since 1996 (Kvarner Net). In Amsterdam now, building autonomous AI infrastructure that runs on Monday morning when nobody's watching — sovereign stacks, agent swarms, LoRA fine-tuning, civic-intelligence platforms.

Related