Strona główna / Artykuły

Debugowanie systemu pobierania danych RAG: Recall@K, BM25, hybrydowa fuzja oraz ponowna sortowanie

Mały eksperyment z pipeline’em RAG, w którym właściwy fragment został umieszczony na czwartym miejscu, pokazujący, jak dzielenie na fragmenty, BM25, fuzja rankingu oraz ponowne rankowanie wpływają na Recall@3 i MRR.

Wydawca: REACTAPP.TOP11 min czytania2556 słów

Źródła: How to Fix a RAG System That Keeps Retrieving the Wrong Context

Pytanie, na które system odpowiedzi popełnił błąd

Weźmy prosty pipeline RAG i zadajmy mu pytanie, które powinno być łatwe: jak długo jest aktywny link do eksportu w formacie CSV? W dokumentacji znajduje się odpowiedź – link do pobrania gotowego pliku CSV pozostaje aktywny przez siedem dni. Mimo to system umieścił ten fragment na czwartym miejscu, a ponieważ do modelu językowego trafiały tylko trzy najlepsze fragmenty, nigdy nie dotarł do właściwego pytania.

To, co sprawia, że ten błąd jest irytujący, to fakt, że trzy najlepsze wyniki nie wyglądają na błędne. Jeden z nich opisuje, jak przetwarzane są eksporty w formacie CSV, drugi dotyczy dużych plików, a trzeci wyjaśnia, jak anulować eksport. Na pierwszy rzut oka wydaje się, że system funkcjonuje prawidłowo. Tymczasem tak nie jest – znalazł fragmenty na odpowiedni temat, ale żaden z nich nie zawierał informacji potrzebnych do odpowiedzi na pytanie.

Zwykłą reakcją na kiepską odpowiedź RAG jest przechodzenie do dalszych kroków: przepisanie promptu, dodanie instrukcji lub obwinianie modelu. Modyfikowanie promptu daje satysfakcję, ponieważ trzy zmienione linijki natychmiast wpływają na wynik. W tym przypadku jednak nie przyniosłoby to żadnych efektów. To nie model wybrał te dokumenty, lecz mechanizm wyszukiwania, a żaden prompt nie może przywrócić fragmentu, który nigdy nie trafił do okna kontekstowego.

To staje się coraz ważniejsze w miarę dojrzewania systemu. Po fazie demonstracyjnej słabe wyszukiwanie staje się problemem trudnym do zauważenia po analizie ostatecznej odpowiedzi. Model może brzmieć pewnie, kontekst może wydawać się nieco istotny, ale prawdziwa przyczyna leży kilka kroków wcześniej. Lepszym podejściem jest rozróżnianie przyczyn awarii: brak dowodów, słabe dzielenie na fragmenty, niska kolejność sortowania lub błędy w etapie generowania. Wtedy kolejność debugowania wygląda następująco:

Zła odpowiedź – najpierw sprawdzić proces wydobywania informacji, potem to zmierzyć, następnie to naprawić, a dopiero później zająć się generowaniem odpowiedzi.

Niewielki eksperyment pokazuje, jak ważne są wcześniejsze etapy.

Korpus na tyle mały, by można go było przeczytać

W eksperymencie użyto wymyślonego zestawu dokumentacji dotyczącej ograniczeń szybkości, kopii zapasowych, eksportu w formacie CSV, błędów API, autoryzacji, monitoringu oraz rejestrów audytowych. Nie był on imponujący, co było zamierzone: na tyle mały, by osoba mogła przeczytać każdy wydobyty fragment, a jednocześnie na tyle skomplikowany, by kilka fragmentów mogło wydawać się istotnych dla jednego pytania. Każdy fragment otrzymał stały identyfikator:

chunks = [
    {
        "id": "audit_03",
        "source": "audit-logs",
        "text": (
            "Enterprise audit logs are retained for 365 days "
            "before automatic deletion."
        ),
    },
    {
        "id": "errors_07",
        "source": "api-errors",
        "text": (
            "NX-204 means the requested resource exists but is not "
            "available in the caller's current region."
        ),
    },
    {
        "id": "exports_01",
        "source": "csv-exports",
        "text": (
            "CSV exports run asynchronously and appear in the exports "
            "panel when processing completes."
        ),
    },
    {
        "id": "exports_04",
        "source": "csv-exports",
        "text": (
            "A completed CSV download link remains active for seven days."
        ),
    },
]

ID wygląda jak metoda księgowości, ale pozwala uniknąć kłopotliwego skrótu później. Zestaw zawierał również 12 zapytań oceniających, z których każde było oznaczone fragmentami, które były rzeczywiście istotne:

