Strona główna / Artykuły

Hybrydowy RAG dla tekstów prawniczych: dzielenie na fragmenty, BM25, BGE i ponowna klasyfikacja

Prawidłowy pipeline RAG dla Konstytucji Indii: dzielenie na fragmenty rodzic-dziecko, wyszukiwanie za pomocą BM25 w połączeniu z BGE, fuzja rankingu, ponowne sortowanie za pomocą cross-encodera oraz rozszerzanie zapytania, przetestowane na benchmarkach.

Wydawca: REACTAPP.TOP20 min czytania4590 słów

Źródła: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)

Tekst prawny stanowi trudny test dla każdego systemu wyszukiwania. Weźmy Konstytucję Indii: składa się ona z setek artykułów i klauzul, a także z postanowień dodatkowych, załączników, poprawek, tabel oraz sieci odniesień wzajemnych. Fragment odpowiadający na pytanie może składać się z jednego zdania, jednak jego prawidłowe odczytanie może zależeć od otaczającego go artykułu, powiązanej wyjątki lub innej przepisy, do której odnosi się. Dlatego też przydatny system musi pogodzić precyzję z kontekstem, znajdując dokładny fragment bez odcięcia go od tekstu, który nadaje mu znaczenie.

Wiele prostych rozwiązań opartych na technologii retrieval-augmented generation (RAG) polega na prostym schemacie: wyodrębnianiu tekstu z pliku PDF, dzieleniu go na fragmenty o stałej wielkości, włączaniu tych fragmentów do modelu i zwracaniu najbliższych odpowiedników. To przyzwoity punkt wyjścia, ale systemy produkcyjne zazwyczaj wymagają czegoś więcej. Muszą zachować strukturę dokumentu, obsługiwać zarówno wyszukiwanie dokładne, jak i semantyczne, ponownie sortować kandydatów, odzyskiwać otaczający kontekst, śledzić każdy wynik do jego źródła oraz sprawdzać, czy rzeczywiście udało się odzyskać potrzebne dowody.

Ten tekst opisuje projekt badawczy, który odtwarza taki przepływ pracy typu produkcyjnego w formie umożliwiającej analizę krok po kroku, przy użyciu Konstytucji Indii jako pierwszego korpusu, ponieważ jej struktura ujawnia liczne słabości zwykłego wyszukiwania wektorowego. Nie jest to produkt gotowy – to próba zbadania, w jaki sposób zwykłe komponenty pipeline RAG produkcyjnego łączą się ze sobą, gdzie powstają problemy oraz jak ocena może wpłynąć na kolejne decyzje projektowe.

1. Przyjmowanie danych: od plików PDF prawniczych do strukturyzowanego treści

Zanim można rozważyć dzielenie na fragmenty, wykorzystanie embeddingów lub procesy wyszukiwania, źródłowy plik PDF musi zostać przekształcony w treść, którą oprogramowanie może pewnie przetwarzać. PDF rejestruje wygląd dokumentu, a niekoniecznie sposób, w jaki program powinien go odczytywać. Nagłówki, zdania, przypisy, listy, tabele oraz granice stron mogą być oczywiste dla człowieka, a mimo to w wyniku podstawowego wydobywania treści pojawiają się jako rozproszony tekst. Jeśli ta struktura zostanie utracona na etapie przyjmowania danych, późniejsze kroki przetwarzania prowadzą do fragmentów o błędnym porządku odczytu, oddzielonych nagłówków lub uszkodzonych tabel.

W projekcie użyto MinerU do konwersji Konstytucji na format Markdown, zachowując przy tym ważne informacje o układzie. Został on wybrany, ponieważ według porównania autora lepiej radził sobie z tabelami i listami niż inne metody wydobywania treści, które zostały wypróbowane. Celem nie było tylko zebranie każdego słowa, ale także zachowanie relacji pomiędzy nimi. Wydobyta treść zachowywała:

  • sposób nawijania się nagłówków,
  • miejsce, w którym zaczyna się i kończy każdy akapit oraz zdanie,
  • listy z punktami i numerowanymi pozycjami,
  • tabele,
  • strona, z której pochodzi każdy element, oraz
  • przypisy wraz z strukturą otaczającą je.

Markdown nadaje się do tej roli jako format pośredni. Ludzie mogą go czytać podczas ręcznej inspekcji, a zawiera on znaczniki strukturalne, które Python może przetworzyć. Nagłówek taki jak ## Prawa podstawowe może później pomóc w tworzeniu większych elementów, natomiast listy i tabele mogą być rozpoznane i traktowane jako odrębne bloki.

Ekstrakcja została przeprowadzona w Google Colab, a nie na lokalnym komputerze. OCR oraz analiza układu mogą być wymagające, szczególnie w przypadku długiego dokumentu pełnego tabel i złożonego formatowania, więc laptop o ograniczonych możliwościach sprzętowych miał trudności z obsługą tego obciążenia. Colab oferuje lepiej przystosowane, izolowane środowisko z silniejszymi zasobami obliczeniowymi, więc warto go rozważyć na tym etapie, jeśli Twój lokalny sprzęt jest skromny. Komenda użyta w notatniku brzmiała:

