iToverDose/Software· 27 APRIL 2026 · 20:04

Markdown-Chunking für RAG: Warum Token-Splitter Tabellen zerstören

Feste Token-Limits in Retrieval-Augmented Generation (RAG) zerschneiden Markdown-Tabellen und Codeblöcke – ein neues Verfahren bewahrt Struktur und verbessert die Antwortqualität von Doc-Bots.

DEV Community4 min0 Kommentare

Ein Support-Mitarbeiter fragt einen Dokumentations-Chatbot, warum ein Webhook-Retry fehlschlägt. Die Antwort des Bots fällt falsch aus, weil der Retriever nur die Hälfte einer Markdown-Tabelle liefert: die Kopfzeile und zwei Zeilen des Inhalts, während die entscheidende Zeile mit dem Statuscode 429 im nächsten Chunk landet. Solche Fehler entstehen nicht durch bessere Embeddings oder Reranker, sondern durch naive Token-Splitter, die strukturelle Elemente ignorieren.

Feste Token-Chunks sind in RAG-Tutorials beliebt, weil sie mit einer Codezeile umsetzbar sind und die Chunk-Anzahl vorhersagbar ist. Doch sie ignorieren die Semantik eines Dokuments: Tabellen werden zerteilt, Codeblöcke verlieren ihre Begrenzungsstriche, und Aufzählungen verlieren ihre Kohärenz, wenn nur ein Teil davon im Retrieval landet. Die Lösung liegt in einem splitter, der die Dokumentenstruktur respektiert und gleichzeitig ein weiches Token-Limit einhält.

Warum feste Token-Splitter scheitern

Die meisten RAG-Systeme verwenden einen einfachen Ansatz, um Texte in gleich große Token-Chunks zu unterteilen. Ein typisches Beispiel zeigt, wie ein 512-Token-Limit ohne Rücksicht auf Inhalte angewendet wird:

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o")

def fixed_chunks(text: str, size: int = 512) -> list[str]:
    tokens = enc.encode(text)
    return [
        enc.decode(tokens[i : i + size])
        for i in range(0, len(tokens), size)
    ]

Dieser Code schneidet an Position 512 zu, unabhängig davon, ob dort ein Tabellenende, ein Codeblock-Ende oder ein Satzzeichen liegt. Wenn die 512-Token-Grenze mitten in einer Tabellenzeile wie | HTTP-Status | Beschreibung | fällt, landet die Hälfte der Zeile im aktuellen Chunk und die andere im nächsten. Der Retriever erhält zwei unvollständige Fragmente, die zusammen keine sinnvolle Antwort bilden können.

Eine Benchmark-Studie aus dem Februar 2026, die 50 wissenschaftliche Papers analysierte, ergab, dass rekursive 512-Token-Splitter eine Retrieval-Genauigkeit von 69 % erreichten, während rein semantische Chunking-Ansätze nur auf 54 % kamen. Der Grund: Recursive Splitter nutzen bereits bestehende Strukturen im Dokument, um an sinnvollen Grenzen zu schneiden. Der folgende Ansatz überträgt dieses Prinzip auf Markdown und nutzt dabei den echten Tokenizer des Modells – nicht die Zeichenzählung.

Markdown-Strukturen als Chunking-Grenzen

Bevor ein Dokument in Chunks unterteilt wird, muss seine Struktur erkannt werden. Ein Markdown-Dokument besteht aus Blöcken wie Überschriften, Absätzen, Codeblöcken, Tabellen und Aufzählungen. Jeder Block sollte als eine Einheit behandelt werden, die nicht intern zerteilt wird. Hier ein minimaler Parser, der diese Strukturen identifiziert:

import re
from dataclasses import dataclass

@dataclass
class Block:
    kind: str  # h1..h6, para, code, table, list
    level: int  # 1-6 für Überschriften, sonst 0
    text: str

HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
FENCE = re.compile(r"^`{3}")
TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
LIST_ITEM = re.compile(r"^\s*([-*+]|\d+\.)\s+")

def parse(md: str) -> list[Block]:
    lines = md.splitlines()
    blocks: list[Block] = []
    i = 0
    
    while i < len(lines):
        ln = lines[i]
        
        if m := HEADING.match(ln):
            lvl = len(m.group(1))
            blocks.append(Block(f"h{lvl}", lvl, ln))
            i += 1
            
        elif FENCE.match(ln):
            i = _consume_code(lines, i, blocks)
            
        elif TABLE_ROW.match(ln):
            i = _consume_table(lines, i, blocks)
            
        elif LIST_ITEM.match(ln):
            i = _consume_list(lines, i, blocks)
            
        elif ln.strip() == "":
            i += 1
        
        else:
            i = _consume_paragraph(lines, i, blocks)
    
    return blocks