eval_cases = [
    {
        "query": "How long are enterprise audit logs kept?",
        "relevant": {"audit_03"},
    },
    {
        "query": "What does error NX-204 mean?",
        "relevant": {"errors_07"},
    },
    {
        "query": "How long is a CSV export link usable?",
        "relevant": {"exports_04"},
    },
]

Dwanaście zapytań stanowiłoby śmieszny punkt odniesienia dla testów wydajności, ale to nie było celem. Celem było posiadanie wystarczającej liczby przypadków, aby zobaczyć, kiedy naprawa jednego zapytania mimowolnie uszkodziła inne. Nawet mały zbiór do oceny podważa pewność, która wynika z ponownego ręcznego testowania tych samych trzech zapytań.

Celowo prosty punkt odniesienia

Jako punkt odniesienia użyto TF-IDF, skróconej metody SVD oraz podobieństwa kosinowego. W prawdziwym projekcie prawdopodobnie wybrano by nowoczesny model embeddingowy, ale dodanie go tutaj wprowadziłoby kolejną zmienną bez pomocy w rozwiązaniu aktualnego problemu. Narzędzie do wyszukiwania nie musiało być skomplikowane – musiało to być coś, co można było uszkodzić, przeanalizować i zmierzyć.

import numpy as np

from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import normalize


class LsaRetriever:
    def __init__(self, chunks, dims=16):
        self.chunks = chunks

        self.tfidf = TfidfVectorizer(
            stop_words="english",
            ngram_range=(1, 2),
            sublinear_tf=True,
        )

        term_matrix = self.tfidf.fit_transform(
            chunk["text"] for chunk in chunks
        )

        # The corpus is tiny. SVD doesn't need dimensions it cannot use.
        dims = min(
            dims,
            term_matrix.shape[0] - 1,
            term_matrix.shape[1] - 1,
        )

        if dims < 1:
            raise ValueError("Need more text to build the LSA index.")

        self.svd = TruncatedSVD(
            n_components=dims,
            random_state=0,
        )

        self.index = normalize(
            self.svd.fit_transform(term_matrix)
        )

    def search(self, query, limit=None):
        query_vec = self.tfidf.transform([query])
        query_vec = normalize(self.svd.transform(query_vec))

        similarity = cosine_similarity(
            query_vec,
            self.index,
        )[0]

        ranked = np.argsort(similarity)[::-1]

        if limit is not None:
            ranked = ranked[:limit]

        return [
            (self.chunks[i], float(similarity[i]))
            for i in ranked
        ]

Pierwsza faza dzielenia dokumentacji odbyła się zdanie po zdaniu. Zadanie dotyczące CSV dało następujący wynik:

1. 0.879  exports_01
   CSV exports run asynchronously and appear in the exports panel...

2. 0.843  exports_02
   Large exports are split into multiple compressed files.

3. 0.830  exports_03
   Users can cancel an export while it is still queued...

4. 0.772  exports_04
   A completed CSV download link remains active for seven days.

W perspektywie czasu najwyższy wynik 0,879 wydaje się niemal zabawny, ponieważ wygląda na to, że system dokładnie wie, co robi. Potem czyta się ten fragment, a on w ogóle nic nie odpowiada. To właśnie wtedy należy przestać traktować wynik podobieństwa jako gwarancję.

Awaria występuje przed generowaniem. Narzędzie do wyszukiwania wybiera prawdopodobny, ale bezużyteczny kontekst, a model językowy widzi tylko to, co przeżywa filtr top-k.

Wyświetl to, co wróciło

Zanim zaczniesz modyfikować modele, rozmiary fragmentów lub strategie wyszukiwania, małe narzędzie pomocnicze, które wyświetla to, co zwraca narzędzie do wyszukiwania, sprawdza się doskonale.

def show_hits(retriever, query, limit=5):
    print(f"\n{query}\n")

    for position, (chunk, score) in enumerate(
        retriever.search(query, limit),
        start=1,
    ):
        print(
            f"{position:>2}. {score:.3f}  "
            f"{chunk['id']} ({chunk['source']})"
        )
        print(f"    {chunk['text']}\n")

Ze wszystkiego, co występuje w eksperymencie, ten narzędzie pomocnicze jest używane najczęściej. Gdy fragmenty pojawiają się na ekranie, niepowodzenia są klasyfikowane według określonych typów. Może występować właściwy dokument, ale wybierana jest niewłaściwa sekcja, albo dwa lub trzy fragmenty mogą być niemal identyczne. W innych przypadkach zapytanie i tekst pokrywają się na tyle pod względem słownictwa, by dać wysoką ocenę, choć sam tekst nie nadaje się jako dowód. Zwykłe liczby ukrywają każdy z tych przypadków, chyba że ktoś przeczyta tekst.