!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en

2. Dzielenie na fragmenty

Gdy mowa była o strukturyzowanym Markdownu w kontekście konstytucji, następnym pytaniem było to, jak ją podzielić w celu łatwiejszego wyszukiwania. Powszechną odpowiedzią jest dzielenie na fragmenty o stałej wielkości: cięcie po określonej liczbie znaków lub tokenów, często z pewnym nakładaniem się. Jest to proste i sprawdza się przy jednolitej prozie, ale dokumenty prawnicze są zorganizowane według znaczenia, a nie długości. Stałe granice mogą:

  • oddzielić nagłówek od tekstu poniżej niego,
  • przerwać klauzulę w połowie zdania, które ją określa,
  • przeciąć listę w środku jej pozycji,
  • rozbić tabelę na niekompletne fragmenty oraz
  • połączyć niespowiązane artykuły tylko dlatego, że mieszczą się w jednym oknie tokenów.

Bardziej duże fragmenty zachowują więcej kontekstu, ale sprawiają, że wyszukiwanie staje się mniej precyzyjne. Mniejsze fragmenty lepiej pasują, jednak ważne otaczające je elementy mogą zostać pominięte. Dlatego projekt zastosował strategię dwupoziomową:

Structured document
        ↓
Heading-aware parent chunks
        ↓
Semantic child chunks

Dlaczego dwa poziomy są pomocne

Wyszukiwanie odbywa się wśród małych dzieci, ponieważ ich ograniczona zawartość dostarcza BM25 oraz bardziej precyzyjnych sygnałów do odzyskiwania informacji. Po wybraniu dziecka, parent_id, który ono posiada, wskazuje na większą sekcję rodzica:

Query
  ↓
Retrieve focused child
  ↓
Read parent_id
  ↓
Return complete parent context

To dzieli obowiązki na dwa. Części potomne zapewniają dokładność odzyskiwania informacji, natomiast części nadrzędne gwarantują kompletność kontekstu.

Części nadrzędne uwzględniające nagłówki

Algoritm dzielenia na części nadrzędne opiera się na dwóch produktach powstających podczas przetwarzania danych:

data/processed/cr.md
data/extracted/auto/blockpage.json

Plik Markdown zawiera oczyszczony tekst oraz jego widoczną strukturę. Plik blockpage.json przechowuje uporządkowane bloki układu utworzone podczas wydobywania danych, w tym informacje o poziomach nagłówków. Markdown pozostaje głównym źródłem tekstu nadrzędnego, natomiast JSON pełni rolę uporządkowanej referencji strukturalnej umożliwiającej powiązanie nagłówków Markdown z wydobytym dokumentem. Implementacja znajduje się w jednym skrypcie:

code/chunking_parent.py

Algorytm działa w ustalonej kolejności.

Krok 1: załaduj oba pliki. Skrypt odczytuje wyczyszczone pliki Markdown oraz plik JSON zawierający bloki układu:

md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
    blockpage = json.load(file)

Użycie obu elementów zapobiega temu, by narzędzie do dzielenia tekstu traktowało Markdown jako zwykły, niestrukturyzowany tekst. Markdown dostarcza treść, a JSON pomaga zachować kolejność nagłówków znalezionych podczas wydobywania danych.

Krok 2: podzielić Markdown na bloki. Przecinki służą jako separator:

for raw_block in md_text.split("\n\n"):

Każdy blok jest następnie klasyfikowany jako jeden z kilku typów:

heading
paragraph
list
table
superscript
blank

Tekst rozpoczynający się od #, ## lub innego poziomu nagłówka jest rozpoznawany jako nagłówek:

HEADING_RE = re.compile(r"^(#{1,6})\s+")

Lista, tabele oraz bloki z tekstem nadpisanym mają własne reguły wykrywania, które zapewniają, że pozostaną powiązane z sekcją, do której należą.

Krok 3: śledzenie aktywnego nagłówka. Gdy pojawia się nagłówek, jego tekst jest dodawany do bieżącego heading_path. Skrypt szuka również odpowiadającego mu nagłówka w pliku blockpage.json, przeglądając go sekwencyjnie, bez konieczności ponownego skanowania od początku za każdym razem:

def find_heading(heading_text: str):
    nonlocal search_pos
    for index in range(search_pos, len(blockpage)):
        block = blockpage[index]        if (
            block.get("type") == "text"
            and block.get("text") == heading_text
            and "text_level" in block
        ):
            search_pos = index + 1
            return block

Skanowanie tylko do przodu zachowuje nagłówki w ich oryginalnej kolejności i zapobiega trafieniu na wcześniejszy, identyczny nagłówek.

Krok 4: zbieranie powiązanego treści. Akapity, listy, tabele oraz podobne elementy są dodawane do bieżącego stosu rodzicielskiego. Gdy po zebraniu tekstu głównego pojawia się nowy nagłówek, bieżący stos rodzicielski jest zamykany, a rozpoczyna się nowy. Koncepcyjnie wygląda to w ten sposób:

