Podział na fragmenty rodzic-dziecko i indeksowanie hierarchiczne dla lepszej funkcjonalności systemu RAG.
Dlaczego małe fragmenty wygrywają w procesie wyszukiwania, ale tracą sens, oraz w jaki sposób podział na fragmenty rodzic-dziecko, eliminacja duplikatów i indeksowanie hierarchiczne przywracają kontekst w systemie RAG.
Źródła: Parent–Child Chunking and Hierarchical Indexing in RAG: A Practical Guide to Better Retrieval
Gdy system RAG zwraca odpowiedź, która jest prawie poprawna, ale brakuje w niej kluczowego szczegółu, słabym punktem jest zazwyczaj sposób podziału tekstu, a nie embeddingi. Dwie techniki bezpośrednio adresują ten problem: podział na fragmenty typu rodzic-dziecko, który przeszukuje małe części, ale czyta większe, oraz indeksowanie hierarchiczne, które zawęża wyszukiwanie poprzez śledzenie własnego schematu dokumentu. Obie mają na celu zapewnienie modelowi językowemu precyzji i kontekstu jednocześnie.
Dlaczego podział jest trudnym krokiem
Bazowy proces jest prosty: dzieli się dokumenty na fragmenty, wstawia je, przechowuje, pobiera najważniejsze k elementy dla danej zapytania i przekazuje je modelowi. Gdy występuje awaria, zazwyczaj wynika ona z pierwszego kroku. Fragmenty zbyt duże osłabiają istotność informacji i zamazują reprezentację semantyczną, natomiast te zbyt małe odcinają zdania, od których zależy odpowiedź na pytanie. Dzielenie tekstu o stałej wielkości, rekurencyjne metody, podejścia semantyczne oraz rozdzielanie na okna zdań to wszystko próby zrównoważenia tej sprzeczności w ramach jednej jednostki tekstu.
Warto spróbować innej koncepcji: przestać szukać jednej idealnej wielkości i używać dwóch różnych rozmiarów, każdego dostosowanego do innego zastosowania. Zanim wprowadzi się taką poprawkę, pomocne jest dokładne zidentyfikowanie momentu, w którym pojedynczy rozmiar zawodzi.
Niewielki przykład niepowodzenia
Rozważmy fragment składający się z dwóch zdań z dokumentu technicznego. Pierwsze zdanie stwierdza, że cache sprawia, że system działa znacznie szybciej. Drugie ostrzega, że niewłaściwe unieważnianie cache może pozostawić przestarzałe dane.
Niewielki narzędzie dzielące mogłoby podzielić ten tekst na dwa fragmenty. Fragment A to zdanie chwalące wydajność, a fragment B to zdanie o przestarzałych danych spowodowanych błędnym unieważnianiem cache. Teraz pytanie: jaki jest ryzyko stosowania cache? Poniższy skrypt zawiera to pytanie oraz oba fragmenty, a następnie porównuje je za pomocą podobieństwa kosinowego.
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}'")
Uruchomienie go wyświetla stopień podobieństwa każdego fragmentu do pytania.
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.'
Fragment A uzyskuje wyższy wynik, około 0,59 w porównaniu z mniej więcej 0,51, mimo że to fragment B odpowiada na pytanie. Obie wzmianki dotyczą cacheowania, więc wydają się powiązane, a model embeddingowy nie ma żadnego szczególnego powodu, by łączyć słowo „ryzyko” z zdaniem o przestarzałych danych, w którym to słowo w ogóle nie występuje. System wyszukiwania, który bierze pod uwagę tylko najlepsze dopasowanie, z pewnością przekazałby modelowi niewłaściwą część tekstu.
Lekcją jest to, że podobieństwo semantyczne nie gwarantuje kompletności. Fragment może być słusznie uznany za istotny dla tematu, a mimo to nie zawierać informacji potrzebnych do odpowiedzi na pytanie, a im mniejszy jest fragment, tym większe jest prawdopodobieństwo, że ta informacja znajduje się w sąsiednim zdaniu.
Dzielenie na fragmenty rodzic-dziecko
Idea polega na przechowywaniu jednocześnie dwóch poziomów szczegółowości tego samego tekstu, z których każdy pełni inną funkcję.
Kawałki tekstowe dla dzieci są małe i precyzyjne. Zazwyczaj składają się z 100 do 400 tokenów i służą do wyszukiwania z wysokim odzyskiwaniem wyników. Ich niewielki rozmiar zapewnia bardziej precyzyjne wektory, ponieważ mniej niepowiązanego materiału zmniejsza wpływ czynników zakłócających znaczenie każdego wektora. Jednak redukcja do zaledwie kilku słów ma negatywny efekt, gdyż taki fragment może nie posiadać samodzielnego znaczenia niezbędnego do wiarygodnego dopasowania.
Klocki nadrzędne są duże i mają kontekstualny charakter. Często obejmują od 800 do 3000 tokenów lub więcej, pokrywając całą sekcję lub jednostkę logiczną, i służą do rozumowania, a nie do dopasowywania. W przykładzie z buforowaniem, klock nadrzędny zawierający cały tekst obejmuje obie części idei, więc niezależnie od tego, który klock potomny się dopasuje, model nadal widzi pełen obraz sytuacji. Górna granica wynika z okna kontekstowego modelu, ponieważ kilka dużych klocków nadrzędnych połączonych w jeden prompt może zastąpić wszystko inne, co prompt musi zawierać.
Związek ten można opisać jednym zdaniem: klocki potomne to te, które są wyszukiwane, a klocki nadrzędne to te, które są czytane. Klock potomny musi być wystarczająco precyzyjny, aby wygrać konkurs na podobieństwo, a jego klock nadrzędny zapewnia modelowi kontekst, od którego zależy odpowiedź tego klocka potomnego.
Jak to wygląda w praktyce
Załóżmy sekcję z tytułem „Optymalizacja wydajności bazy danych”. Narzędzie do dzielenia na elementy rodzic-dziecko działa w dwóch etapach. Najpierw określa granice elementu rodzica, czyli całą sekcję obejmującą indeksowanie, optymalizację zapytań, buforowanie oraz związane z nimi kompromisy. Następnie dzieli ten sam tekst na mniejsze elementy dziecięce, na przykład:
- element dziecięcy o tym, jak indeksowanie przyspiesza zapytania poprzez skrócenie czasu skanowania,
- element dziecięcy o tym, jak buforowanie zmniejsza konieczność wielokrotnych obliczeń, choć niesie ze sobą ryzyko przestarzałych danych,
- element dziecięcy o przepisywaniu zapytań SQL w celu ich optymalizacji.
Umożliwia to działanie schematu fakt, że każdy element dziecięcy pamięta, skąd pochodzi. Najprostszą metodą jest przechowywanie pełnego tekstu elementu rodzica w metadanych każdego elementu dziecięcego, jak w tej funkcji indeksowania.
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")
Kopiowanie elementu nadrzędnego do każdego potomnego wymaga dodatkowej przestrzeni przechowywania, ale nie wymaga oddzielnego wyszukiwania podczas realizacji zapytania, ponieważ element nadrzędny znajduje się już w rekordzie potomka. Alternatywą jest przechowywanie jedynie parent_id i pobieranie elementu nadrzędnego w razie potrzeby, co oszczędza przestrzeń w skali dużych zbiorów, ale dodaje jeden krok wyszukiwania. W obu przypadkach każdy potomek pozostaje możliwy do samodzielnego wyszukiwania, podobnie jak każdy inny element, przy jednoczesnym posiadaniu bezpośredniej ścieżki prowadzącej do jego pełniejszego kontekstu.
Pobierz małe dane, rozwiń duże
To odwraca standardowy tok działania. W standardowym podejściu RAG następuje kolejność: pobranie danych, wysłanie ich, udzielenie odpowiedzi. W wersji z elementami nadrzędnymi i potomnymi kolejność wygląda tak: pobranie potomka, rozszerzenie informacji o jego elemencie nadrzędnym, wysłanie wzbogaconego kontekstu, udzielenie odpowiedzi. Poniżej przedstawiono taką implementację, kontynuując przykład z buforowaniem.
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)
Zwróć uwagę na sprawdzenie seen_parents. Gdy dwa najwyższe elementy potomne należą do tego samego rodzica, co jest częste, ponieważ sąsiadujące fragmenty jednej sekcji często mają podobne oceny, rozszerzanie każdego z nich osobno spowodowałoby wysłanie tekstu tego samego rodzica dwa razy i zmarnowanie kontekstu. Usunięcie duplikatów na poziomie rodzica sprawia, że każdy fragment kontekstu pojawia się tylko raz, niezależnie od tego, ilu elementów potomnych się do niego dopasowało.
W przypadku fragmentu do przechowywania w pamięci cache model otrzymuje teraz zdanie, które wygrało dopasowanie, wraz z towarzyszącym mu wyjaśnieniem, dzięki czemu obie części docierają razem, niezależnie od tego, który element potomny zajął pierwsze miejsce.
Dlaczego to działa
- Dokładność z kontekstem. Elementy potomne znajdują odpowiednie miejsce, a rodzice dostarczają informacji, których pojedynczy element potomny nie może zawrzeć.
- Lepsze rozumowanie. Modele mają trudności z niepowiązanymi fragmentami, a rodzic przywraca ciągłość narracji.
Indeksowanie hierarchiczne
Jeśli relacja rodzic-dziecko ma dwa poziomy, to indeksowanie hierarchiczne obejmuje wiele poziomów. Zamiast jednego rodzica i jego dzieci tworzy się strukturę, która odzwierciedla sposób organizacji dokumentu. Przykładowa hierarchia może wyglądać w ten sposób.
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
Zwróć uwagę, skąd pochodzi ta hierarchia. Niektóre systemy kierują zapytania według kategorii, takich jak dział, temat czy tagi metadanych przypisane niezależnie od konkretnego dokumentu. Tutaj hierarchia wynika z samych nagłówków i sekcji dokumentu, z sposobu, w jaki jego autor go uporządkował. Obie metody zawężają zakres wyszukiwania, ale jedna organizuje całą bazę wiedzy według tematów, a druga pojedynczy dokument według jego spisu treści.
Większość dokumentów to nie zwykły tekst. Nagłówki i podsekcje już zawierają informacje o wzajemnych powiązaniach pomysłów. Indeksowanie hierarchiczne zachowuje tę strukturę i umożliwia systemowi znalezienie odpowiedniej sekcji, następnie podsekcji, a w końcu dokładnego fragmentu tekstu, podobnie jak osoba, która przegląda spis treści przed dokładną lekturą.
W praktyce – wyszukiwanie
Załóżmy, że zapytanie brzmi: „Jak działa równoważenie obciążenia na podstawie najmniejszej liczby połączeń?”. System znajduje sekcję dotyczącą sieci, zawęża wybór do podsekcji o równoważeniu obciążenia, a następnie pobiera fragment wyjaśniający, że metoda najmniejszej liczby połączeń wybiera serwer z najmniejszą liczbą aktywnych połączeń.
Każdy krok zawężania wymaga rzeczywistego mechanizmu, ponieważ nic z tego nie dzieje się samo z siebie. Możliwościami są na przykład wyszkolony klasyfikator, który przewiduje, do której sekcji należy zapytanie, wywołanie modelu językowego, który czyta zapytanie i wybiera najbardziej prawdopodobną sekcję z danej listy, lub dopasowanie oparte na regułach do tytułów sekcji i słów kluczowych. Niezależnie od wybranej metody jest ona stosowana na każdym poziomie – raz dla sekcji, a ponownie dla podsekcji – zanim rozpocznie się wyszukiwanie na poziomie fragmentów tekstu.
Czy dodatkowa głębokość jest warta zachodu? Dwa poziomy wystarczają dla większości dokumentów. Pełna, wielopoziomowa hierarchia ma sens w przypadku długich, głęboko zstrukturyzowanych materiałów, w których jedna sekcja zawiera tak wiele odrębnych podtematów, że wyszukiwanie ich wszystkich naraz osłabiłoby trafność wyników. Krótka strona z FAQ lub artykuł na jeden temat rzadko wymaga więcej niż dwóch poziomów, natomiast stostronicowy przewodnik po architekturze z dziesiątkami podsekcji może ich potrzebować. Podobnie jak przy każdej decyzji dotyczącej indeksowania, należy najpierw ocenić sytuację, zanim dodaje się dodatkową złożoność.
Szybkie wskazówki dotyczące dostosowań
Zacznij od rozmiarów podelementów znajdujących się mniej więcej w środku zakresu, około 200 tokenów, i sprawdź stopień odzwierciedlenia w kilku rzeczywistych pytaniach przed jakimikolwiek zmianami. Zapisuj, które elementy rodzicielskie są wykorzystywane dla każdego zapytania, aby móc zidentyfikować sekcje, które są zawsze włączane i mogą być zbyt szerokie. Ogranicz liczbę elementów rodzicielskich wysyłanych do modelu, ponieważ trzy lub cztery duże sekcje mogą już wypełnić niewielkie okno kontekstowe, a oryginalny tekst podelementów musi pozostać dostępny do cytowania, aby użytkownicy mogli dokładnie zobaczyć, która zdanie pasuje.
Porównanie obu metod
- Zakres: metoda rodzic-dziecko pracuje z dwiema różnymi granularnościami tego samego tekstu, natomiast indeksowanie hierarchiczne modeluje cały strukturę.
- Główna zaleta: metoda rodzic-dziecko zachowuje kontekst wokół pasującego wyniku, podczas gdy hierarchia ogranicza obszar od początku wyszukiwania.
- Koszt: podejście rodzic-dziecko wymaga powiązań między elementami dziećmi a ich rodzicami, natomiast hierarchia wymaga wykrywania struktury oraz kroku routingu na każdym poziomie.
- Przydatność: podejście rodzic-dziecko nadaje się do większości długich dokumentów, natomiast hierarchia jest odpowiednia dla dużych, głęboko zstrukturyzowanych źródeł.
Dobrze się one łączą, ponieważ hierarchia może kończyć się na grupach typu rodzic-dziecko na najniższym poziomie.
Proces, który można wdrożyć
- Przyjmowanie danych. Analizuj dokumenty i wykrywaj nagłówki. Narzędzie do ładowania musi przechowywać informacje o nagłówkach, czyli określać, który tekst należy do której sekcji, zamiast łączyć wszystko w jeden strumień, ponieważ kolejne etapy od tego zależą.
- Dzielenie na fragmenty. Twórz fragmenty rodzicielskie z pełnych sekcji, a następnie dziel każdą z nich na fragmenty dziecięce, najpierw określając granice rodzicielskie.
- Indeksowanie. Wbuduj potomki i przechowaj linki do rodziców – albo poprzez skopiowanie tekstu rodzica do metadanych każdego potomka, albo poprzez przechowywanie
parent_idw celu szybkiego wyszukiwania. - Zapytanie. Wbuduj pytanie, przeszukaj potomki, wybierz najbardziej odpowiednie wyniki, pobierz ich rodziców z usunięciem duplikatów, stwórz wzbogacony kontekst i prześlij go do modelu.
Błędy, których należy unikać
- Tylko potomki. Zyskujesz precyzję, ale tracisz kontekst rozumowania, co jest problemem w przykładzie z buforowaniem – w tym przypadku fragment B nigdy nie otrzymuje zdania, które uzupełnia odpowiedź.
- Tylko rodzice. Zachowujesz kontekst, ale tracisz dokładność wyszukiwania, ponieważ wbudowanie sekcji obejmującej kilka tematów jako jednego wektora sprawia, że staje się on niejasny, podobnie jak zbyt duży fragment.
- Ignorowanie struktury. Jeśli narzędzie ładowania odrzuca nagłówki, nie pozostaje żadna granica sekcji, której mógłby skorzystać hierarchiczny routowanie, bez względu na to, jak dobre jest to narzędzie.
- Słabe metadane. Bez wyraźnych odnośników do elementów nadrzędnych, takich jak
parent_textlubparent_id, późniejsze rozszerzenie staje się niemożliwe. Metadane muszą zostać zapisane w momencie tworzenia fragmentu, ponieważ nie można ich odzyskać po ich odrzuceniu.
Gdzie przynosi największe korzyści
Te wzorce sprawdzają się tam, gdzie dokumenty są długie, ustrukturyzowane i ze sobą powiązane: bazy wiedzy korporacyjnej, dokumentacja wewnętrzna, boty obsługi klienta, archiwa prawne i związane z przepisami, wiki inżynieryjne oraz asystenci badawczy. W takich materiałach żaden pojedynczy rozmiar fragmentu nie może zapewnić zarówno precyzji, jak i kontekstu.
RAG przechodzi od prostej metody wyszukiwania a następnie generowania treści ku modelowi, który najpierw przegląda uporządkowaną wiedzę, a potem analizuje to, co znalazł. Dzielenie treści na grupy rodzic-dziecko poprawia zrozumienie na poziomie lokalnym, dzięki czemu w miejscu, gdzie znajduje się pasująca informacja, dostępna jest również odpowiednia wyjaśnienie. Hierarchiczne indeksowanie ułatwia nawigację na poziomie globalnym, więc wyszukiwanie rozpoczyna się we właściwym obszarze, a nie w całym zbiorze danych. Proste dzielenie treści przypomina wyrwanie stron z książki i poleganie na modelu, który ma odgadnąć fabułę. Obie te metody pozostawiają książkę w całości, choć umożliwiają znalezienie konkretnej linijki tekstu. W praktyce oznacza to mniej odpowiedzi, które są prawie poprawne – co jest największym problemem irytującym użytkowników – oraz warstwę wyszukiwania, której działanie jest łatwiejsze do wyjaśnienia, gdy ktoś pyta, dlaczego dany fragment trafił do modelu.
Literatura pokrewna
- Wzbogacanie metadanych w RAG: filtry, cytaty, świeżość i kontrola dostępu — Dlaczego system RAG zwraca politykę niewłaściwego regionu oraz jak metadane źródłowe, organizacyjne, czasowe i bezpieczeństwa poprawiają wyniki wyszukiwania, gdy są dołączane podczas przetwarzania danych.
- PageIndex: RAG oparte na rozumowaniu, bez baz wektorowych, z indeksem drzewiastym — PageIndex zastępuje bazy danych wektorowych indeksem drzewiastym zarządzanym przez LLM. Dowiedz się, jak to działa, zobacz przykłady kodu w Pythonie, wyniki testów i w jakich sytuacjach należy go używać.