Podkreśla to również coś, co łatwo przeoczyć: narzędzie to podejmuje decyzje o znaczeniu. To, co przejdzie tę fazę, staje się wersją rzeczywistości modelu, a zasługuje na dokładniejszą analizę, niż zwykle otrzymuje.

Ocena według ID, a nie według dopasowania ciągu znaków

W wczesnej wersji projektu oceny istniała skrótka, która później została usunięta. Sprawdzała ona obecność określonego ciągu znaków w tekście fragmentu:

if answer_hint in chunk["text"]:
    ...

Praktyczne, ale niewiarygodne. Niewielka zmiana w granicach fragmentów, zmiana interpunkcji lub parafraza mogą sprawić, że poprawne odzyskanie informacji będzie wyglądać błędnie. Co gorsza, ocena jest powiązana z sformułowaniem syntetycznej odpowiedzi, a nie z oceną istotności, która jest ważna. Dlatego fragmenty otrzymały wcześniej ID, aby oceniający mógł je bezpośrednio sprawdzić:

def evaluate_retriever(retriever, cases, k=3):
    recall_scores = []
    reciprocal_ranks = []

    for case in cases:
        hits = retriever.search(case["query"])
        relevant = case["relevant"]

        relevant_positions = [
            position
            for position, (chunk, _) in enumerate(hits, start=1)
            if chunk["id"] in relevant
        ]

        found_in_top_k = sum(
            position <= k
            for position in relevant_positions
        )

        recall_scores.append(
            found_in_top_k / len(relevant)
        )

        reciprocal_ranks.append(
            1 / relevant_positions[0]
            if relevant_positions
            else 0.0
        )

    return {
        f"recall@{k}": float(np.mean(recall_scores)),
        "mrr": float(np.mean(reciprocal_ranks)),
    }

Zbadano dwa wskaźniki. Recall@K określa, czy potrzebne dowody trafiają do okna kontekstowego, natomiast Mean Reciprocal Rank odróżnia poprawny fragment na pozycji pierwszej od ukrytego na pozycji ósmej. Wyniki bazowe: Recall@1 wynosi 0,750, Recall@3 wynosi 0,917, a MRR wynosi 0,854.

Problematyczną wartością jest Recall@3. Przy planowanym kontekście składającym się z trzech fragmentów niektóre pytania straciły już informacje niezbędne do udzielenia poprawnej odpowiedzi jeszcze przed rozpoczęciem generowania. Dostosowywanie instrukcji systemu w tym momencie byłoby bezskuteczne.

Wielkość fragmentu: częściowy winowajca

Głównym podejrzanym były fragmenty na poziomie zdań, które w części ponosiły winę. Przy fragmentach składających się z dwóch zdań Recall@1 wzrósł z 0,750 do 0,917, Recall@3 osiągnął wartość 1,000, a MRR wzrósł do 0,958. Fragmenty składające się z trzech zdań dały niemal identyczne wyniki. Całe strony przyniosły doskonałe wyniki, co daje satysfakcję przez około pięć sekund.

Problem polegał na tym, że strony były bardzo małe. Zwracanie całej strony sprawiało, że niemal niemożliwe było przeoczenie odpowiedzi, więc dokładność wyszukiwania poprawiła się częściowo dzięki temu, że każda jednostka zawierała większą ilość tekstu. Ma to jednak swoją cenę. Większe fragmenty zużywają kontekst, zawierają niespokrewnione zdania i pozostawiają mniej miejsca na oddzielne dowody, a system wyszukiwania, który zawsze zwracałby cały podręcznik, osiągnąłby również doskonałe wyniki pod względem przywoływania informacji.

Zatem lekcją nie była magiczna wielkość fragmentu. Chodziło o to, że pierwotne dzielenie na poziomie zdań było zbyt radykalne, co jest mniej interesującym, ale nadal użytecznym wnioskiem. To również budzi podejrzenia co do takich ustawień:

chunk_size = 500
chunk_overlap = 50

Sam w sobie z tymi liczbami nie ma nic złego. Po prostu nie powinny trafić do produkcji, ponieważ akurat w jakimś tutorialu zostały one użyte. Dzielenie na fragmenty jest częścią procesu wydobywania informacji i, podobnie jak wszystko inne, wymaga dowodów.