Heading
  ├── Paragraph
  ├── Clause
  ├── List
  └── Table
        ↓
     Parent chunk

Krok 5: przypisanie stabilnych metadanych. Każdy ukończony element nadrzędny jest przechowywany wraz z sekwencyjnym identyfikatorem, identyfikatorem dokumentu, ścieżką nagłówka oraz pełnym tekstem:

{
    "parent_id": f"parent_{len(chunks):04d}",
    "doc_id": DOC_ID,
    "heading_path": heading_path.copy(),
    "text": "\n\n".join(stack),
}

Sprywatyzowany rodzic wygląda w ten sposób:

{
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}

Samotny rodzic może korzystać z kilku powiązanych świadczeń, na przykład:

PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund

Proces wygenerował 333 chunki nadrzędne.

Chunki potomne semantyczne

Nadrzędne chunki zachowują kontekst w nienaruszonej formie, ale niektóre są zbyt szerokie na dokładne dopasowanie, dlatego te większe są dzielone na mniejsze chunki potomne semantyczne. Te prace znajdują się w notatniku:

code/semantic-children.ipynb

Krótkie teksty nadrzędne, oceniane według przybliżonej granicy 400 tokenów w notatniku, są zachowywane jako jeden element, ponieważ dzielenie czegoś, co jest już wystarczająco zwięzłe, by stanowić spójną jednostkę, nie przynosi żadnych korzyści. Dłuższe teksty są przetwarzane za pomocą narzędzia do dzielenia na fragmenty, które uwzględniają strukturę i porównuje znaczenie.

Krok 1: podzielenie tekstu nadrzędnego na bloki. Tekst nadrzędny jest dzielony na nagłówki, akapity, listy i tabele. Tablice HTML są rozpoznawane za pomocą specjalnego wzorca:

table_pattern = re.compile(
    r"(<table[\s\S]*?</table>)",
    re.IGNORECASE
)

Zwykły tekst jest dzielony na puste linie i klasyfikowany za pomocą nagłówków Markdown oraz znaczników list, dzięki czemu proza, listy i tabele mogą być obsługiwane osobno, zamiast zakładać, że jedna struktura pasuje do wszystkich.

Krok 2: przypisz nagłówki do ich treści. Nagłówek nigdy nie powinien istnieć samodzielnie jako element potomny. Narzędzie do dzielenia tekstu tymczasowo przechowuje każdy nagłówek i umieszcza go na początku następującego bloku:

if pending_heading:
    block.text = pending_heading + "\n\n" + block.text
    pending_heading = None

Dziecko odzyskane w ten sposób zachowuje nagłówek identyfikujący jego temat. Kolejne bloki listy są również grupowane przed porównaniem semantycznym, co pomaga zachować logiczny porządek zdań i elementów wyliczonych.

Krok 3: porównaj sąsiednie akapity. Dla przyległych bloków akapitów notatnik generuje embeddingi BGE:

embedder = SentenceTransformer(
    "BAAI/bge-base-en-v1.5"
)

Następnie notatnik oblicza podobieństwo kosinowe pomiędzy każdym akapitem a tym poprzedzającym go:

similarity = cosine_similarity(
    emb1,
    emb2
)[0][0]

Jeśli dwa bloki są wystarczająco podobne pod względem znaczenia, mogą pozostać w tym samym potomku, a spadek podobieństwa wskazuje, że następny blok otwiera nowy temat. Ustawiona granica to:

Similarity threshold: 0.40

Jednak podobieństwo to tylko jeden z czynników. Bierze się również pod uwagę typ i rozmiar bloku.

Krok 4: zastosowanie zasad dotyczących rozmiaru i struktury. Domyślne ustawienia semantycznego dzielacza to:

Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters

Blok można dodać do obecnego potomka, gdy liczba znaków w tym potomku jest nadal poniżej wymaganego minimum, gdy sąsiednie akapity są wystarczająco podobne, gdy jeden z bloków jest mały i nie powinien stać samodzielnie, lub gdy kolejne bloki należą do tej samej listy lub tabeli. Łączna liczba znaków w potomku musi zazwyczaj mieścić się w granicach 1600 znaków. Tabela nie zostanie przyklejona do zwykłego tekstu tylko po to, by osiągnąć minimalny rozmiar:

if (
    current_type == BlockType.TABLE
    and block.type != BlockType.TABLE
) or (
    block.type == BlockType.TABLE
    and current_type != BlockType.TABLE
):
    is_under_min = False

Lista o zwiększonej wielkości może zostać podzielona przy jej elementach, natomiast pojedynczy blok o zwiększonej wielkości, który nie jest listą, pozostaje jako jedna całość strukturalna i nie jest przypadkowo dzielony. Wynikiem nie jest ani czysto semantyczne, ani wyłącznie oparte na rozmiarze dzielenie na fragmenty. Łączy ono:

Document structure
        +
Semantic similarity
        +
Minimum and maximum sizes

Krok 5: zachowaj link nadrzędny. Każde utworzone dziecko otrzymuje własny ID i zachowuje ID swojego nadrzędnego elementu:

