Головна / Статті

Розділення на фрагменти батьківського та дочірнього типу та ієрархічне індексування для покращення пошуку в RAG.

Чому невеликі фрагменти виграють у пошуку, але програють у отриманні відповіді, та як розділення на фрагменти батьківського та дочірнього типу, усунення дублікатів та ієрархічне індексування відновлюють контекст у процесі пошуку в RAG.

Видавець: REACTAPP.TOP10 хв читання2363 слів

Джерела: 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. Коли два верхніх дочірні елементи належать до одного батьківського елемента, що є поширеним, оскільки сусідні фрагменти одного розділу часто мають схожі оцінки, розгортання кожного з них окремо призведе до подвійного відображення тексту одного батька та марнування контексту. Видалення дублікатів на рівні батьківського елемента змушує кожен фрагмент контексту з’являтися лише один раз, незалежно від кількості зіставлених дочірніх елементів.

Щодо частини для кешування, модель тепер отримує речення, яке пройшло перевірку, разом із поясненням навколо нього, тож обидві частини надходять разом, незалежно від того, який дочірній елемент посів перше місце.

Чому це працює

  • Точність із контекстом. Дочірні елементи знаходять правильне місце, а батьківські елементи надають ту інформацію, яку один дочірній елемент не може містити.
  • Краще міркування. Моделі мають труднощі з розрізненими фрагментами, а батьківські елементи відновлюють логічну послідовність.
  • Комірка вигаданих деталей. Бачачи навколишні обмеження та пояснення, модель має менше причин заповнювати прогалини припущеннями.
  • Технічні матеріали. Документація API, SDK, інженерні вікі, наукові статті та юридичні тексти рідко складаються з одного речення, і саме виняток чи умова, які з’являються через два речення, часто є вирішальними.
  • Ієрархічне індексування

    Якщо структура «батько-дитина» має два рівні, то ієрархічне індексування передбачає багато рівнів. Замість одного батька та його дітей створюється структура, яка відображає спосіб організації документа. Приклад ієрархії може виглядати так.

    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 токенів, — та перевірте рівень відтворення інформації на кількох реальних запитаннях, перш ніж щось змінювати. Фіксуйте, які «батьківські» тексти використовуються для кожного запиту, щоб можна було виявити розділи, які завжди включаються та можуть бути занадто широкими. Обмежте кількість «батьківських» текстів, які надсилаються до моделі, адже три-чотири великі розділи вже можуть заповнити невелике вікно контексту, і зберігайте оригінальний дочірній текст для посилань, щоб користувачі могли бачити, яке саме речення відповідає.

    Порівняння двох підходів

    • Обсяг: підхід «батьківський-дочірній» працює з двома рівнями деталізації одного й того ж тексту, тоді як ієрархічний індексування моделює весь структурний план.
    • Основна перевага: підхід «батьківський-дочірній» зберігає контекст навколо знайденого результату, тоді як ієрархія звужує область пошуку з самого початку.
    • Витрати: підхід «батько-дитина» потребує зв’язків між дітьми та батьками, тоді як ієрархія вимагає виявлення структури та кроку маршрутизації на кожному рівні.
    • Підходженість: підхід «батько-дитина» підходить для більшості довгих документів, тоді як ієрархія підходить для великих, глибоко структурованих джерел.

    Вони добре поєднуються, оскільки ієрархія може закінчуватися блоками типу «батько-дитина» у нижній частині.

    Конвеєр, який можна використовувати

    1. Завантаження. Аналізувати документи та виявляти заголовки. Завантажувач має зберігати інформацію про заголовки, тобто визначати, який текст належить до якого розділу, замість того щоб об’єднувати все в один потік, адже наступні етапи залежать від цього.
    2. Розділення на блоки. Створювати батьківські блоки з повних розділів, а потім розділяти кожен з них на дочірні блоки, спочатку визначаючи межі батьківського блоку.
    3. Індексування. Вбудовуйте дочірні елементи та зберігайте посилання на батьківські елементи – або шляхом копіювання тексту батьківського елемента до метаданих кожного дочірнього, або шляхом зберігання parent_id для пошуку.
    4. Запит. Вбудовуйте запит, шукайте серед дочірніх елементів, беріть найкращі результати, отримуйте їхніх батьківських елементів з усуненням дублікатів, формуєте збагачений контекст та надсилаєте його до моделі.

    Помилки, яких слід уникати

    • Лише дочірні елементи. Це забезпечує точність, але втрачається контекст міркувань; це призводить до проблем у прикладі кешування, де частина B ніколи не отримує речення, яке доповнює відповідь.
    • Лише батьківські елементи. Це зберігає контекст, але знижує точність пошуку, оскільки вбудовування багатотематичної частини як одного вектора спотворює її так само, як і надто велика частина тексту.
    • Ігнорування структури. Якщо завантажувач видаляє заголовки, не залишається жодних меж розділів, які міг би використати ієрархічний маршрутизатор, яким би хорошим він не був.
    • Слабкі метадані. Без чітких посилань на батьківські елементи, таких як parent_text або parent_id, подальше розширення стає неможливим. Метадані потрібно фіксувати під час створення частини, оскільки їх неможливо відновити після її видалення.

    Де це дає найбільшу користь

    Ці підходи є особливо ефективними там, де документи є довгими, структурованими та взаємопов’язаними: корпоративні бази знань, внутрішня документація, боти підтримки, архіви юридичних та регуляторних документів, інженерні вікі та допоміжні інструменти для досліджень. У таких матеріалах жодний розмір частини не може одночасно забезпечити точність та контекст.

    RAG поступово переходить від простого пошуку та генерації тексту до моделі, яка спочатку аналізує структуровані знання, а потім робить висновки на основі знайденого. Розділення інформації на частини у форматі «батько-дитина» покращує локальне розуміння, тож там, де знаходиться відповідь, разом із нею надається пояснення. Ієрархічне індексування покращує глобальну навігацію, тож пошук починається у правильному регіоні, а не серед усього контенту. Плоске розділення інформації нагадує виривання сторінок з книги та довіру до моделі щодо вгадування сюжету. Обидві ці техніки залишають книгу зібраною, проте дозволяють знайти точний рядок. На практиці це означає менше відповідей, які майже правильні — що є причиною найбільшого розчарування користувачів, — а також шар пошуку, поведінку якого легше пояснити, коли хтось запитує, чому саме певний уривок потрапив до моделі.