Разбиение на фрагменты родитель-дочерний и иерархическое индексирование для улучшения поиска в RAG
Почему небольшие фрагменты побеждают в поиске, но теряют ответ, и как методы разбиения на фрагменты родитель-дочерний, удаление дубликатов и иерархическое индексирование восстанавливают контекст при поиске в RAG.
Источники: Parent–Child Chunking and Hierarchical Indexing in RAG: A Practical Guide to Better Retrieval
Когда система RAG возвращает ответ, который почти верен, но не содержит того важного деталя, проблема обычно заключается в способе разделения текста, а не в эмбеддингах. Два метода напрямую решают эту проблему: метод разбиения на части «родитель-дочерь», при котором ищутся небольшие фрагменты, но читаются крупные, и иерархическое индексирование, которое сужает диапазон поиска путем следования собственной структуре документа. Оба метода направлены на то, чтобы одновременно обеспечить языковой модели точность и контекст.
Почему разделение текста — сложный этап
Основная схема довольно проста: документы делятся на фрагменты, эти фрагменты встраиваются, сохраняются, затем для запроса выбираются лучшие k фрагментов, которые передаются в модель. Когда возникают проблемы, они обычно связаны с первым же шагом. Слишком крупные фрагменты снижают степень релевантности и искажают представление, тогда как слишком маленькие фрагменты отсекают окружающие предложения, от которых зависит ответ на вопрос. Использование фиксированного размера, рекурсивного разделения, семантического анализа и разделения по окну предложений — всё это попытки сбалансировать этот конфликт внутри одной текстовой единицы.
Стоит рассмотреть другой подход: вместо поиска одного идеального размера использовать два размера, каждый из которых настроен для выполнения определённой задачи. Прежде чем внедрять такое решение, полезно точно понять, где проваливается использование одного размера.
Пример неудачи с небольшим фрагментом
Рассмотрим отрывок из технического документа, состоящий из двух предложений. В первом предложении говорится, что кэширование значительно ускоряет работу системы. Во втором предупреждается, что неправильная аннулирование кэша может привести к оставлению устаревших данных.
Небольшой инструмент разделения может разделить этот отрывок на две части. Часть А — это предложение, хвалящее производительность, а часть Б — предложение о проблемах с устаревшими данными из-за некорректной аннулирования кэша. Теперь зададимся вопросом: в чем заключается риск кэширования? Приведённый ниже скрипт включает этот вопрос и обе части, затем сравнивает их с помощью косинусного сходства.
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2")
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
query = "What is a risk of caching?"
chunk_a = "Caching improves system performance significantly."
chunk_b = "However, improper cache invalidation can lead to stale data issues."
query_embedding = model.encode(query)
score_a = cosine_similarity(query_embedding, model.encode(chunk_a))
score_b = cosine_similarity(query_embedding, model.encode(chunk_b))
print(f"Chunk A similarity: {score_a:.4f} - '{chunk_a}'")
print(f"Chunk B similarity: {score_b:.4f} - '{chunk_b}'")
При его запуске выводится степень сходства каждого фрагмента с вопросом.
Chunk A similarity: 0.5891 — 'Caching improves system performance significantly.'
Chunk B similarity: 0.5103 — 'However, improper cache invalidation can lead to stale data issues.'
Часть A показывает более высокий балл — около 0,59 против примерно 0,51, хотя именно часть B отвечает на вопрос. В обеих упоминается кэширование, поэтому они кажутся связанными, но у модели эмбеддингов нет особых причин связывать слово «риск» с предложением о устаревших данных, в котором это слово вообще не используется. Ретривер, выбирающий только лучшее совпадение, с уверенностью передаст модели неверную часть текста.
Урок заключается в том, что семантическое сходство не гарантирует полноты информации. Часть текста может быть правильно признана релевантной, но при этом не содержать необходимого факта, причем чем меньше часть, тем выше вероятность того, что этот факт находится в соседнем предложении.
Разделение на родительские и дочерние части
Идея заключается в одновременном хранении двух уровней детализации одного и того же текста, причем каждый из них выполняет свою функцию.
Детали текста небольшие и точные. Обычно они включают от 100 до 400 токенов и предназначены для поиска с высоким уровнем восстановления результатов. Их небольшой размер позволяет получать более четкие векторы, поскольку меньше несвязанного материала снижает степень точности представления каждого вектора. Однако использование всего нескольких слов оказывается контрпродуктивным, так как такой фрагмент может не иметь самостоятельного смысла, необходимого для надежного совпадения.
Родительские чанки большие и имеют контекстуальную направленность. Они обычно включают от 800 до 3000 токенов или более, охватывая целый раздел или логическую единицу, и предназначены для рассуждений, а не для поиска совпадений. В примере кэширования родительский чанк, содержащий полный текст, включает обе части идеи, поэтому независимо от того, какой дочерний чанк совпадет, модель всё равно видит полную картину. Верхняя граница определяется окном контекста модели, поскольку несколько крупных родительских чанков, объединённых в один запрос, могут занять всё пространство, необходимое для других элементов запроса.
Эта связь можно описать одним предложением: дочерние чанки — это то, что ищут, а родительские — то, что читают. Дочерний чанк должен быть достаточно точным, чтобы победить в соревновании по сходству, а его родительский чанк обеспечивает модели контекст, от которого зависит ответ дочернего чанка.
Как это выглядит на практике
Представьте раздел с названием «Оптимизация производительности базы данных». Механизм разделения на родительские и дочерние элементы работает в два этапа. Сначала он определяет границы родительского элемента — весь раздел, охватывающий темы индексации, оптимизации запросов, кэширования и связанных с ними компромиссов. Затем этот же текст разделяется на более мелкие дочерние элементы, например:
- дочерний элемент о том, как индексация ускоряет выполнение запросов за счёт сокращения времени сканирования,
- дочерний элемент о кэшировании, снижающем необходимость повторных вычислений, но создающем риск устаревания данных,
- дочерний элемент о переписывании SQL-запросов с целью их оптимизации.
Схема работает благодаря тому, что каждый дочерний элемент помнит, откуда он происходит. Самый простой способ — хранить полный текст родительского элемента в метаданных каждого дочернего элемента, как это делается в этой функции индексации.
def build_parent_child_index(document_section, parent_text, child_splitter, embedding_model):
child_chunks = child_splitter(parent_text)
records = []
for child_text in child_chunks:
records.append({
"text": child_text,
"embedding": embedding_model.encode(child_text),
"parent_text": parent_text # full parent stored directly on every child
})
return records
parent_text = """Indexing improves query speed by reducing scan time across large tables.
Caching reduces repeated computation but may introduce staleness if not invalidated properly.
Query optimization involves rewriting SQL queries to use more efficient execution plans."""
child_texts = [
"Indexing improves query speed by reducing scan time across large tables.",
"Caching reduces repeated computation but may introduce staleness if not invalidated properly.",
"Query optimization involves rewriting SQL queries to use more efficient execution plans.",
]
records = [
{"text": t, "parent_text": parent_text} for t in child_texts
]
for r in records:
print(f"Child: {r['text'][:50]}...")
print(f" → linked to parent ({len(r['parent_text'])} chars)\n")
Копирование родителя в каждый ребенок требует дополнительного пространства хранения, но не нуждается в отдельном поиске во время запроса, поскольку родитель уже содержится в записи ребенка. Альтернатива — хранить только parent_id и загружать родителя по мере необходимости, что экономит место в масштабе, но добавляет один шаг поиска. В любом случае каждый ребенок остается доступным для независимого поиска, как и любой другой фрагмент, при этом имеется прямой путь к его полному контексту.
Загрузка небольших данных, расширение больших
Это изменяет обычный порядок действий. Стандартный подход RAG предусматривает загрузку данных, их отправку и ответ. Версия с родителем и ребенком начинается с загрузки ребенка, затем расширения информации до уровня родителя, отправки обогащенного контекста и последующего ответа. Ниже показано реализованное таким образом выполнение, продолжая пример кэширования.
def parent_child_retrieve(query, child_records, embedding_model, top_k=3):
query_embedding = embedding_model.encode(query)
scored = [
(np.dot(query_embedding, r["embedding"]) /
(np.linalg.norm(query_embedding) * np.linalg.norm(r["embedding"])), r)
for r in child_records
]
scored.sort(key=lambda x: x[0], reverse=True)
top_children = scored[:top_k]
# Expand each matched child to its parent - but deduplicate first
seen_parents = set()
expanded_context = []
for score, record in top_children:
parent = record["parent_text"]
if parent not in seen_parents:
expanded_context.append(parent)
seen_parents.add(parent)
return expanded_context
for r in records:
r["embedding"] = model.encode(r["text"])
context = parent_child_retrieve("What is a risk of caching?", records, model, top_k=2)
print("Context sent to the LLM:\n")
for c in context:
print(c)
Обратите внимание на проверку seen_parents. Когда два верхних дочерних элемента принадлежат к одному и тому же родителю, что часто бывает, поскольку соседние фрагменты одного раздела часто имеют схожие оценки, разделение их по отдельности приведёт к тому, что текст одного и того же родителя будет отправлен дважды, что приведёт к потере контекста. Удаление дубликатов на уровне родителя позволяет каждому фрагменту контекста появиться лишь один раз, независимо от того, сколько дочерних элементов совпало.
Что касается раздела кэширования, то теперь модель получает предложение, победившее в сравнении, вместе с объяснением к нему, так что обе части поступают вместе, независимо от того, какой дочерний элемент занял первое место.
Почему это работает
- Точность с учётом контекста. Дочерние элементы находят подходящее место, а родители предоставляют информацию, которую один дочерний элемент не может передать.
- Лучшее обоснование. Модели испытывают трудности с разрозненными фрагментами, а родитель восстанавливает непрерывность повествования.
Иерархическое индексирование
Если отношения «родитель-дочерний» существуют на двух уровнях, то при иерархическом индексировании их много. Вместо одного родителя и его детей создается структура, отражающая способ организации документа. Пример иерархии может выглядеть так.
Document: "Cloud Architecture Guide"
Section: Networking
Subsection: Load Balancing
Chunk: Round-robin method
Chunk: Least connections method
Subsection: CDN usage
Section: Security
Subsection: IAM policies
Subsection: Encryption
Обратите внимание, откуда берется эта иерархия. В некоторых системах поиск осуществляется по категориям — таким как отдел, тема или метаданные, присваиваемые независимо от конкретного документа. Здесь же иерархия формируется на основе собственных заголовков и разделов документа, то есть того порядка, в котором его автор его структурировал. Оба подхода сужают объем поиска заранее, но один организует всю базу знаний по темам, а другой — отдельный документ по его оглавлению.
Большинство документов — это не просто текст. Заголовки и подразделы уже содержат информацию о взаимосвязи идей. Иерархическое индексирование сохраняет эту структуру, позволяя системе сначала найти нужный раздел, затем подраздел и, наконец, точный фрагмент текста, подобно тому, как человек сначала просматривает оглавление, прежде чем приступить к детальному чтению.
Практическое применение поиска
Предположим, что запрос звучит так: «Как работает балансировка нагрузки по принципу наименьшего количества соединений?» Система находит раздел «Сети», сужает выбор до подраздела «Балансировка нагрузки», а затем извлекает текст, объясняющий, что метод наименьшего количества соединений выбирает сервер с наименьшим количеством активных соединений.
Каждый шаг сужения выбора требует конкретного механизма, поскольку ничего из этого не происходит само по себе. Варианты включают обученный классификатор, предсказывающий, к какому разделу относится запрос, вызов большой языковой модели, которая читает запрос и выбирает наиболее вероятный раздел из известного списка, или сопоставление на основе правил с заголовками разделов и ключевыми словами. Любой из выбранных методов применяется на каждом уровне — сначала для раздела, затем для подраздела, прежде чем начнется поиск на уровне текстовых блоков.
Стоит ли добавлять дополнительную глубину? Для большинства документов достаточно двух уровней. Полная многоуровневая иерархия оправдана для длинных, сложно структурированных материалов, в которых один раздел содержит столько отдельных подтем, что поиск во всем объеме снизит релевантность результатов. Короткая страница с часто задаваемыми вопросами или статья на одну тему редко требует более двух уровней, в то время как сотнистраничный руководство по архитектуре с десятками подразделов — возможно. Как и при любом решении, связанном с индексацией, сначала оцените ситуацию, прежде чем увеличивать сложность.
Краткие советы по настройке
Начните с размеров дочерних элементов, находящихся примерно посередине диапазона — около 200 токенов, и проверьте уровень воспроизведения на нескольких реальных вопросах, прежде чем что-либо менять. Фиксируйте, какие родительские элементы используются для каждого запроса, чтобы вы могли выявить разделы, которые всегда включаются и могут быть слишком обширными. Ограничьте количество родительских элементов, отправляемых в модель, поскольку три-четыре крупных раздела уже могут заполнить небольшое окно контекста, и сохраняйте оригинальный текст дочерних элементов для цитирования, чтобы пользователи могли точно увидеть, какое предложение совпало.
Сравнение двух подходов
- Область применения: подход «родитель-дочерний» работает с двумя уровнями детализации одного и того же текста, в то время как модели иерархического индексирования моделируют всю структуру целиком.
- Основное преимущество: подход «родитель-дочерний» сохраняет контекст вокруг совпадения, тогда как иерархический подход сужает область поиска с самого начала.
- Стоимость: подход «родитель-дети» требует связей между детьми и родителями, тогда как иерархия нуждается в обнаружении структуры и шаге маршрутизации на каждом уровне.
- Подходящесть: подход «родитель-дети» подходит для большинства длинных документов, в то время как иерархия подходит для крупных источников с глубокой структурой.
Они хорошо сочетаются, поскольку иерархия может заканчиваться блоками «родитель-дети» в нижней части.
Поток обработки, который можно использовать
- Прием данных. Анализируются документы и выявляются заголовки. Загрузчик должен сохранять информацию о заголовках, то есть определять, какой текст относится к какому разделу, вместо того чтобы объединять всё в один поток, поскольку последующие этапы от этого зависят.
- Разбиение на блоки. Сначала из полных разделов формируются родительские блоки, затем каждый из них делится на дочерние блоки с определением границ родительского блока.
- Индексация. Встраивайте дочерние элементы и храните ссылки на родительские элементы: либо путем копирования текста родителя в метаданные каждого дочернего элемента, либо путем хранения
parent_idдля поиска. - Запрос. Встраивайте вопрос, ищите среди дочерних элементов, берите наиболее подходящие результаты, загружайте их родительские элементы с устранением дубликатов, собираете дополненный контекст и отправляете его в модель.
Ошибки, которых следует избегать
- Только дочерние элементы. Это повышает точность, но приводит к потере контекста для логических рассуждений; это и есть проблема в примере кэширования, когда блок B так и не получает предложение, завершающее ответ.
- Только родительские элементы. Это сохраняет контекст, но снижает точность поиска, поскольку встраивание многотематического раздела в один вектор приводит к его размытию так же, как и слишком большой блок данных.
- Игнорирование структуры. Если загрузчик удаляет заголовки, для иерархического маршрутизирования не остается границ секций, каким бы хорошим ни был маршрутизатор.
- Слабые метаданные. Без явных ссылок на родительские элементы, таких как
parent_textилиparent_id, дальнейшее расширение становится невозможным. Метаданные необходимо фиксировать при создании блока, поскольку их нельзя восстановить после его удаления.
Где это приносит наибольшую пользу
Эти подходы особенно эффективны там, где документы длинные, структурированные и взаимосвязанные: корпоративные базы знаний, внутренняя документация, боты поддержки, архивы юридических и регуляторных документов, инженерные вики и помощники для исследований. В таких случаях ни один размер блока не может одновременно обеспечить точность и контекстность.
RAG постепенно переходит от простого подхода «поиск — генерация» к модели, которая сначала просматривает структурированные знания, а затем анализирует найденную информацию. Разделение на части в формате «родитель-дочерний» способствует локальному пониманию, поэтому там, где происходит совпадение, с ним приходит и соответствующее объяснение. Иерархическое индексирование улучшает глобальную навигацию, позволяя начинать поиск в нужном регионе, а не среди всего контента. Плоское разделение на части напоминает вырывание страниц из книги и доверие модели в том, что она сама догадается о сюжете. Обе эти техники сохраняют структуру книги, но позволяют находить конкретные строки. На практике это означает меньше ответов, которые почти верны — именно это является причиной разочарования пользователей, — а также слой поиска, поведение которого легче объяснить, когда кто-то спрашивает, почему именно определенный отрывок попал в модель.