{
    "parent_id": parent["parent_id"],
    "child_id": f"{parent['parent_id']}_child_{index}",
    "doc_id": parent.get("doc_id", ""),
    "heading_path": parent.get("heading_path", []),
    "text": chunk["text"],
    "type": chunk.get("type", "mixed"),
    "embedding": encode_text(chunk["text"])
}

Użycie rodzica Finance jako przykładu do ćwiczeń:

parent_0164
│
├── parent_0164_child_1
│   Article 264 and introductory Finance context
│
├── parent_0164_child_2
│   Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
    Article 267 concerning the Contingency Fund

Wszystkie trzy dzieci zachowują:

{
  "parent_id": "parent_0164"
}

Zapisane dziecko wygląda w ten sposób:

{
  "child_id": "parent_0164_child_2",
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "type": "list",
  "text": "265. Taxes not to be imposed save by authority of law...",
  "embedding": [0.012, -0.034, 0.021]
}

Ostatecznie korpus zawiera 871 elementów dziecięcych powiązanych z 333 rodzicami.

3. Hybrydowe wyszukiwanie, od zapytania do kontekstu rodzica

Gdy mamy już 871 elementów dziecięcych, pojawia się pytanie, jak znaleźć odpowiednie dowody na zapytanie użytkownika. Wyszukiwanie odbywa się w oparciu o fragmenty dziecięce, a nie rodziców, ponieważ krótsze części łatwiej pasują do konkretnego artykułu, zdania lub idei. Większe elementy rodzicowskie są identyfikowane dopiero po ustaleniu najważniejszych elementów dziecięcych.

Dlaczego jeden metod nie wystarcza

Pytania prawne przybierają różne formy. Zapytanie takie jak „Co mówi artykuł 23?” zawiera numer artykułu, który występuje również w źródle, więc wyszukiwanie według słów kluczowych doskonale się do niego nadaje. Użytkownik może zadać to samo pytanie w inny sposób: „Czy państwo może zobowiązać kogoś do odbycia służby obowiązkowej?”. Tak sformułowane pytanie nie wymienia artykułu 23, więc system musi powiązać frazę „zobowiązać kogoś do odbycia służby” z pojęciem służby obowiązkowej w celach publicznych. Te dwa przypadki wymagają różnych możliwości:

  • porównywania dosłownego, stosowanego w przypadku numerów artykułów, zwrotów prawnych, nazw i nietypowych terminów, oraz
  • porównywania opartego na znaczeniu, gdy sformułowanie pytania odbiega od tekstu.

Zamiast dokonywać wyboru, system przepuszcza to samo korpus pomocniczy przez dwa oddzielne mechanizmy: wyszukiwanie rzadkie BM25 oraz wyszukiwanie gęste BGE.

Rzadka gałąź: BM25

BM25 sortuje według słów, które zapytanie ma wspólne z każdym potomkiem. Podczas tworzenia indeksu ścieżki nagłówków oraz tekst potomków są przekształcane na małe litery i tokenizowane, a indeks rejestruje, które potomki zawierają dany termin, jak często się on pojawia oraz jak rzadki jest w całym korpusie. Istotne są trzy czynniki: częstotliwość występowania terminu zapytania w potomku, stopień rzadkości tego terminu w korpusie oraz długość potomka w porównaniu ze średnią. Rzadkie terminy mają większe znaczenie niż słowa powszechne, co sprawia, że BM25 jest szczególnie skuteczny przy numerach artykułów i konkretnych wyrażeniach prawniczych.

Na przykład wyszukiwanie Article 23 compulsory service silnie faworyzuje elementy zawierające 23, compulsory i service. BM25 nie musi rozumieć szerszego znaczenia, ponieważ bezpośrednia obecność tych terminów jest już silnym sygnałem. Jego wadą jest to, że istotny fragment może otrzymać niską ocenę, gdy zapytanie i tekst źródłowy używają różnego słownictwa.

Gęsty gałąź: BGE i FAISS

Gęsta gałąź przeprowadza wyszukiwanie na podstawie znaczenia, a nie tylko ze względu na pokrycie słów. Każdy element jest reprezentowany przez ścieżkę jego nagłówka wraz z tekstem:

embedding_text = heading_path + child_text

Włączenie nagłówka dodaje kontekst, co pomaga powiązanym fragmentom i zapytaniom znaleźć się blisko siebie w przestrzeni embeddingów.

Wybór modelu embeddingowego za pomocą benchmarka

Pierwsza wersja projektu wykorzystywała nomic-ai/nomic-embed-text-v1.5, ale autor chciał potwierdzić, że jest to najlepszy wybór dla tego korpusu. Kontrolowany benchmark przeszukiwania gęstego porównał cztery rozwiązania: Nomic bez prefiksów, Nomic z zalecanymi prefiksami dla zapytań i dokumentów, BAAI/bge-base-en-v1.5 oraz intfloat/e5-base-v2.

