Startseite / Artikel

Debugging von RAG-Auswertungsmethoden: Recall@K, BM25, hybride Fusion sowie Neubewertung

Ein kleines Experiment mit einem RAG-Pipeline, bei dem der richtige Chunk auf dem vierten Platz landete, um zu zeigen, wie Chunking, BM25, Rangfusion und Neuranking die Recall@3-Werte sowie den MRR verändern.

Verlag: REACTAPP.TOP11 Min. Lesezeit2556 Wörter

Quellen: How to Fix a RAG System That Keeps Retrieving the Wrong Context

Eine Frage, die der Retriever falsch beantwortet hat

Nehmen wir ein einfaches RAG-Pipeline-System und stellen ihm eine eigentlich einfache Frage: Wie lange ist ein CSV-Export-Link nutzbar? In der Dokumentation steht die Antwort darauf. Ein fertiger CSV-Download-Link bleibt sieben Tage lang aktiv. Dennoch rangiert der Retriever diesen Abschnitt auf Platz vier ein, und da nur die drei besten Abschnitte an das Sprachmodell weitergeleitet werden, erreicht dieser die ursprüngliche Frage nie.

Was die Fehlbeantwortung besonders ärgerlich macht, ist, dass die drei besten Ergebnisse nicht falsch erscheinen. Einer beschreibt, wie CSV-Exporte verarbeitet werden, ein anderer befasst sich mit großen Exportmengen und ein weiterer erklärt, wie man einen Export storniert. Auf den ersten Blick scheint der Retriever seine Arbeit gut zu machen. Das ist jedoch nicht der Fall. Er fand Abschnitte zum richtigen Thema, doch keiner enthielt die für die Frage benötigte Information.

Die übliche Reaktion auf eine schlechte RAG-Antwort ist, weiterzumachen: den Prompt umzuschreiben, eine Anweisung hinzuzufügen oder dem Modell die Schuld zu geben. Das Anpassen des Prompts ist befriedigend, denn bereits drei geänderte Zeilen ändern die Ausgabe sofort. Hier hätte das jedoch nichts gebracht. Das Modell hat diese Dokumente nicht ausgewählt, sondern der Retriever – und kein Prompt kann einen Abschnitt zurückholen, der niemals in das Kontextfenster gelangt ist.

Das wird umso wichtiger, je weiter das System entwickelt wird. Nach der Demo-Phase wird eine schlechte Informationsbeschaffung zu einem Fehler, der aus der Endantwort schwer erkennbar ist. Das Modell kann selbstsicher klingen, der Kontext mag vage relevant erscheinen, doch der eigentliche Fehler liegt mehrere Schritte weiter oben. Eine bessere Vorgehensweise besteht darin, die Ursachen des Fehlers zu trennen: fehlende Belege, schwaches Aufteilen in Teile, schlechte Ranking-Logik oder Probleme beim Generieren. Die Reihenfolge der Fehlersuche wird dann wie folgt aussehen:

Falsche Antwort – erst die Abrufprozesse überprüfen, anschließend messen, danach beheben, und erst danach auf die Erstellung achten.

Ein kleines Experiment zeigt, wie wichtig die frühen Phasen sind.

Ein Korpus, das klein genug zum Lesen ist

Im Experiment wurde ein erfundenes Dokumentationsset zu Themen wie Rate Limits, Backups, CSV-Exporten, API-Fehlern, Authentifizierung, Überwachung und Audit-Logs verwendet. Es war nicht beeindruckend – und das war beabsichtigt: klein genug, damit eine Person jeden abgerufenen Abschnitt lesen kann, aber doch komplex genug, sodass mehrere Abschnitte für eine Frage relevant erscheinen könnten. Jeder Abschnitt erhielt eine eindeutige ID:

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

Die ID wirkt wie eine Buchhaltungsbezeichnung, vermeidet aber später einen unangenehmen Umweg. Die Sammlung enthielt außerdem 12 Bewertungsabfragen, jede mit den tatsächlich relevanten Abschnitten versehen:

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

Zwölf Abfragen wären ein lächerlicher Produktivitätsmaßstab, aber das war nicht das Ziel. Es ging darum, genügend Fälle zu haben, um festzustellen, wann die Behebung einer Abfrage versehentlich eine andere beeinträchtigte. Schon eine sehr kleine Bewertungsgruppe untergräbt das Vertrauen, das durch das manuelle Erneuttesten derselben drei Fragen entsteht.

