Strona główna / Artykuły / Wskazówki praktyczne: Jak naprawić system RAG, który ciągle pobiera niewłaściwy kontekst

Wskazówki praktyczne: Jak naprawić system RAG, który ciągle pobiera niewłaściwy kontekst

Krok po kroku instrukcja z praktycznymi wskazówkami: jak naprawić system RAG, który ciągle pobiera niewłaściwy kontekst – umowy, sprawdzenia oraz gotowe elementy kodu dla zespołów wdrażających ten wzorzec.

2332 słów

Niech to służy jako wersja przeznaczona dla operatorów, zawierająca zrekonstruowane idee z artykułu „Jak naprawić system RAG, który ciągle pobiera niewłaściwy kontekst”: wyraźne etapy, uporządkowane sekcje kodu oraz notatki dotyczące napraw, które przetrwają przeniesienie obowiązków. Etap Przeglądu działa najlepiej, gdy traktowany jest jako mierzalna powierzchnia do analizy. Zapisz jeden idealny zapis rozmowy, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian, zanim rozszerzysz zakres pracy. Wolno preferować małe, testowalne jednostki zamiast rozbudowanych skryptów. Gdy jakiś krok zawiedzie, awaria powinna wskazywać na konkretną odpowiedzialność, a nie na skomplikowany łańcuch operacji.

Potrzebowałeś awarii, którą można odtworzyć

Aby potrzebna była faza niepowodzenia, należy zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed modyfikacją kodu. Operatorzy powinni móc ponownie uruchomić ten krok od znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Traktuj tę fazę jako umowę pomiędzy danymi wejściowymi a zweryfikowanymi wynikami. Nazwij poszczególne elementy, zdefiniuj sprawdzenia sukcesu i odrzuć ciche, częściowe ukończenie zadania. Podaj fragmenty tekstu, które faktycznie stanowią podstawę odpowiedzi. Bez tych odniesień operatorzy nie będą w stanie odróżnić halucynacji od luki w indeksowaniu.

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."
        ),
    },
]
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"},
    },
]

Zacząłeś od celowo prostego mechanizmu wyszukiwania

Aby rozpocząć od określonego etapu, zdefiniuj wprowadzenia, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed modyfikacją kodu. Operatorzy powinni móc ponownie uruchomić ten krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Zapisuj czas trwania oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega niespodziewanym rachunkom, gdy ścieżka przechodzi z środowiska demonstracyjnego do współdzielonych środowisk. Wskazuj fragmenty tekstu, które faktycznie stanowiły podstawę odpowiedzi. Bez tych odniesień operatorzy nie mogą odróżnić halucynacji od luki w indeksowaniu.

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
        ]
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.

Najprostszy narzędzie do debugowania okazało się najpraktyczniejsze

W najprostszej fazie debugowania należy zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić ten krok od znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Konfigurację należy przechowywać oddzielnie od kodu aplikacji. Pliki środowiskowe, magazyny tajnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności przeglądania całej struktury. Należy podawać konkretne fragmenty tekstu, na których opiera się odpowiedź. Bez tych odniesień operatorzy nie są w stanie odróżnić halucynacji od braku danych w indeksie. W najprostszej fazie debugowania należy zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić ten krok od znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Należy preferować małe, testowalne jednostki nad rozbudowanymi skryptami. Gdy dany krok zawiedzie, błąd powinien wskazywać na konkretną odpowiedzialność zamiast

r niż splątana ścieżka przetwarzania.

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")

Nie chciałeś, aby ocena była powiązana z dokładnym sformułowaniem

Podczas przechodzenia przez tę fazę najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co się dzieje w przypadku częściowego niepowodzenia. Ta lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Traktuj tę fazę jako umowę pomiędzy danymi wejściowymi a zweryfikowanymi wynikami. Nadaj nazwy poszczególnym elementom, zdefiniuj kryteria sukcesu i odrzuć ciche, częściowe ukończenie zadania. Zmierz stopień przywoływania informacji na ustalonej serii pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko naprawiają słabe możliwości wyszukiwania.