Każdy model przeszukiwał identyczny zbiór 871 ustalonych podkawałków, wraz z 383 pytaniami oceniającymi, które zostały automatycznie przyporządkowane.

  • MRR: przyznaje większą wartość, gdy pierwszy istotny podkawałek znajduje się bliżej góry.
  • Recall@10 i Recall@20: ułamek pytań, które mają istotne wyniki w pierwszej 10 lub 20 pozycjach.
  • All-recall@10: udział wszystkich istotnych wyników w pierwszej 10 pozycjach.
  • Mean coverage@10: średni udział istotnych wyników w pierwszej 10 pozycjach, obliczony na podstawie wszystkich pytań.
  • BGE osiągnął najlepsze wyniki pod względem MRR, Recall@10, Recall@20 oraz mean coverage@10. Wartość MRR wzrosła z 0,624 w przypadku Nomic do 0,652, a Recall@20 wzrósł z 0,877 do 0,911. Było to istotne, ponieważ późniejsze fuzje i ponowne sortowanie wykorzystują ograniczoną liczbę kandydatów, a istotny wynik nieobecny w początkowym wyniku wyszukiwania nie może zostać później odzyskany.

    Nomic osiągnął nieco lepsze wyniki w testie All-recall@10 (0,347 w porównaniu z 0,334), co oznacza, że był nieznacznie skuteczniejszy w odzyskiwaniu wszystkich oznaczonych elementów w przypadku zadań dotyczących wielu elementów. Mimo to wcześniejsze znalezienie przynajmniej jednego istotnego elementu miało większy wpływ na odzyskanie kontekstu nadrzędnego. Opóźnienie przy wysyłce zapytań było niemal identyczne. Dlatego przechowywane reprezentacje elementów Nomic zostały zastąpione reprezentacjami BGE, przy zachowaniu istniejących granic semantycznych, tak aby zmiana reprezentacji nie wpłynęła na wcześniejsze decyzje dotyczące podziału danych.

    Elementy są reprezentowane jako zwykły tekst bez żadnego prefiksu instrukcyjnego. Zapytania natomiast otrzymują instrukcję odzyskiwania danych zalecaną przez BGE:

    query_text = (
        "Represent this sentence for searching relevant passages: "
        + user_query
    )
    

    Ten sam model BGE zawiera zapytania oraz elementy potomne. Wektory są normalizowane według metody L2, dzięki czemu iloczyn skalarny zachowuje się jak podobieństwo kosinowe, przy czym wyższe wartości oznaczają większe podobieństwo.

    Indeks FAISS

    Znormalizowane wektory potomne trafiają do indeksu FAISS Hierarchical Navigable Small World:

    index = faiss.IndexHNSWFlat(
        embedding_dimension,
        32,
        faiss.METRIC_INNER_PRODUCT,
    )
    
    index.hnsw.efConstruction = 200
    index.hnsw.efSearch = 64
    index.add(embeddings)
    

    HNSW układa embeddingi w postaci grafu nadającego się do nawigacji, dzięki czemu wyszukiwanie odbywa się poprzez obiecujące sąsiednie elementy, a nie porównuje zapytania z każdym wektorem. Pełne porównanie wciąż byłoby do zarządzenia dla 871 dziecięcych danych. Wybrano HNSW, aby system mógł nadal funkcjonować, gdy do korpusu dołączą kolejne ustawy, przepisy, orzeczenia i podobne dokumenty. Dane dziecięce zostały ponownie wstawione za pomocą code/reembed_children_bge.py, a indeks odbudowano za pomocą code/rebuild_dense_index.py. Przygotowanie benchmarku, definicje modeli, zapytania oraz wyniki znajdują się w katalogu code/dense_benchmark/v2/.

    Łączenie dwóch rankingów

    BM25 oraz metoda gęstego wyszukiwania generują oddzielne listy uporządkowane. Dziecko może zajmować wysokie miejsce w obu, silnie w jednym z nich lub nie występować w żadnym. Brutalne wyniki nie mogą być po prostu dodawane, ponieważ wynik BM25 i podobieństwo wektorowe odnoszą się do różnych skali. Obie listy uporządkowane są łączone za pomocą metody Reciprocal Rank Fusion:

    RRF score = Σ 1 / (k + rank + 1)
    

    W tym procesie używane jest k = 60. Każde dziecko zdobywa punkty na podstawie swojej pozycji we wszystkich listach, a nie na podstawie swojego początkowego wyniku. Dziecko, które zajmuje wysoką pozycję w obu gałęziach, zbiera wkłady z obu i zazwyczaj awansuje, natomiast to, które jest silne tylko w jednej gałęzi, może nadal pozostać w grupie kandydatów. Uproszczony etap fuzji:

    sparse_results = bm25_search(query)
    dense_results = dense_search(query)
    
    fused_candidates = reciprocal_rank_fusion(
        sparse_results,
        dense_results,
    )
    

    RRF tworzy szeroką bazę kandydatów. Zwiększa szanse na to, że przydadzą się dowody, ale nie porównuje całego zapytania ze wszystkimi fragmentami w szczegółowy sposób. To jest zadanie ponownego rankowania.

    Ponowne rankowanie za pomocą cross-encodera

    Poиск typu dense jest tani, ponieważ zapytanie oraz jego elementy są kodowane oddzielnie, dzięki czemu embeddingi tych elementów mogą być obliczone raz i wykorzystane dla każdego zapytania. Wadą jest to, że niezależne kodowanie ogranicza stopień, w jakim model może ocenić wzajemną wagę słów. Cross-encoder obiera inną drogę i otrzymuje zarówno zapytanie, jak i kandydujące elementy jednocześnie:

    pairs = [
        (query, child_text),
        ...
    ]
    
    scores = reranker.predict(pairs)
    

    Ponieważ zapytanie i fragment tekstu są wprowadzane jako jeden zestaw danych, model może bezpośrednio sprawdzić, w jaki sposób ich tokeny ze sobą współgrają, co zazwyczaj przynosi dokładniejszą ocenę istotności niż metody oparte na BM25 lub intensywnym wyszukiwaniu.

    candidates = fused_candidates[:30]
    reranked_children = cross_encoder.rank(query, candidates)
    

    Reranker ponownie ocenia każde dziecko i na nowo sortuje kandydatów. Poprzednie pozycje BM25, dense oraz RRF tworzą jedynie zbiór kandydatów, a wynik cross-encodera decyduje o ostatecznej kolejności. Następnie system sprawdza pierwsze 20 ponownie sklasyfikowanych dzieci i zachowuje te, które uzyskały co najmniej 0,30 punktu:

    selected_children = [
        child
        for child in reranked_children[:20]
        if child.score >= 0.30
    ]
    

    To progiem jest polityka filtrowania dla tego konkretnego modelu i procesu przetwarzania. Nie powinien być traktowany jako uniwersalna prawdopodobieństwo istotności, a zmiana narzędzia do ponownego rankowania oznaczałaby konieczność ponownej jego oceny i kalibracji. Tabele wymagają szczególnej uwagi, ponieważ niektóre przekraczają limit 512 tokenów wchodzących do narzędzia do ponownego rankowania. Przed tokenizacją proces przetwarzania oznacza pary tabel, które są zbyt długie, aby nie powodować błędów indeksowania ani by po prostu znikać bez śladu.

    Rozszerzanie elementów potomnych do kontekstu nadrzędnego

    Narzędzie do ponownego rankowania nadal przetwarza elementy potomne, ponieważ stanowią one spójne jednostki do porównania. Jednak przywrócenie tylko tych elementów potomnych mogłoby spowodować usunięcie otaczających je klauzul, warunków, wyjątków lub powiązanych artykułów. Dlatego każdy element potomny zachowuje przypisany podczas dzielenia na fragmenty parent_id:

    {
      "child_id": "parent_0082_child_1",
      "parent_id": "parent_0082",
      "text": "23. Prohibition of traffic in human beings and forced labour..."
    }
    

    Gdy filtrowanie według progu zostanie zakończone, system bierze parent_id z każdego wybranego dziecka i wyszukuje cały rodzic w kolekcji parent-chunk:

    parent_id = selected_child["parent_id"]
    parent = parent_lookup[parent_id]
    

    Kilka wybranych dzieci może dzielić jednego rodzica, a jego wielokrotne pokazywanie spowodowałoby duplikację kontekstu, dlatego dzieci są grupowane według parent_id. Każdy unikalny rodzic jest pokazywany raz wraz z dziećmi, które do niego należą, a wynik najwyżej ocenionej dziecka staje się wynikiem tego rodzica:

    Selected child 1 ──┐
    Selected child 2 ──┼── parent_0082
    Selected child 3 ──┘
    

    Dziecko o niewielkich rozmiarach, zawierające odpowiedni język z Artykułu 23, może odwołać się do parent_0082, którego element nadrzędny dostarcza szerszy rozdział „Prawo do ochrony przed wyzyskiem”, włączając otaczające go klauzule oraz powiązaną treść z Artykułu 24. Dzięki temu podział obowiązków pozostaje nienaruszony: elementy dziecięce decydują o tym, które dowody są istotne, natomiast elementy nadrzędne dostarczają kontekstu, w którym one występują.

    Zwrócony źródło nadrzędne zachowuje również swój identyfikator dokumentu i ścieżkę nagłówka, identyfikatory dopasowanych elementów dziecięcych, ich typy bloków oraz wyniki oceny ponownego rankingu, dzięki czemu zwrócone źródło można prześledzić w całym procesie, aby zrozumieć, dlaczego zostało wybrane. Eksperyment z hybrydowym wyszukiwaniem znajduje się w pliku code/hybridrag.ipynb.

    4. Rozszerzanie zapytania i pytania wieloetapowe: wypełnianie luki w słownictwie

    Hybrydowy system funkcjonował dobrze, gdy sformułowanie użytkownika dokładnie odpowiadało tekstu Konstytucji. Bezpośrednie pytanie typu „Co to jest artykuł 21?” zawiera dokładne odniesienie, co daje BM25 oraz metodzie gęstego wyszukiwania silny sygnał.

    Pytania konceptualne były trudniejsze. Użytkownicy często opisują dany zasadę za pomocą terminów z orzeczeń, podręczników lub komentarzy prawniczych, a nie języka samej Konstytucji. Pytanie dotyczące „doktryny podstawowej struktury” może wymagać odniesienia do artykułów 13 i 368, mimo że wyrażenie „podstawowa struktura” nie występuje w żadnym z nich. Innymi słowy, użytkownik ma rację co do koncepcji prawniczej, choć dokument określa ją inaczej.

    Co pokazała ocena

    Problem został przetestowany w notatniku Kaggle zawierającym pytania bezpośrednie oraz trudniejszy zestaw pytań koncepcyjnych powiązanych z oczekiwanymi artykułami konstytucji. Eksperyment przypada na okres przed migracją do BGE. W tym czasie 871 dzieci miało embeddingi z nomic-ai/nomic-embed-text-v1.5, a zapytania były kodowane za pomocą tego samego modelu bez żadnych prefiksów do wyszukiwania. Ten szczegół jest ważny, ponieważ celem eksperymentu było zbadanie wpływu zmiany sformułowania zapytania, a nie rankowanie modeli embeddingowych; dlatego ta sama hybrydowa metoda oparta na Nomic obsługiwała obie wersje każdego pytania koncepcyjnego.

    Zadanie jest uznawane za udane tylko wtedy, gdy uzyskany kontekst nadrzędny zawierał wszystkie artykuły docelowe z nim powiązane, więc podawany procent odzwierciedla pełne pokrycie wszystkich celów wyszukiwania, a nie dokładność faktograficzną żadnej odpowiedzi napisanej przez model językowy. Kluczowe porównanie dotyczy tych dwóch zbiorów koncepcyjnych. Rozszerzenie zapytania podniosło wskaźnik udanych wyszukiwań z 56,0% na 74,5%, co stanowi wzrost o 18,5 punktu procentowego. Umożliwiło ono odzyskanie pełnego zbioru celów dla 37 dodatkowych zadań i zmniejszyło liczbę nieudanych prób z 88 do 51.

    Wyniki wskazywały, że wiele nieudanych prób nie wynikało z braku dokumentów lub błędnych granic fragmentów. Dowody istniały w korpusie, ale pierwotne zapytanie nie dostarczyło systemowi wyszukiwania słownictwa potrzebnego do ich znalezienia.

    Rozszerzanie bez zmiany intencji

    Rozszerzenie sformułowuje pytanie koncepcyjne w słowach, które są bardziej prawdopodobne w źródle. Załóżmy, że pytanie dotyczy tego, co ogranicza Parlament w zmianie istotnej tożsamości Konstytucji. Forma rozszerzona może uwzględnić zasadę podstawowej struktury – rdzenia Konstytucji, którego nie można zmieniać, prawa sprzeczne z prawami podstawowymi, artykuł 13 oraz uprawnienie do zmiany zawarte w artykule 368. To, czego chce użytkownik, nie uległo zmianie, ale dłuższy tekst dodaje sygnały ułatwiające wyszukiwanie, takie jak:

    • ogólne nazwy tej zasady,
    • powiązana terminologia konstytucyjna,
    • alternatywne sformułowania tej samej idei,
    • odpowiednia terminologia instytucjonalna lub strukturalna oraz
    • wyraźne numery artykułów, gdy można je pewnie wywnioskować.

    To samo podejście obejmowało kwestie rozdzielności elementów tekstu, istoty i sedna treści, aktywizmu sądowego oraz sposobu, w jaki prawa podstawowe wiążą się z zasadami dyrektywnymi.

    W zadaniach na Kaggle rozszerzone pytania były przygotowywane z wyprzedzeniem, a nawet wymieniano prawdopodobne artykuły prawa, więc należy to traktować jako kontrolowane doświadczenie związane z reformulacją zapytań, a nie jako ocenę automatycznego modelu rozszerzania zapytań. Pokazuje to, o ile lepsza może być skuteczność wyszukiwania, gdy zapytanie zawiera język zgodny ze źródłem, jednak rzeczywisty planer zapytań wymagałby oddzielnej oceny bez dostępu do etykiet docelowych. Późniejszy, bardziej zaawansowany benchmark zastąpił Nomic modelem BGE, ponieważ ten drugi radził sobie lepiej w ustandaryzowanym korpusie danych, mimo to wyniki z Kaggle nadal wpłynęły na projekt: lepszy model embeddingów poprawiał semantyczne wyszukiwanie, natomiast rozszerzanie zapytań służyło do rozwiązania innego problemu – luki w słownictwie pomiędzy użytkownikiem a dokumentem.

    Rozszerzanie zapytań versus podzapytania

    Rozszerzenie jest przydatne, gdy jedna potrzeba informacyjna jest sformułowana w nieznanych lub pośrednich terminach, co pozwala na stworzenie bardziej złożonej zapytania. Pytania wieloetapowe są inne, ponieważ zawierają kilka potrzeb informacyjnych, które mogą odnosić się do różnych części dokumentu. Rozważmy prośbę o porównanie środków zaradczych przewidzianych w artykułach 32 i 226. Gdy jest to wyszukiwane jako jedno długie zapytanie, sygnały ze sobą konkurują: doskonały wynik dla artykułu 32 może zdominować listę kandydatów, podczas gdy artykuł 226 otrzymuje zbyt mało reprezentacji, a ponowne rankowanie połączonych kandydatów nie gwarantuje, że obie strony przeżyją.

    Zamiast tego pytanie wieloetapowe można podzielić na skoncentrowane podzapytania:

    1. Które środki zaradcze i jurysdykcja przewiduje artykuł 32?
    2. Które uprawnienia i jurysdykcja przewiduje artykuł 226?
    3. W jaki sposób artykuły 32 i 226 różnią się pod względem konstytucyjnym?

    Każde podzapytanie przechodzi przez cały proces wyszukiwania (BM25 i gęste wyszukiwanie, następnie fuzja rankingów, potem ponowne rankowanie za pomocą kodera krzyżowego, wreszcie rozszerzenie rodzicowskie), a uzyskane „rodzice” są łączone i usuwane z duplikatami, tworząc połączone dowody. Dzięki temu jedna część złożonego pytania nie zajmuje zasobów przeznaczonych dla innej, a sam proces wyszukiwania staje się łatwiejszy do analizy, ponieważ każda gałąź odpowiada konkretnemu wymaganiu.

    Otrzymana strategia kierowania

    Ewaluacja pokazała, że żadna pojedyncza transformacja zapytania nie nadaje się do wszystkich pytań. Pytania bezpośrednie już zawierają silne sygnały i powinny pozostać takie, jakie są. Pytania konceptualne odnoszą korzyść z rozszerzenia słownictwa, natomiast pytania wieloetapowe wymagają podziału, aby każde wymaganie miało własną gałąź:

    • Bezpośrednie pytania faktograficzne lub dotyczące konkretnych artykułów: wyszukiwanie z oryginalnym zapytaniem.
  • Zadania koncepcyjne z niespójnością słownictwa: należy je rozszerzyć na jedno zapytanie zgodne ze źródłem.
  • Zadania wieloetapowe, porównawcze lub dotyczące kilku artykułów: trzeba je podzielić na skoncentrowane podzapytania i przeprowadzić każdą z nich osobno.
  • Taka struktura unika niepotrzebnych modyfikacji prostych zapytań, jednocześnie zapewniając złożonym strukturę, której potrzebują. Oryginalna ocena w Kaggle została zaimplementowana w pliku code/hybrid_eval.ipynb, a późniejsze eksperymenty z rozszerzaniem i rozwijaniem struktury znajdują się w pliku code/hybridrag.ipynb.

    Lekcje z projektu

    Główną lekcją jest to, że lepszy RAG rzadko zależy od jednego doskonałego modelu. Jakość wydobywania informacji pochodzi od powiązanych decyzji dotyczących ekstrakcji, dzielenia na fragmenty, embeddingów, kombinacji kandydatów, ponownego sortowania oraz przywracania kontekstu. Proces oceny doprowadził do zmiany kilku wyborów. Gęsty benchmark zastąpił Nomic modelem BGE, zachowując przy tym granice semantyczne; eksperyment na Kaggle pokazał, że pytania konceptualne korzystają z terminologii bliższej oryginałowi, natomiast błędy typu multi-hop wskazywały na konieczność użycia skoncentrowanych podzapytań oraz niezależnych ścieżek wydobywania informacji.

    Naturalne kroki następne to otoczenie całego procesu aplikacją chatbot z ustrukturyzowanym backendem oraz interfejsem użytkownika umożliwiającym zadawanie pytań i sprawdzanie źródeł, a następnie zapakowanie jej za pomocą Dockera i wdrożenie na platformie chmurowej, gdzie kwestie takie jak ładowanie modeli, zużycie pamięci, opóźnienia, trwałe indeksy, logowanie, skalowalność i koszty stają się istotne. System wciąż się rozwija, a niektóre problemy pozostają nierozwiązane, co jest właśnie celem tego ćwiczenia: zapewnienie możliwości sprawdzania każdego etapu, zlokalizowanie miejsc, gdzie informacje giną, oraz wykorzystanie ocen do dalszych ulepszeń.

    Literatura pokrewna

  • Te ciche błędy w wyszukiwaniu w systemach RAG w produkcji i sposoby ich naprawy — Od rozmiaru i nakładania się fragmentów po wyszukiwanie hybrydowe, RRF, ponowną sortację, wybór embeddingów oraz oceny CI: dziesięć błędów w wyszukiwaniu powodujących błędne odpowiedzi w systemach RAG i sposoby ich rozwiązania.
  • Wyszukiwanie kontekstowe w systemach RAG: co kryje się za liczbą 67%, jakie są koszty i kiedy ta metoda zawodzi — Jak umieszczenie prefiksów naprawia samotne fragmenty, dlaczego 67% poprawy wynika z trzech połączonych interwencji, co oszczędza cacheowanie promptów oraz cztery przypadki, w których ta technika nie przynosi efektów.