Eine absichtlich einfache Basislinie

Als Basislinie wurden TF-IDF, abgeschnittene SVD-Methode sowie Kosinusähnlichkeit verwendet. Ein echtes Projekt würde wahrscheinlich ein modernes Embedding-Modell wählen, doch dessen Hinzufügen würde weitere Variablen einbringen, ohne der jeweiligen Frage zu helfen. Der Abfrager musste nicht besonders ausgefeilt sein – er musste lediglich etwas sein, das beschädigt, untersucht und gemessen werden konnte.

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
        ]

Die erste Aufteilungsschleife teilte die Dokumentationstexte Satz für Satz auf. Die Abfrage des CSV-Formats ergab anschließend Folgendes:

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.

Der höchste Wert von 0,879 erscheint im Nachhinein fast lächerlich, da es so aussieht, als wüsste das System genau, was es tut. Wenn man sich dann den entsprechenden Textabschnitt ansieht, antwortet er jedoch auf nichts. In diesem Moment sollte man aufhören, einen Ähnlichkeitswert als Bestätigung zu betrachten.

Der Fehler tritt bereits vor der Generierung auf. Der Retriever wählt plausiblen, aber nutzlosen Kontext aus, und das LLM sieht nur das, was die Top-k-Filterung überlebt.

Ausgeben, was zurückkommt

Bevor man Modelle, Blockgrößen oder Retrieval-Strategien anpasst, ist ein kleiner Hilfsprogramm, das ausgibt, was der Retriever zurückgibt, äußerst nützlich.

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

Von allem im Experiment wird dieser Hilfsmechanismus am häufigsten verwendet. Sobald die Textabschnitte auf dem Bildschirm erscheinen, werden die Fehlschläge in verschiedene Kategorien eingeteilt. Es kann vorkommen, dass das richtige Dokument vorhanden ist, während der falsche Abschnitt bevorzugt wird, oder dass zwei oder drei Abschnitte nahezu identisch sind. In anderen Fällen überschneiden sich die Abfrage und der Textabschnitt in ihrem Wortschatz so weit, dass eine gute Bewertung erzielt wird, obwohl der Textabschnitt als Beweismittel nutzlos ist. Einfache Zahlen verbergen all diese Fälle, es sei denn, jemand liest den Text.

Er unterstreicht außerdem etwas, das leicht übersehen wird: Der Abrufmechanismus trifft eine entscheidende Wahl. Alles, was diese Phase übersteht, wird zur Realitätsdarstellung des Modells – und das verdient mehr Prüfung, als es üblicherweise erhält.

Bewertung nach ID, nicht nach Zeichenkettenübereinstimmung

Ein früher Entwurf der Bewertung enthielt einen Abkürzungsweg, der später entfernt wurde. Dabei wurde nach einer Hinweistextzeichenkette im Abschnittstext gesucht:

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

Praktisch, aber unzuverlässig. Eine kleine Verschiebung der Chunk-Grenzen, eine Änderung in der Interpunktion oder eine Umformulierung kann dazu führen, dass eine korrekte Extraktion falsch erscheint. Noch schlimmer ist, dass die Bewertung an die Formulierung einer synthetischen Antwort gebunden ist und nicht an das entscheidende Relevanzurteil. Deshalb erhielten die Chunks von Anfang an IDs, damit der Bewertende sie direkt überprüfen kann:

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)),
    }

Zwei Messgrößen wurden überwacht. Recall@K gibt an, ob die benötigten Belege in das Kontextfenster gelangen, und Mean Reciprocal Rank unterscheidet einen korrekten Abschnitt an Position eins von einem versteckten Abschnitt an Position acht. Baseline-Ergebnisse: Recall@1 von 0,750, Recall@3 von 0,917, MRR von 0,854.

Recall@3 ist die besorgniserregende Zahl. Bei einem geplanten Kontext aus drei Abschnitten verloren bereits einige Fragen vor Beginn der Generierung die für eine richtige Antwort notwendigen Informationen. In diesem Fall wäre eine Anpassung des Systemprompt nur Scheinmanöver.

Größe der Abschnitte: ein teilweiser Schuldiger

Satzbasierte Abschnitte waren der Hauptverdächtige und trugen teilweise die Schuld. Bei zweisatzigen Abschnitten stieg Recall@1 von 0,750 auf 0,917, Recall@3 erreichte 1,000 und MRR stieg auf 0,958. Drei-satzige Abschnitte ergaben nahezu identische Ergebnisse. Ganze Seiten lieferten perfekte Ergebnisse, was etwa fünf Sekunden lang großartig wirkt.