Die Hilfsfunktionen _consume_code, _consume_table und _consume_list sammeln jeweils einen vollständigen Block ein, bevor sie diesen als Block-Objekt hinzufügen. Dadurch bleibt ein Codeblock mit seinen Begrenzungsstricke ` , eine Tabelle mit allen Zeilen und eine Aufzählung als Ganzes erhalten. Der Splitter erhält keine Gelegenheit, diese Strukturen zu zerstören.

Token-Zählung mit dem echten Modell-Tokenizer

Für eine effiziente Implementierung sollte die Token-Zählung einmalig pro Block erfolgen und zwischengespeichert werden. Dies vermeidet wiederholtes Encodieren desselben Textes:

import tiktoken

ENC = tiktoken.encoding_for_model("gpt-4o")

def n_tokens(text: str) -> int:
    return len(ENC.encode(text))

In einer Produktionsumgebung wird die Token-Zählung bereits während des Parsens durchgeführt und im Block-Objekt hinterlegt. Dies beschleunigt den anschließenden Chunking-Prozess erheblich.

Weiche Token-Limits: Flexibilität ohne Strukturverlust

Ein weiches Token-Limit ermöglicht es, Chunks nahe an eine Zielgröße heranzuführen, ohne harte Grenzen zu erzwingen. Typische Einstellungen für gängige Embedding-Modelle sind:

  • Für 512-Token-Embeddings: Ziel 480 Token, Maximum 512 Token
  • Für Langkontext-Embeddings wie text-embedding-3-large (8.191 Token): Ziel 800 Token, Maximum 1.024 Token

Diese Einstellungen entsprechen den Konfigurationen, die viele Produktions-RAG-Systeme verwenden. Sie ermöglichen eine effiziente Verarbeitung, ohne die Dokumentenstruktur zu opfern.

Greedy-Packing mit Prioritäten

Der Kern des Algorithmus ist ein Greedy-Packing-Verfahren, das Blöcke in Chunks zusammenfasst. Die Logik ist einfach: Füge Blöcke hinzu, solange das Token-Limit nicht überschritten wird. Wenn ein Block zu groß ist, wird er rekursiv in kleinere Einheiten unterteilt – zunächst nach Absätzen, dann nach Sätzen und schließlich nach Wörtern. Überschriften bleiben immer mit dem folgenden Inhalt verbunden, um Kohärenz zu wahren.

def pack(
    blocks: list[Block],
    target: int = 480,
    hard_max: int = 512,
) -> list[str]:
    chunks: list[str] = []
    buf: list[Block] = []
    buf_tokens = 0

    def flush():
        nonlocal buf, buf_tokens
        if buf:
            chunks.append("\n\n".join(b.text for b in buf))
        buf, buf_tokens = [], 0

    for blk in blocks:
        bt = n_tokens(blk.text)
        
        if bt > hard_max:
            flush()
            for piece in split_oversize(blk, hard_max):
                chunks.append(piece)
            continue
        
        if buf_tokens + bt > target and buf_tokens > 0:
            flush()
        
        buf.append(blk)
        buf_tokens += bt
    
    flush()
    return chunks

Zwei Prinzipien sind entscheidend: Erstens werden zu große Blöcke sofort verarbeitet, statt sie in den Puffer zu legen. Zweitens wird der Puffer nur dann geleert, wenn er nicht leer ist – ein Block genau in der Zielgröße landet also immer in einem eigenen Chunk, anstatt einen leeren Chunk zu erzwingen.

Ein Absatz mit 700 Token wird automatisch in kleinere Einheiten unterteilt. Die Prioritätenfolge lautet: Zuerst wird nach Unterüberschriften gespalten, dann nach Absätzen, dann nach Sätzen und schließlich nach Wörtern. Diese Logik stellt sicher, dass auch sehr lange Inhalte sinnvoll verarbeitet werden können, ohne die Dokumentenstruktur zu verlieren.

Dieser Ansatz kombiniert die Vorteile rekursiver Splitter mit der Präzision eines tokenbasierten Modells. Er ist besonders wertvoll für technische Dokumentationen, API-Referenzen und andere strukturierte Inhalte, die in RAG-Systemen häufig verwendet werden. Die Implementierung erfordert nur wenige Zeilen Code und nutzt Standardbibliotheken wie tiktoken, ohne externe Chunking-Bibliotheken zu benötigen.

KI-Zusammenfassung

RAG sistemlerinde karşılaşılan sabit token parçalama sorunlarını çözmek için markdown yapısına saygılı ayrıştırma tekniklerini keşfedin. Tabloların ve kod bloklarının bütünlüğünü koruyan yöntemler.

Kommentare

00
KOMMENTAR SCHREIBEN
ID #6ONMAZ

0 / 1200 ZEICHEN

Menschen-Check

6 + 3 = ?

Erscheint nach redaktioneller Prüfung

Moderation · Spam-Schutz aktiv

Noch keine Kommentare. Sei der erste.