Розбиття 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, така зв'язка дає і зручний розмір фрагментів, і контекст навколо кожного з них.