Markdown für RAG aufteilen: fünf Experimente mit Überschriften in LangChain
MarkdownHeaderTextSplitter Option für Option testen: Überschriften-Metadaten, strip_headers, return_each_line und die Verkettung mit RecursiveCharacterTextSplitter für RAG.
Eine Markdown-Datei in Zeichenfenster fester Größe zu zerhacken, ist ein schneller Weg, ihre Struktur zu ruinieren. Überschriften landen in einem Chunk und ihre Absätze in einem anderen, und eine Liste wird mitten im Eintrag zerschnitten. Wenn dieser Text eine RAG-Pipeline speist, leidet das Retrieval darunter. Der MarkdownHeaderTextSplitter von LangChain geht einen anderen Weg: Er schneidet an Überschriften und hält als Metadaten fest, unter welchen Überschriften ein Textstück stand.
Am einfachsten versteht man, was er tut, wenn man an einem winzigen Dokument jeweils eine Einstellung ändert und die Ausgabe liest. Genau das folgt jetzt.
Was der Splitter macht
Die Klasse teilt Markdown-Text überall dort, wo die angegebenen Überschriften vorkommen. Nehmen wir dieses Miniaturdokument:
# Header 1
Here is the test content of Header 1
## Header 2
1. Content
2. Content
3. Content
4. Content
Die Zeilen mit # und ## geben dem Text seine Form. Der Splitter behandelt diese Zeilen als Schnittpunkte und legt den Überschriftentext in den Metadaten jedes entstehenden Dokuments ab. Für RAG ist das praktisch, weil jeder Chunk festhält, woher in der Originaldatei er stammt.
Die Testdatei
Alle Experimente unten verwenden dieselbe Datei, simple_water_cycle.md, eine absichtlich kleine Seite über den Wasserkreislauf. Sie hat zwei Überschriftenebenen. Eine Überschrift der obersten Ebene, "The Water Cycle", eröffnet die Seite, gefolgt von einem einleitenden Satz: Der Wasserkreislauf beschreibt, wie sich Wasser durch die Umwelt bewegt. Unter einer Überschrift der zweiten Ebene, "Main Stages", stehen vier nummerierte Phasen (Verdunstung, Kondensation, Niederschlag und Sammlung) und danach ein Schlusssatz, der sagt, dass sich der Kreislauf ständig wiederholt. Die Namen der Phasen sind fett gedruckt.
Vorbereitung
Beginne mit pathlib und lies die Datei über ihr Path-Objekt ein. Lege dann die Liste der Überschriften fest, an denen geteilt werden soll. Jeder Eintrag ist ein Paar: zuerst das Markdown-Symbol, dann der Metadatenschlüssel, unter dem diese Überschrift gespeichert wird. Hier sind die Paare # zugeordnet zu "Header 1" und ## zugeordnet zu "Header 2". Zeit zum Experimentieren.
Experiment 1: die Standardeinstellungen
Führe den Splitter ohne zusätzliche Optionen aus. Das Ergebnis sind zwei Document-Objekte.
Das erste enthält den Einleitungssatz mit den Metadaten {'Header 1': 'The Water Cycle'}. Das zweite enthält die vier Phasen plus den Schlusssatz, und seine Metadaten tragen beide Ebenen: {'Header 1': 'The Water Cycle', 'Header 2': 'Main Stages'}.
Zwei Dinge fallen auf. Erstens stehen die Überschriftszeilen selbst standardmäßig nicht in page_content. Der Text # The Water Cycle taucht im Inhalt des ersten Dokuments nie auf, weil diese Information stattdessen in den Metadaten liegt. Zweitens wandert die übergeordnete Überschrift mit der untergeordneten mit. Die Metadaten des Abschnitts "Main Stages" enthalten "Header 1", obwohl diese Überschrift darüber steht. Der Splitter schneidet also nicht nur Text. Er erhält die Überschriftenhierarchie des Dokuments über die Metadaten.
Experiment 2: Überschriften im Text behalten
Überschriften werden aus dem Inhalt entfernt, wenn man nichts anderes sagt. strip_headers=False ändert das. Jetzt beginnt das erste Dokument mit # The Water Cycle, gefolgt vom Einleitungssatz, und das zweite mit ## Main Stages und dann seiner Liste.
Schnittpunkte und Metadaten bleiben identisch. Anders ist nur, ob der Überschriftentext in page_content steht. Standardmäßig landet eine Überschrift nur in den Metadaten, mit diesem Flag in den Metadaten und im Inhalt. Das will man, wenn die echten Wörter der Überschrift mit dem Text zum nächsten Verbraucher wandern sollen, etwa zu einem Embedding-Modell oder in einen Prompt.
Experiment 3: return_each_line
Zurück zum Standardverhalten bei Überschriften, und jetzt nur return_each_line=True umschalten. Die Ausgabe wächst von zwei auf drei Dokumente. Das erste ist weiterhin die Einleitung. Das zweite sind die vier nummerierten Phasen. Das dritte ist der Schlusssatz über die Wiederholung des Kreislaufs. Dokument zwei und drei tragen beide die vollständigen Metadaten "Header 1" plus "Header 2".
Das heißt nicht ein Dokument pro physischer Zeile. Die vier Listeneinträge bleiben als ein Block zusammen, während der letzte Absatz ein eigenes Dokument wird. Man bekommt feinere Inhaltsblöcke, an denen jeweils die richtigen Überschriften-Metadaten hängen.
Experiment 4: beide Optionen zusammen
Jetzt kombiniere strip_headers=False mit return_each_line=True. Fünf Dokumente kommen zurück, in dieser Reihenfolge: die oberste Überschrift, der Einleitungsabsatz, die Überschrift der zweiten Ebene, die Liste der Phasen und der letzte Absatz. Die Überschriften werden zu eigenständigen Dokumenten mit page_content='# The Water Cycle' und page_content='## Main Stages', und die Metadaten hängen weiterhin daran.
Die beiden Flags haben getrennte Aufgaben. Eines entscheidet, ob Überschriften im Inhalt bleiben. Das andere entscheidet, wie fein die Inhaltsblöcke sind. Zusammen werden Überschriften zu eigenen Dokumenten.
Wie die Hierarchie vererbt wird
Die Überschriftenliste war durchgehend # und ##, und das Dokument ist so verschachtelt: "The Water Cycle" oben, "Main Stages" darunter. Die Metadaten bilden diese Verschachtelung ab. Text direkt unter der obersten Überschrift bekommt nur {'Header 1': 'The Water Cycle'}. Text unter "Main Stages" bekommt beide Schlüssel. Kurz gesagt: Ein untergeordneter Abschnitt erbt die Metadaten seiner übergeordneten Überschrift, eine der nützlichsten Eigenschaften dieser Art des Aufteilens.
Experiment 5: einen zweiten Splitter dazunehmen
Ein Abschnitt aus der Aufteilung nach Überschriften kann für ein RAG-System immer noch zu groß sein. Die Lösung: Diese Documents in RecursiveCharacterTextSplitter geben, der sie in kleinere Stücke schneidet. Jedes kleinere Stück weiß weiterhin, zu welchem Abschnitt es gehört, und diese Metadaten helfen später, wenn man den Kontext eines gefundenen Chunks herausfinden muss.
Bei der Beispieldatei bleibt der Einleitungsabsatz ein einzelnes Document. Die vier nummerierten Phasen, bisher ein Block, werden zu vier getrennten Documents, je eines für Verdunstung, Kondensation, Niederschlag und Sammlung. Jedes davon behält die Metadaten "Header 1" und "Header 2". Die Reihenfolge zählt: Zuerst nutzt der Markdown-Splitter die Struktur des Dokuments, dann verkleinert der rekursive Splitter die Ergebnisse.
Fazit
An einer kleinen Datei hast du gesehen, dass MarkdownHeaderTextSplitter an Überschriften teilt und die Überschriftenhierarchie als Metadaten speichert. strip_headers steuert, ob der Überschriftentext im Inhalt bleibt, und return_each_line erzeugt feinere Documents. Zusammen mit RecursiveCharacterTextSplitter entstehen kleinere Chunks, ohne die Abschnittsinformation zu verlieren. Für strukturiertes Markdown auf dem Weg in RAG bekommt man mit dieser Kombination sowohl eine handhabbare Chunk-Größe als auch den Kontext jedes Stücks.