Das Problem war, dass die Seiten sehr klein waren. Wenn man eine ganze Seite zurückgab, war es fast unmöglich, die Antwort zu übersehen, wodurch die Suche teilweise verbessert wurde, weil jede Einheit mehr vom Korpus enthielt. Dafür gibt es jedoch einen Preis: Größere Abschnitte verbrauchen den Kontext, enthalten unzusammenhängende Sätze und lassen weniger Raum für getrennte Belege. Ein Suchalgorithmus, der stets ein ganzes Handbuch zurückgibt, würde auch bei der Wiedererkennung hervorragende Ergebnisse erzielen.

Die Lektion bestand also nicht in einer magischen Abschnittsgröße. Vielmehr war die ursprüngliche Aufteilung auf Satzebene zu aggressiv – eine weniger brillante, aber dennoch nützliche Schlussfolgerung. Sie weckt außerdem Misstrauen gegenüber solchen Einstellungen:

chunk_size = 500
chunk_overlap = 50

An diesen Zahlen an sich ist nichts auszusetzen. Sie sollten nur nicht in die Produktion gelangen, weil zufällig ein Tutorial sie verwendet hat. Das Aufteilen in Blöcke ist Teil des Abrufvorgangs und benötigt genauso wie alles andere Belege.

Genaue Begriffe tragen die Bedeutung

Das CSV-Beispiel lieferte einen weiteren Hinweis: Jeder hochrangige Abschnitt war semantisch den Exportdaten nahe, sodass der Abrufmechanismus das Thema nicht falsch interpretiert hatte. Er unterschied sich einfach nicht ausreichend zwischen den Abschnitten darin. Technische Dokumentation verbessert dies, denn genaue Zeichenketten tragen eine enorme Menge an Bedeutung. Ein Fehlercode wie NX-204 ist nicht nur semantisch ähnlich zu einem benachbarten Code – genauso verhält es sich mit customer_id, einem Endpunkt, einer Versionnummer oder einem Funktionsnamen.

BM25 erzielte bei Recall@1, Recall@3 und MRR einen konstanten Wert von 1,000. Es übertraf den semantischen Baseline-Modell, schnitt besser ab, als man erwarten konnte, und war – ärgerlicherweise – auf dieser kleinen Datensammlung fehlerfrei. Ein Schlagzeilen-Titel wie „Embeddings sind tot“ wäre verlockend, doch die Daten stützen das nicht. Stattdessen zeigt sich, dass diese Bewertungsdatensammlung bei präziser Terminologie große Vorteile brachte. Eine Sammlung voller umformulierter Fragen könnte einen anderen Gewinner hervorbringen. Die Schlussfolgerung ist, wie wichtig die lexikalische Übereinstimmung für dieses Korpus war – und das ist schließlich der Grund, warum überhaupt eine Bewertungsdatensammlung erstellt wird.

Hybride Suche mit Rangfusion

Das Weglassen der semantischen Suche schien immer noch vorzeitig zu sein. Die beiden Methoden versagen auf unterschiedliche Weise, weshalb ihre Ranglisten mithilfe der Reciprocal Rank Fusion kombiniert wurden. Ein wichtiger Implementierungsaspekt ist, dass jeder Suchmechanismus nur eine begrenzte Anzahl an Kandidaten liefert. Ein riesiges Korpus zweimal zu ranken und anschließend alles in Python zu verschmelzen, wäre ein seltsamer Entwurf, den man nachbilden sollte.

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
    ]

Der Umschlag darum ist schlicht:

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)

Durch die hybride Suchmethode stieg der MRR auf 0,958 und der ursprüngliche Fehler bei Recall@3 wurde behoben. Dennoch unterlag sie weiterhin BM25. Das Experiment wurde nicht manipuliert, bis die aufwändigere Architektur gewann – schließlich würde das den Zweck zunichtemachen. Mehr Komponenten bedeuten nicht automatisch höhere Relevanz.

Ein günstiger Nachrangierer obenauf

Da die Suche weit gefasst erfolgen muss, ist es sinnvoll, dass sie günstig ist. Sobald es 10 oder 20 plausible Kandidaten gibt, wird eine gezieltere Relevanzprüfung erschwinglich. In diesem lokalen Experiment diente dazu ein Zeichen-n-gramm-Bewertungsalgorithmus. Es handelt sich dabei um einen günstigen experimentellen Nachrangierer und nicht um eine Empfehlung für die Produktion. In einem echten System sollte man stattdessen einen gelernten Cross-Encoder in Betracht ziehen.

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

