Разбиение Markdown для RAG: пять экспериментов с заголовками в LangChain
Проверяем MarkdownHeaderTextSplitter по одной настройке за раз: метаданные заголовков, strip_headers, return_each_line и связка с RecursiveCharacterTextSplitter для RAG.
Нарезать Markdown-файл на окна фиксированной длины в символах — быстрый способ разрушить его структуру. Заголовки оказываются в одном фрагменте, их абзацы в другом, а список разрезается посреди пункта. Если этот текст питает RAG-конвейер, поиск от этого становится хуже. MarkdownHeaderTextSplitter из LangChain идёт другим путём: он режет по заголовкам и записывает в метаданные, под какими заголовками находился кусок текста.
Понять, что он делает, проще всего, меняя по одной настройке на крошечном документе и читая результат. Этим мы дальше и займёмся.
Что делает сплиттер
Класс разбивает Markdown-текст везде, где встречаются указанные вами заголовки. Возьмём такой миниатюрный документ:
# Header 1
Here is the test content of Header 1
## Header 2
1. Content
2. Content
3. Content
4. Content
Строки # и ## задают тексту форму. Сплиттер считает эти строки точками разреза и кладёт текст заголовка в метаданные каждого получившегося документа. Для RAG это удобно: каждый фрагмент помнит, из какого места исходного файла он взят.
Тестовый файл
Во всех экспериментах ниже используется один и тот же файл simple_water_cycle.md — намеренно маленькая страница о круговороте воды. В ней два уровня заголовков. Страницу открывает заголовок верхнего уровня «The Water Cycle», за ним одно вводное предложение: круговорот воды описывает, как вода перемещается в окружающей среде. Под заголовком второго уровня «Main Stages» идут четыре пронумерованных этапа (испарение, конденсация, осадки и сбор), а затем заключительное предложение о том, что цикл непрерывно повторяется. Названия этапов выделены жирным.
Подготовка
Начните с pathlib и прочитайте файл через его объект Path. Затем задайте список заголовков, по которым нужно разбивать. Каждый элемент — пара: сначала символ Markdown, потом ключ метаданных, под которым сохранить этот заголовок. Здесь пары такие: # соответствует «Header 1», а ## — «Header 2». Пора экспериментировать.
Эксперимент 1: настройки по умолчанию
Запустите сплиттер без дополнительных опций. Результат — два объекта Document.
В первом вводное предложение с метаданными {'Header 1': 'The Water Cycle'}. Во втором четыре этапа и заключительное предложение, а его метаданные содержат оба уровня: {'Header 1': 'The Water Cycle', 'Header 2': 'Main Stages'}.
Бросаются в глаза две вещи. Во-первых, сами строки заголовков по умолчанию не попадают в page_content. Текст # The Water Cycle так и не появляется в содержимом первого документа, потому что эта информация хранится в метаданных. Во-вторых, родительский заголовок следует за дочерним. Метаданные раздела «Main Stages» включают «Header 1», хотя этот заголовок стоит выше. Значит, сплиттер не просто режет текст. Он сохраняет иерархию заголовков документа через метаданные.
Эксперимент 2: оставить заголовки в тексте
Заголовки вырезаются из содержимого, если не сказать иначе. Это меняет настройка strip_headers=False. Теперь первый документ начинается с # The Water Cycle, за которым идёт вводное предложение, а второй — с ## Main Stages и затем списка.
Точки разреза и метаданные остаются прежними. Отличается только наличие текста заголовка внутри page_content. По умолчанию заголовок попадает только в метаданные, а с этим флагом — и в метаданные, и в содержимое. Это нужно, когда настоящие слова заголовка должны идти вместе с текстом к следующему потребителю, например к модели эмбеддингов или в промпт.
Эксперимент 3: return_each_line
Верните поведение заголовков по умолчанию и включите только return_each_line=True. Документов становится не два, а три. Первый — по-прежнему вводная часть. Второй — четыре пронумерованных этапа. Третий — заключительное предложение о повторении цикла. У второго и третьего документов полные метаданные «Header 1» и «Header 2».
Не стоит понимать это как один документ на каждую физическую строку. Четыре пункта списка остаются вместе одним блоком, а последний абзац становится отдельным документом. Вы получаете более мелкие блоки содержимого, к каждому из которых привязаны правильные метаданные заголовков.
Эксперимент 4: обе опции вместе
Теперь объедините strip_headers=False с return_each_line=True. Возвращается пять документов в таком порядке: верхний заголовок, вводный абзац, заголовок второго уровня, список этапов и последний абзац. Заголовки превращаются в самостоятельные документы с page_content='# The Water Cycle' и page_content='## Main Stages', и метаданные по-прежнему на месте.
Два флага выполняют разную работу. Один решает, остаются ли заголовки в содержимом. Другой — насколько мелкими будут блоки. Вместе они делают заголовки отдельными документами.
Как наследуется иерархия
Во всех экспериментах список заголовков был # и ##, а документ вложен так: «The Water Cycle» наверху, «Main Stages» под ним. Метаданные повторяют эту вложенность. Текст прямо под верхним заголовком получает только {'Header 1': 'The Water Cycle'}. Текст под «Main Stages» получает оба ключа. Короче говоря, дочерний раздел наследует метаданные родительского заголовка, и это одно из самых полезных свойств такого разбиения.
Эксперимент 5: добавить второй сплиттер
Раздел, полученный разбиением по заголовкам, всё равно может оказаться слишком большим для RAG-системы. Решение — передать эти объекты Document в RecursiveCharacterTextSplitter, который порежет их на более мелкие куски. Каждый мелкий кусок по-прежнему знает, к какому разделу относится, и эти метаданные пригодятся позже, когда нужно будет понять контекст найденного фрагмента.
На примере файла вводный абзац остаётся одним Document. Четыре пронумерованных этапа, которые были одним блоком, превращаются в четыре отдельных Document — по одному на испарение, конденсацию, осадки и сбор. Каждый из них сохраняет метаданные «Header 1» и «Header 2». Порядок важен: сначала Markdown-сплиттер использует структуру документа, затем рекурсивный сплиттер уменьшает результаты.
Выводы
На одном маленьком файле видно, что MarkdownHeaderTextSplitter разбивает текст по заголовкам и сохраняет их иерархию в метаданных. strip_headers управляет тем, остаётся ли текст заголовка в содержимом, а return_each_line даёт более мелкие Document. В паре с RecursiveCharacterTextSplitter получаются небольшие фрагменты без потери сведений о разделе. Для структурированного Markdown, который идёт в RAG, такая связка даёт и удобный размер фрагментов, и контекст вокруг каждого из них.