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 blocksDie 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 chunksZwei 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.
Tags