Blog

Semantic chunking from Markdown for RAG

26 Aug 2026 · 8 min read


If you're building a RAG pipeline, the quality of your retrieval depends almost entirely on how you chunk your documents. Poor chunking means relevant context gets split across two vectors — and your LLM never sees the full picture.

Markdown preserves document structure — headings, lists, tables — which makes it the ideal input for semantic chunking strategies. After converting with AnyMD, you're not working with raw text from a PDF parser; you're working with structured documents where every heading, list item, and table cell has meaningful boundaries.

This post compares three chunking strategies on Markdown output from AnyMD: heading-based, token-based, and recursive character chunking.

Why Markdown matters for chunking

Raw PDF text extraction loses everything that makes a document a document. Headings become plain paragraphs. List numbering collapses. Tables become jagged rows of unaligned text. When you feed that into a chunking algorithm, the chunks have no semantic anchors — they're just word-count windows over a linear stream of text.

Markdown from AnyMD preserves:

  • Heading hierarchy#, ##, ### map directly to document sections
  • List structure — ordered and unordered lists with correct nesting
  • Table headers — GFM tables with column alignment
  • Code blocks — fenced blocks with language tags
  • Block quotes — semantic boundaries for cited content

Each of these is a semantic boundary — a natural place to split your chunks so that each chunk contains a complete thought.

Strategy 1: Heading-based chunking

Heading-based chunking splits a Markdown document at heading boundaries. Each # or ## starts a new chunk. This is the simplest and often most effective strategy for long-form documents.

import re
from pathlib import Path

def heading_chunks(markdown: str, max_chars: int = 2000) -> list[dict]:
    """Split Markdown into chunks by heading boundaries."""
    # Split on any heading (# through ######)
    parts = re.split(r'(^#+\s+.*$)', markdown, flags=re.MULTILINE)

    chunks = []
    current = {"heading": "Introduction", "content": ""}

    for part in parts:
        if re.match(r'^#+\s+', part):
            # Save previous chunk
            if current["content"]:
                chunks.append(current)
            current = {"heading": part.strip("# ").strip(), "content": part + "\n"}
        else:
            current["content"] += part

    if current["content"]:
        chunks.append(current)

    # Merge small chunks under a parent heading
    merged = []
    for c in chunks:
        if merged and len(c["content"]) + len(merged[-1]["content"]) < max_chars:
            merged[-1]["content"] += "\n" + c["content"]
        else:
            merged.append(c)

    return merged

# Apply to AnyMD output
md_text = Path("report.md").read_text()
chunks = heading_chunks(md_text)
for i, c in enumerate(chunks):
    print(f"Chunk {i+1}: {c['heading']} ({len(c['content'])} chars)")

The result is a list of chunks where each chunk maps to a document section. For retrieval, this means a query about "installation" retrieves the installation section — not a fragment that happens to contain the word "installation" somewhere in the middle.

Pros and cons

AspectRatingNotes
Retrieval precisionHighChunks align with document structure
Implementation complexityLow~15 lines of Python
Chunk size controlMediumDepends on section length
Context coherenceExcellentEach chunk is a complete section
Best forTechnical docs, reports, manuals

Strategy 2: Token-aware chunking

Token-aware chunking accounts for your model's context window. Instead of counting characters, you count tokens using the same tokenizer your embedding or LLM model uses.

import tiktoken
from typing import List

def token_chunks(
    markdown: str,
    model: str = "text-embedding-3-small",
    max_tokens: int = 512,
    overlap_tokens: int = 64,
) -> List[str]:
    enc = tiktoken.encoding_for_model(model)
    tokens = enc.encode(markdown)

    chunks = []
    start = 0
    while start < len(tokens):
        end = start + max_tokens
        chunk_tokens = tokens[start:end]
        chunks.append(enc.decode(chunk_tokens))
        start += max_tokens - overlap_tokens

    return chunks

# Tokenize AnyMD output
md_text = """## Installation
To install AnyMD, send the file to the API endpoint.
The response is clean GitHub-Flavored Markdown."""
chunks = token_chunks(md_text, max_tokens=128)

for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1}: {len(chunk)} chars")

Token-aware chunking gives you precise control over chunk size — every chunk fits inside your model's context window. The overlap parameter (default 64 tokens) ensures that no concept is split across a chunk boundary.

Pros and cons

AspectRatingNotes
Retrieval precisionMediumCan break mid-section
Implementation complexityMediumNeeds tokenizer library
Chunk size controlExcellentGuaranteed fit in context window
Context coherenceMediumOverlap helps but not perfect
Best forLLM context optimization

Strategy 3: Recursive character chunking

LangChain-style recursive chunking tries to split on natural boundaries (paragraphs, sentences, words) in order of preference, falling back to smaller separators only when a chunk exceeds the size limit.

def recursive_chunk(
    text: str,
    max_chars: int = 1000,
    overlap: int = 100,
) -> list[str]:
    separators = ["\n\n", "\n", ". ", " ", ""]

    def _chunk(text: str, sep_idx: int = 0) -> list[str]:
        if len(text) <= max_chars or sep_idx >= len(separators):
            return [text] if text.strip() else []

        sep = separators[sep_idx]
        parts = text.split(sep)

        chunks = []
        current = ""
        for part in parts:
            candidate = current + (sep if current else "") + part
            if len(candidate) <= max_chars:
                current = candidate
            else:
                if current:
                    chunks.append(current)
                current = part

        if current:
            chunks.append(current)

        # If splitting on this separator didn't reduce size, try next
        if len(chunks) == 1 and len(chunks[0]) > max_chars * 1.1:
            return _chunk(text, sep_idx + 1)

        return chunks

    raw = _chunk(text)
    # Add overlap: prepend last `overlap` chars from previous chunk
    result = []
    prev_tail = ""
    for chunk in raw:
        if prev_tail:
            chunk = prev_tail + chunk
            if len(chunk) > max_chars * 1.5:
                chunk = chunk[len(prev_tail):]  # Remove overlap if too long
            else:
                result[-1] = result[-1][:-len(prev_tail)]  # Trim previous
        result.append(chunk)
        prev_tail = chunk[-overlap:] if len(chunk) >= overlap else chunk

    return result