Dokładne terminy niosą znaczenie

Przykład pliku CSV dostarczył kolejnej wskazówki. Każdy tekst o wysokim rankingu był semantycznie bliski danym eksportowanym, więc system wydobywania informacji nie błędnie zinterpretował domenę. Po prostu nie potrafił wystarczająco dobrze odróżnić poszczególnych fragmentów wewnątrz niej. Dokumentacja techniczna poprawia tę sytuację, ponieważ dokładne ciągi znaków niosą ogromne ilości informacji. Kod błędu taki jak NX-204 nie jest jedynie semantycznie podobny do sąsiedniego kodu – to samo dotyczy customer_id, punktu końcowego, numeru wersji czy nazwy funkcji.

BM25 osiągnął stałą wartość 1,000 w metrykach Recall@1, Recall@3 i MRR. Przewyższył bazę semantyczną, spisał się lepiej, niż ktokolwiek mógł przypuszczać, i co irytujące, był bezbłędny na tym małym zbiorze danych. Tytuł w stylu „embeddings są martwe” mógłby być kuszący, ale dane tego nie potwierdzają. Pokazują one jedynie, że ten zbiór do oceny bardzo sprzyjał dokładnemu dopasowaniu terminologii. Zbiór pełen przepisanych pytań mógłby wybrać innego zwycięzcę. Kluczowym wnioskiem jest to, jak ważne było dopasowanie leksykalne w tym korpusie, co jest właśnie powodem tworzenia zbiorów do oceny.

Hibrydowe wyszukiwanie z fuzją rankingu

Odrzucenie semantycznego wyszukiwania wydawało się nadal przedwczesne. Obie metody zawodzą w różny sposób, dlatego ich rankingi połączono za pomocą Reciprocal Rank Fusion. Ważny jest jeden detal implementacji: każdy mechanizm wyszukiwania dostarcza jedynie ograniczoną liczbę kandydatów. Rankingowanie ogromnego korpusu dwukrotnie i łączenie wszystkiego w Pythonie byłoby dziwnym rozwiązaniem do naśladowania.

from collections import defaultdict


def fuse_rankings(vector_hits, bm25_hits, rrf_k=60):
    fused = defaultdict(float)
    chunks_by_id = {}

    for hits in (vector_hits, bm25_hits):
        for rank, (chunk, _) in enumerate(hits, start=1):
            chunk_id = chunk["id"]
            chunks_by_id[chunk_id] = chunk
            fused[chunk_id] += 1 / (rrf_k + rank)

    ranked_ids = sorted(
        fused,
        key=fused.get,
        reverse=True,
    )

    return [
        (chunks_by_id[chunk_id], fused[chunk_id])
        for chunk_id in ranked_ids
    ]

Opakowanie wokół niego jest proste:

def hybrid_search(query, lsa, bm25, candidate_k=20):
    vector_hits = lsa.search(query, limit=candidate_k)
    bm25_hits = bm25.search(query, limit=candidate_k)

    return fuse_rankings(vector_hits, bm25_hits)

Hybrydowy mechanizm wyszukiwania podniósł wartość MRR do 0,958 i naprawił błąd Recall@3 występujący wcześniej. Nadal przegrywał z BM25. Eksperyment nie był modyfikowany, dopóki nie zwyciężyła bardziej złożona architektura, ponieważ to zniweczyłoby cel badania. Więcej komponentów nie oznacza automatycznie wyższej trafności.

Tani mechanizm ponownego rankowania

Wyszukiwarka musi przeszukiwać szeroko, więc sensowne jest, aby była tania. Gdy pojawi się 10 lub 20 prawdopodobnych kandydatów, bardziej precyzyjna weryfikacja trafności staje się dostępna cenowo. W tym lokalnym eksperymencie taką weryfikacją był oceniający n-gramy znakowe. Jest to tani mechanizm eksperymentalny do ponownego rankowania, a nie rozwiązanie przeznaczone do użycia w produkcji. W rzeczywistym systemie należałoby rozważyć wykorzystanie uczonego kodera krzyżowego.

def cheap_local_rerank(query, candidates, limit=5):
    """
    Good enough for this experiment.
    I'd use a learned reranker for a real deployment.
    """
    candidate_text = [
        chunk["text"]
        for chunk, _ in candidates
    ]

    tfidf = TfidfVectorizer(
        analyzer="char_wb",
        ngram_range=(3, 5),
        min_df=1,
    )

    matrix = tfidf.fit_transform(
        [query, *candidate_text]
    )

    relevance = cosine_similarity(
        matrix[0],
        matrix[1:],
    )[0]

    reranked = sorted(
        zip(candidates, relevance),
        key=lambda row: row[1],
        reverse=True,
    )

    return [
        (chunk, float(score))
        for ((chunk, _), score) in reranked[:limit]
    ]