if answer_hint in chunk["text"]:
    ...
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)),
    }

Następnie obwiniałeś podział na fragmenty

Gdy przechodzisz przez etap Then you blamed chunking, najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Zapisz czasy wykonywania oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega nieoczekiwanym rachunkom, gdy ścieżka przechodzi z środowiska demonstracyjnego do współdzielonych środowisk. Zmierz stopień przywoływania informacji na ustalonej grupie pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko naprawiają słabe możliwości wyszukiwania.

chunk_size = 500
chunk_overlap = 50

BM25 sprawiło, że eksperyment stał się nieco krępujący

Gdy pracujesz nad fazą eksperymentalną BM25, najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Przechowuj konfigurację poza kodem aplikacji. Pliki środowiskowe, magazyny tajnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności czytania całej struktury. Zmierz stopień odzyskiwania informacji na ustalonej grupie pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko naprawiają słabe mechanizmy wyszukiwania. Gdy pracujesz nad fazą eksperymentalną BM25, najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Wolij małe, testowalne jednostki nad rozbudowane skrypty. Gdy jakiś krok się nie powiedzie, błąd powinien wskazywać na konkretną odpowiedzialność, a nie na skomplikowaną sekwencję operacji.

Nadal chcesz mieć oba sygnały

Najlepiej radzą sobie oba te etapy, gdy traktuje się je jako mierzalną powierzchnię. Zapisz jeden udany wynik, jeden przypadek niepowodzenia oraz notatkę o cofnięciu zmian przed rozszerzeniem zakresu. Traktuj ten etap jako umowę pomiędzy danymi wejściowymi a zweryfikowanymi wynikami. Nadaj nazwy poszczególnym elementom, zdefiniuj kryteria sukcesu i odrzuć ciche, częściowe ukończenie zadania. Oddziel zasadę dzielenia na fragmenty od zasady pobierania danych. Zmiana jednej nie powinna zmuszać do przepisania drugiej, gdy zmieniają się metryki jakości.

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
    ]
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)

Reranking był ostatnią częścią, którą przetestowałeś

Reranking stanowi ostatni etap, który działa najlepiej, gdy traktuje się go jako mierzalną powierzchnię. Zanim rozszerzysz zakres, zapisz jeden udany przypadek, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian. Zapisz czasy wykonywania operacji oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega niespodziewanym rachunkom, gdy ścieżka przechodzi z środowiska demonstracyjnego do współdzielonych środowisk. Oddziel politykę dzielenia na fragmenty od polityki wyszukiwania. Zmiana jednej z nich nie powinna zmuszać do przepisywania drugiej, gdy zmieniają się metryki jakości.

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]
    ]

Ostateczne liczby okazały się mniej interesujące, niż się spodziewałeś

Ostateczne liczby są najskuteczniejsze na etapie rozwoju, gdy traktuje się je jako mierzalną wartość. Zapisz jeden idealny przykład działania, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian, zanim rozszerzysz zakres projektu. Trzymaj konfigurację poza kodem aplikacji. Pliki środowiskowe, magazyny poufnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności przeglądania całej struktury. Oddziel zasady dzielenia na fragmenty od zasad pobierania danych. Zmiana jednych nie powinna zmuszać do przepisywania drugich, gdy zmieniają się metryki jakości. Ostateczne liczby są najskuteczniejsze na etapie rozwoju, gdy traktuje się je jako mierzalną wartość. Zapisz jeden idealny przykład działania, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian, zanim rozszerzysz zakres projektu. Wolij małe, testowalne jednostki nad rozbudowane skrypty. Gdy jakiś krok zawiedzie, awaria powinna wskazywać na konkretną odpowiedzialność, a nie na skomplikowaną sekwencję operacji.

Z powrotem do zapytania CSV

W etapie powrotu do formatu CSV należy zdefiniować dane wejściowe, osobę odpowiedzialną za ten krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić ten krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Traktuj ten etap jako umowę pomiędzy danymi wejściowymi a zweryfikowanymi wynikami. Nadaj nazwy plikom, zdefiniuj kryteria sukcesu i odrzuć ciche, częściowe ukończenie zadania. Wskazuj fragmenty tekstu, które faktycznie stanowią podstawę odpowiedzi. Bez tych odniesień operatorzy nie będą w stanie odróżnić halucynacji od luki w indeksowaniu.