# Compare on real AnyMD output
md = Path("report.md").read_text()
chunks = recursive_chunk(md)

for i, c in enumerate(chunks):
    print(f"Chunk {i+1}: {len(c)} chars")

Recursive chunking works well on any text, but it performs best on well-structured Markdown because the \n\n separator cleanly splits paragraphs. On raw PDF text (which often has no paragraph breaks), it falls all the way to word-level splits.

Pros and cons

AspectRatingNotes
Retrieval precisionMedium-HighDepends on document quality
Implementation complexityMediumRecursive logic needs testing
Chunk size controlGoodHard limit per chunk
Context coherenceGoodOverlap preserves transitions
Best forMixed document types

Benchmark: Three strategies on 100 real documents

We tested the three strategies on 100 real documents (PDFs, DOCX, reports, articles) converted to Markdown via AnyMD. The metric is retrieval precision@5 — the fraction of top-5 retrieved chunks that contain the answer.

StrategyPrecision@5Avg chunks/docAvg chunk sizeImplementation
Heading-based0.87141,240 chars15 lines
Token-aware (512 tok)0.79221,890 chars10 lines + tokenizer
Recursive character0.8318980 chars40 lines

Heading-based chunking wins on precision because chunks align with document semantics. When a user asks "How do I install the CLI?", the answer lives in the "Installation" section — not split across two chunks.

Token-aware chunking is the most consistent for context-window management, making it the right choice when you're feeding chunks directly into an LLM.

Recursive character chunking is the best default for heterogeneous document collections, where section structure varies wildly between documents.

Building a hybrid pipeline

In production, the best approach combines strategies. Here's a pipeline that starts with heading boundaries and falls back to token-aware splitting for oversized sections:

import tiktoken
import re
from pathlib import Path
from typing import List, Dict, Any

def hybrid_chunk(
    markdown: str,
    section_header: str = "Introduction",
    max_tokens: int = 512,
    overlap_tokens: int = 48,
    model: str = "text-embedding-3-small",
) -> List[Dict[str, Any]]:
    enc = tiktoken.encoding_for_model(model)
    chunks = []

    # Step 1: Split by heading
    parts = re.split(r'(^#+\s+.*$)', markdown, flags=re.MULTILINE)
    sections = []
    current_header = section_header
    current_text = ""
    for part in parts:
        if re.match(r'^#+\s+', part):
            if current_text.strip():
                sections.append((current_header, current_text.strip()))
            current_header = part.strip("# ").strip()
            current_text = part
        else:
            current_text += part
    if current_text.strip():
        sections.append((current_header, current_text.strip()))

    # Step 2: Tokenize each section; split oversized ones
    for header, text in sections:
        tokens = enc.encode(text)
        if len(tokens) <= max_tokens:
            chunks.append({"heading": header, "tokens": len(tokens), "text": text})
            continue

        # Split oversized section with overlap
        for i in range(0, len(tokens), max_tokens - overlap_tokens):
            chunk_tokens = tokens[i:i + max_tokens]
            chunks.append({
                "heading": header,
                "tokens": len(chunk_tokens),
                "text": enc.decode(chunk_tokens),
                "sub_chunk": i > 0,
            })

    return chunks

# Use with AnyMD
md = Path("converted_report.md").read_text()
chunks = hybrid_chunk(md)
print(f"Created {len(chunks)} chunks from {md.count(chr(10)) + 1} lines")
for c in chunks[:5]:
    print(f"  [{c['heading']}] {c['tokens']} tok")

Integration with LangChain and LlamaIndex

Both frameworks support Markdown-aware splitters:

# LangChain
from langchain_text_splitters import MarkdownHeaderTextSplitter

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#", "h1"),
        ("##", "h2"),
        ("###", "h3"),
    ]
)
chunks = splitter.split_text(markdown)
# LlamaIndex
from llama_index.core.node_parser import MarkdownNodeParser

parser = MarkdownNodeParser()
nodes = parser.get_nodes_from_documents([markdown_doc])

These splitters work best when the input is well-formed Markdown — which is exactly what AnyMD produces. Feed raw PDF text into them and the heading detection finds nothing; feed AnyMD output and every section boundary is properly tagged.

Where AnyMD fits in the pipeline

AnyMD sits at the front of your ingestion pipeline: raw documents → clean Markdown → chunk → embed → index. By converting to Markdown first, every chunking strategy works better because the structural information the chunker needs (headings, lists, tables) is preserved in the text.

For a complete RAG ingestion pipeline using AnyMD + LangChain, see our RAG pipeline guide.

For a deeper comparison of 10 chunking strategies across 500 real documents, see fixed-size vs variable-size chunking benchmarks.

Pricing

AnyMD converts documents to Markdown under standard page-based pricing — the chunking happens on your side, so you only pay for the conversion:

PlanPages/monthMax file sizePrice
Free10010 MB$0
Starter50025 MB$19
Pro5,00050 MB$99
EnterpriseCustomCustomCustom

Get your free API key — 100 pages/month, no credit card required.


← Read more →