Proces kończy się prostym układem składającym się z dwóch etapów.

Strategia polega najpierw na zebraniu szerokiego zestawu kandydatów, a następnie na ich ponownym sklasyfikowaniu tak, aby tylko najbardziej istotne fragmenty trafiły do modelu LLM. Po ponownym sklasyfikowaniu mały zestaw testowy osiągnął doskonałą ocenę 1,000 w metrykach Recall@1, Recall@3 i MRR. Ta wartość nie powinna pojawiać się na slajdzie ogłaszającym, że problem wyszukiwania został rozwiązany, ponieważ dwanaście zapytań pomaga w rozwoju, ale niewiele mówi o ogólnej niezawodności. Mimo to eksperyment spełnił swój cel. Oto jak porównywane są poszczególne etapy:

Gdyby historia została opracowana przed eksperymentem, każdy wiersz poprawiłby się w uporządkowany sposób. Tak się nie stało. BM25, prosty wariant, zrównał się z końcowym rozwiązaniem. Szukanie hybrydowe poprawiło początkową bazę semantyczną, ale nie zająło pierwszego miejsca. Większe fragmenty rozwiązały jeden problem, ale pojawiło się inne pytanie dotyczące budżetu kontekstowego. Brak uporządkowania sprawia, że wynik jest bardziej wiarygodny, a także ujawnił coś konkretnego na temat tego obciążenia: dokładna terminologia miała duże znaczenie. To lepsze niż wygranie konkursu na najpopularniejszą architekturę.

Pierwotne pytanie brzmiało:

How long is a CSV export link usable?

Przed zmianami ranking wyglądał w ten sposób:

1. CSV exports run asynchronously...
2. Large exports are split...
3. Users can cancel an export...
4. A completed CSV download link remains active for seven days.

Później:

1. A completed CSV download link remains active for seven days.
2. Users can cancel an export while it is still queued...
3. CSV exports run asynchronously...

Dokumenty źródłowe pozostały takie same, podobnie jak pytanie, więc nie było potrzeby używania bardziej zaawansowanego modelu językowego. Zmieniły się natomiast dowody.

Procedura debugowania błędnych odpowiedzi RAG

To rozróżnienie decyduje o tym, jak reagować, gdy odpowiedź RAG jest błędna. Jeśli potrzebne dowody w ogóle nie trafiają do kontekstu, należy sprawdzić proces ich pobierania. Gdy dowody są wyraźnie obecne, a model nadal podaje bzdury, dopiero wtedy należy przyjrzeć się procesowi generowania tekstu, sposobom formułowania zapytań, kolejności umieszczania informacji w kontekście oraz zachowaniu modelu. Istnieje również trzeci rodzaj błędu: właściwa odpowiedź trafia do kontekstu, ale znajduje się pod stosem słabo powiązanego tekstu. To problem z rankowaniem lub konstruowaniem kontekstu, a zaliczanie tego wszystkiego do kategorii „halucynacji” maskuje ten problem.

Praktyczny proces sprawdza najpierw uzyskane dowody, a następnie decyduje, czy błąd leży w procesie wydobycia informacji, czy w ich generowaniu. To właśnie ten moment decyzyjny jest najważniejszy. Gdy wskaźnik Recall@3 jest niski, nie ma sensu spędzać godziny na przepisywaniu frazy „odpowiedź tylko na podstawie dostarczonego kontekstu” w sześciu coraz bardziej surowych wersjach, ponieważ kontekst i tak jest błędny.

Zmiany w promptach nadal wydają się szybsze – edytujesz, uruchamiasz ponownie i od razu widzisz nową odpowiedź. Praca z wydobyciem informacji jest wolniejsza i mniej satysfakcjonująca: trzeba czytać fragmenty tekstu, oceniać ich istotność, sprawdzać rankingi, radzić sobie z granicami fragmentów, a czasami okazuje się, że stary model do wydobycia słów lepiej radzi sobie niż nowocześniejsze rozwiązania. Mimo to to, czego się tak naucza, jest bardziej wiarygodne.

Najważniejsze pytanie to nie to, dlaczego LLM udzielił błędnej odpowiedzi, lecz to, czy w ogóle otrzymał potrzebne informacje. Brzmi to niemal zbyt oczywiste, by się tym przejmować, a mimo to jest często pomijane.