How long is a CSV export link usable?
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.
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...

Porządek debugowania jest teraz znacznie prostszy

Aby uporządkować proces debugowania, należy najpierw zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić dany krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu systemu. Należy rejestrować czasy wykonywania operacji oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega nieoczekiwanym rachunkom, gdy proces przechodzi z środowiska demonstracyjnego do współdzielonych środowisk. Konieczne jest podawanie fragmentów tekstu, które faktycznie stanowią podstawę odpowiedzi. Bez takich odniesień operatorzy nie są w stanie odróżnić efektu halucynacji od luki w indeksowaniu.

Ostateczne refleksje i wnioski

W fazie końcowych refleksji i podsumowania należy zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić ten krok od znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Konfigurację należy przechowywać poza kodem aplikacji. Pliki środowiskowe, magazyny tajnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności czytania całej struktury. Należy podawać konkretne fragmenty tekstu, na których opiera się odpowiedź. Bez tych odniesień operatorzy nie będą w stanie odróżnić halucynacji od luki w indeksowaniu. W fazie końcowych refleksji i podsumowania należy zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić ten krok od znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Należy preferować małe, testowalne jednostki nad rozbudowanymi skryptami. Gdy dany krok zawiedzie, powinien wskazywać na jedną konkretne odpowiedzialność, a nie na wiele.

splątany przepływ zadań.

Lista kontrolna operacyjna

Podczas prace nad etapem listy kontrolnej operacyjnej najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego awarii. Taka lista zapewnia uczciwość późniejszych zmian w kodzie.

Zdokumentuj zarówno prawidłowy przebieg działania, jak i ścieżkę odzyskiwania. Próby ponowne, kontrola przez ludzi oraz obsługa wiadomości błędnych stanowią część produktu, a nie elementy dodawane później.

Pomierz stopień odzyskiwania informacji na ustalonej grupie pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko naprawiają słabe mechanizmy wyszukiwania.

Zdefiniuj wersje zależności i zapisz hash obrazu użytego do demonstracji. Reprodukowalność jest ważniejsza od wiedzy opartej na doświadczeniach grupy.

Wolę małe, testowalne jednostki niż rozbudowane skrypty. Gdy jakiś krok zawiedzie, awaria powinna wskazywać na konkretną odpowiedzialność, a nie na splątany przepływ zadań.

Mierzyć stopień przywoływania informacji w ustalonym zestawie pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko poprawiają słabą efektywność wyszukiwania.

Zanim przejdziemy do dalszych kroków, zamroź wersje oprogramowania, utwórz „złoty” zapis transkrypcji dla kluczowych ścieżek oraz potwierdź kroki odwracania zmian. Środowiska współdzielone wymagają ograniczeń szybkości, weryfikacji uprawnień użytkowników oraz wyraźnego właściciela odpowiedzialnego za rotację haseł. Lepiej wybrać nudną niezawodność niż pomysłowe, jednorazowe demonstracje.

Uwaga dotycząca wersji 4527c294eba8: nie umieszczaj kluczy dostawcy w repozytorium, ustaw ograniczenie liczby tokenów na sesję oraz przechowuj transkrypcje obok plików używanych do oceny, aby późniejsze zmiany modeli pozostały porównywalne.

Gdy przechodzisz przez etap 0 notatki dotyczącej wzmocnienia bezpieczeństwa, najpierw zapisz warunki umowy: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego awarii. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Przechowuj konfigurację poza kodem aplikacji. Pliki środowiskowe, magazyny tajnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności czytania całej struktury.

Szczegóły wzmocnienia bezpieczeństwa 0/820: zmierz czas wykonywania, klasę błędu oraz zużycie tokenów dla tej notatki, a następnie zdecyduj, czy zachować zmianę na podstawie ustalonego zestawu pytań, a nie jedynie informacji anegdotycznych.