Der Pipeline endet schließlich in einer einfachen zweistufigen Struktur.

Der Ablauf besteht darin, zunächst eine umfangreiche Liste von Kandidaten zu ermitteln, diese anschließend neu zu ranken, sodass nur die stärksten Elemente zum LLM gelangen. Nach der Neuranking-Phase erzielte die kleine Testmenge einen perfekten Wert von 1,000 bei Recall@1, Recall@3 und MRR. Diese Zahl sollte nicht auf einer Präsentation verwendet werden, die behauptet, das Problem der Informationsabrufung sei gelöst, denn zwölf Abfragen helfen zwar bei der Entwicklung, sagen aber wenig über die allgemeine Zuverlässigkeit aus. Dennoch erfüllte das Experiment seinen Zweck. So sieht der Vergleich der einzelnen Stufen aus:

Hätte die Geschichte vor dem Experiment entworfen werden können, wäre jede Zeile in einer ordentlichen Reihe verbessert worden. So war es jedoch nicht. BM25, die einfache Option, erreichte denselben Ergebnisstand wie der endgültige Prozess. Die hybride Suche verbesserte die Ausgangssemantik, erreichte aber nicht den ersten Platz. Größere Abschnitte lösten ein Problem, doch sie stellten eine neue Frage bezüglich des Kontextbudgets. Die Unordnung macht das Ergebnis glaubwürdiger und zeigte etwas Konkretes über diese Arbeitslast: Genaue Terminologie spielte eine große Rolle. Das ist besser als den Wettbewerb um die beliebteste Architektur zu gewinnen.

Die ursprüngliche Frage lautete:

How long is a CSV export link usable?

Vor den Änderungen sah die Rangliste so aus:

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.

Danach:

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

Die Queldokumente blieben unverändert, genauso wie die Frage, und es war kein intelligenterer LLM erforderlich. Die Beweismittel änderten sich hingegen.

Ein Vorgehensplan zur Fehlerbehebung bei schlechten RAG-Antworten

Genau dieser Unterschied bestimmt, wie man reagieren soll, wenn eine RAG-Antwort fehlerhaft ist. Wenn die benötigten Beweismittel niemals im Kontext ankommen, muss man bei der Abrufphase nachforschen. Wenn die Beweismittel eindeutig vorhanden sind und das Modell dennoch Unsinn von sich gibt, erst dann sollte man sich mit der Generierung, den Anfragen, der Reihenfolge des Kontexts sowie dem Verhalten des Modells beschäftigen. Es gibt auch ein drittes Versagen: Die richtige Antwort gelangt zwar in den Kontext, liegt aber unter einem Haufen nur schwach zusammenhängenden Textes. Das ist ein Problem mit der Rangfolge oder der Kontextkonstruktion, und wenn man alles unter „Halluzination“ abfährt, wird es verschleiert.

Der praktische Ablauf prüft zunächst die abgerufenen Beweise und entscheidet anschließend, ob das Problem bei der Abrufung oder bei der Erstellung liegt. Der Entscheidungspunkt ist der wichtige Teil. Wenn Recall@3 schlecht ist, macht es keinen Sinn, eine Stunde damit zu verbringen, „Antwort nur unter Verwendung des bereitgestellten Kontexts“ auf sechs immer strengere Weisen umzuschreiben, denn der Kontext ist bereits falsch.

Änderungen an den Anfragen erscheinen weiterhin schneller. Man bearbeitet, führt erneut aus und sieht sofort eine neue Antwort. Die Arbeit mit der Abrufung ist langsamer und weniger zufriedenstellend: Das Lesen von Textabschnitten, das Kennzeichnen der Relevanz, die Überprüfung der Rangfolgen, das Herumstreiten mit den Grenzen der Abschnitte – und manchmal stellt sich heraus, dass ein herkömmlicher lexikalischer Abrufer besser abschneidet als eine aufwendigere Konfiguration. Was er jedoch lehrt, ist zuverlässiger.

Die wichtigste Frage ist nicht, warum das LLM falsch geantwortet hat, sondern ob es überhaupt die benötigten Informationen erhalten hat. Das klingt fast zu offensichtlich, um sich damit aufzuhalten, wird aber oft übersehen.