Zum Inhalt
wannacode

RAG bewerten: Wird der Abschnitt gefunden, und ist die Antwort belegt

Kurz gesagt: Die Bewertung von RAG prüft zwei Dinge: ob die Suche den richtigen Abschnitt findet (recall@k) und ob sich die Antwort nur auf das Gefundene stützt.

Problem

Billiger, und es „funktioniert“

Der Assistent aus der vorigen Lektion legt bis zu 3 Abschnitte in den Prompt. Ein Kollege bemerkt, dass die Abschnitte den größten Teil der Tokens ausmachen. Er senkt k auf 1 und probiert zwei Fragen aus:

for (String question : List.of("was kostet die lieferung an eine filiale", "die bank hat meine zahlung abgelehnt"))
System.out.println(question + "\n → " + answer(question, 1));
Ausgabe
was kostet die lieferung an eine filiale
→ Die Lieferung an eine Filiale des Kuriers kostet 3 €. [lieferung-1]
die bank hat meine zahlung abgelehnt
→ Versuche die Zahlung mit einer anderen Karte. [zahlung-2]

Beide Antworten sind richtig, mit Quelle. Der Kollege liefert die Änderung aus.

Am nächsten Tag sagt der Assistent einem Kunden, das Geld komme innerhalb von 14 Tagen zurück. In den Dokumenten stehen fünf. Die zwei von Hand probierten Fragen konnten das nicht fangen: Bei beiden steht der richtige Abschnitt an erster Stelle.

Die Idee

Zwei getrennte Fragen

RAG kann an zwei Stellen schiefgehen, und sie werden getrennt gemessen:

  1. Wird er gefunden? Die Suche muss den richtigen Abschnitt unter den ersten k bringen. Sein Platz unter den gefundenen Abschnitten ist der Rang. Der Anteil der Fragen, bei denen der Rang zwischen 1 und k liegt, ist recall@k. Das ist dieselbe Trefferquote (Recall) wie in der Lektion über die Auswertung eines Modells: Von allem, was du finden solltest, wie viel hast du gefunden.
  2. Ist die Antwort belegt? Die Antwort muss aus den Abschnitten kommen, die das Modell gesehen hat: Jede Quelle muss aus dem Prompt stammen, und jede Zahl muss im zitierten Abschnitt stehen.

Die dritte Prüfung, „ist sie richtig“, ist die Prüfung „muss enthalten“ aus der Lektion über die Evaluation. Alle drei zusammen sagen dir, wo der Fehler liegt: in der Suche, im Modell oder in den Dokumenten.

Dafür brauchst du ein Testset: Kundenfragen und zu jeder der Abschnitt, der sie beantwortet. Das Set schreibt einmal von Hand jemand, der die Dokumente kennt.

Sieh es dir an

Das Testset und der Rang

// ein Fall aus dem Testset: Thema, Frage, der richtige Abschnitt ("" = keiner) und was die Antwort enthalten muss
record Case(String topic, String question, String expect, String has) {}
// das Testset
static final List<Case> CASES = List.of(
new Case("lieferpreis", "was kostet die lieferung an eine filiale", "lieferung-1", "3 €"),
new Case("lieferzeit", "wann ist meine ware da", "lieferung-2", "zwei Tage"),
new Case("rückgabe", "ich will die ware nicht", "rückgabe-1", "14 Tage"),
new Case("geld zurück", "wann kommt das geld zurück", "rückgabe-2", "fünf Tage"),
new Case("nachnahme", "kann ich per nachnahme bezahlen", "zahlung-1", "Nachnahme"),
new Case("karte abgelehnt", "die bank hat meine zahlung abgelehnt", "zahlung-2", "anderen Karte"),
new Case("garantie", "wie lange gilt die garantie auf die ware", "garantie-1", "2 Jahre"),
new Case("sonntag", "habt ihr sonntags geöffnet", "", "Ich weiß es nicht"));
// eine ehrliche Absage beginnt so
static final String DECLINE = "ich weiß es nicht";

Die letzte Frage hat keinen Abschnitt: Der Shop hat nichts über Sonntag geschrieben. Die richtige Antwort ist „Ich weiß es nicht“.

Der Rang ist der Platz des richtigen Abschnitts unter den gefundenen, und recall@k zählt die Fragen, bei denen er unter den ersten k ist:

// Platz des richtigen Abschnitts (ab 1), 0 wenn er fehlt
static int rank(Case c) {
List<Hit> hits = search(c.question(), PIECES.size());
for (int i = 0; i < hits.size(); i++)
if (hits.get(i).piece().id().equals(c.expect()))
return i + 1;
return 0;
}
// recall@k: bei wie vielen Fragen er unter den ersten k ist
static int recall(int k) {
int found = 0;
for (Case c : CASES) {
int r = rank(c);
if (r >= 1 && r <= k)
found++;
}
return found;
}
System.out.printf("%-18s%-6s%-5s%s%n", "Thema", "Rang", "k=1", "k=3");
int total = 0;
for (Case c : CASES) {
if (c.expect().isEmpty())
continue;
total++;
int r = rank(c);
System.out.printf("%-18s%-6d%-5s%s%n", c.topic(), r,
mark(r >= 1 && r <= 1), mark(r >= 1 && r <= 3));
}
for (int k : new int[] {1, 3})
System.out.println("recall@" + k + " = " + recall(k) + "/" + total);
Ausgabe
Thema Rang k=1 k=3
lieferpreis 1 ✓ ✓
lieferzeit 3 ✗ ✓
rückgabe 4 ✗ ✗
geld zurück 2 ✗ ✓
nachnahme 2 ✗ ✓
karte abgelehnt 1 ✓ ✓
garantie 1 ✓ ✓
recall@1 = 3/7
recall@3 = 6/7

// Platz des richtigen Abschnitts (ab 1), 0 wenn er fehlt
static int rank(Case c) {
List<Hit> hits = search(c.question(), PIECES.size());
for (int i = 0; i < hits.size(); i++)
if (hits.get(i).piece().id().equals(c.expect()))
return i + 1;
return 0;
}
// recall@k: bei wie vielen Fragen er unter den ersten k ist
static int recall(int k) {
int found = 0;
for (Case c : CASES) {
int r = rank(c);
if (r >= 1 && r <= k)
found++;
}
return found;
}
Schritt für Schritt, in Worten
  1. Das Testset: 8 Kundenfragen, und zu jeder der passende Abschnitt. Die letzte hat keinen Abschnitt in den Dokumenten.
  2. Rang: An welcher Stelle der richtige Abschnitt unter den gefundenen steht. Bei „rückgabe“ ist es erst Platz 4: Das kleine Vektormodell irrt sich.
  3. Bei k = 1 kommt nur der erste Abschnitt in den Prompt. Der richtige ist bei 3/7 Fragen dabei: recall@1 = 3/7.
  4. Bei k = 3 ist der richtige Abschnitt bei 6/7 dabei: recall@3 = 6/7. Nur „rückgabe“ bleibt draußen.
  5. Stützt sich die Antwort auf die Abschnitte? Jede Quelle muss im Prompt stehen und jede Zahl in der Quelle: 7/8.
  6. Zwei verschiedene Fehler: Die Suche hat den Abschnitt nicht gefunden (recall), und das Modell hat eine Zahl geschrieben, die nicht in seiner Quelle steht (belegt).

Bei k = 1 ist der richtige Abschnitt nur bei 3 von 7 Fragen im Prompt. Bei k = 3 ist er es bei 6. „rückgabe“ hat Rang 4: Das kleine Vektormodell findet „ich will die ware nicht“ der Garantie ähnlicher (Kosinus 1,00) als dem richtigen Abschnitt (0,60), denn die Frage enthält nur das Wort „ware“, und genau darum geht es auch in der Garantie.

Sieh es dir an

Ist die Antwort belegt

Die Prüfung nimmt die Antwort und die Abschnitte, die das Modell gesehen hat. Eine ehrliche Absage besteht sie. Jede Quelle muss unter den Abschnitten sein, und jede Zahl in der Antwort muss ein Wort in den zitierten Abschnitten sein:

// stützt sich die Antwort auf die Abschnitte? "" oder Grund
static String unsupported(String answer, List<Hit> hits) {
if (answer.toLowerCase().startsWith(DECLINE))
return "";
List<String> ids = sources(answer);
if (ids.isEmpty())
return "ohne Quelle";
Set<String> words = new HashSet<>();
for (String source : ids) {
Hit hit = null;
for (Hit h : hits)
if (h.piece().id().equals(source))
hit = h;
if (hit == null)
return "Quelle nicht im Prompt: " + source;
String text = hit.piece().text();
words.addAll(List.of(text.split(" ")));
}
for (String word : answer.split(" ")) {
if (word.startsWith("["))
continue;
String n = word.replaceAll("[^0-9]", "");
if (!n.isEmpty() && !words.contains(n))
return "Zahl " + n + " fehlt in der Quelle";
}
return "";
}

Sie gibt einen leeren String zurück, wenn die Antwort belegt ist, sonst den Grund. Der Grund ist nützlicher als ✗: Er sagt dir, wo du suchen musst.

Probier es aus

Sag die Ausgabe voraus

Bei k = 1 kommt für „wann kommt das geld zurück“ nur „rückgabe-1“ in den Prompt: „du kannst die ware 14 tage lang in der originalverpackung zurückgeben“. Was gibt die Prüfung für diese Antwort aus?

String claim = "Das Geld kommt innerhalb von 14 Tagen zurück. [rückgabe-1]";
String why = unsupported(claim, search("wann kommt das geld zurück", 1));
System.out.println(why.isEmpty() ? "belegt" : why);
Antwort

Sieh es dir an

Das ganze Set

report schickt jede Frage durch RAG und gibt die drei Zeichen aus: gefunden, belegt, richtig. Mit k = 3:

// das ganze Set mit k Abschnitten: gefunden, belegt, richtig
static void report(int k) {
int found = 0, total = 0, stands = 0, right = 0;
System.out.println("k = " + k);
System.out.println("Thema gefunden belegt richtig");
for (Case c : CASES) {
List<Hit> hits = search(c.question(), k);
String text = answer(c.question(), k);
String why = unsupported(text, hits);
String f = "—";
if (!c.expect().isEmpty()) {
total++;
boolean in = false;
for (Hit h : hits)
if (h.piece().id().equals(c.expect()))
in = true;
if (in)
found++;
f = mark(in);
}
if (why.isEmpty())
stands++;
if (text.contains(c.has()))
right++;
String row = String.format("%-18s%-10s%-8s%s", c.topic(), f,
mark(why.isEmpty()), mark(text.contains(c.has())));
System.out.println(why.isEmpty() ? row : row + " " + why);
}
System.out.println("recall@" + k + " " + found + "/" + total + ", belegt " + stands + "/"
+ CASES.size() + ", richtig " + right + "/" + CASES.size());
}
report(3);
Ausgabe
k = 3
Thema gefunden belegt richtig
lieferpreis ✓ ✓ ✓
lieferzeit ✓ ✓ ✓
rückgabe ✗ ✗ ✗ Zahl 30 fehlt in der Quelle
geld zurück ✓ ✓ ✓
nachnahme ✓ ✓ ✓
karte abgelehnt ✓ ✓ ✓
garantie ✓ ✓ ✓
sonntag — ✓ ✓
recall@3 6/7, belegt 7/8, richtig 7/8

Der einzige Fehlschlag ist „rückgabe“: Der richtige Abschnitt wurde nicht gefunden, das Modell hat den Abschnitt über das Geld zitiert und 30 Tage geschrieben, obwohl dort fünf stehen. Die Prüfung fängt die Zahl.

Jetzt dasselbe mit k = 1, der Änderung des Kollegen:

report(1);
Ausgabe
k = 1
Thema gefunden belegt richtig
lieferpreis ✓ ✓ ✓
lieferzeit ✗ ✗ ✓ Quelle nicht im Prompt: lieferung-2
rückgabe ✗ ✗ ✗ Quelle nicht im Prompt: rückgabe-2
geld zurück ✗ ✓ ✗
nachnahme ✗ ✓ ✗
karte abgelehnt ✓ ✓ ✓
garantie ✓ ✓ ✓
sonntag — ✓ ✓
recall@1 3/7, belegt 6/8, richtig 5/8

Alle drei Zahlen fallen. Die Gründe sind verschieden: Bei „lieferzeit“ zitiert das Modell einen Abschnitt, den es nicht gesehen hat (und hat zufällig recht), bei „rückgabe“ ebenso, nur mit der falschen Zahl, bei „geld zurück“ stützt es sich auf den falschen Abschnitt, und bei „nachnahme“ sagt es ehrlich „Ich weiß es nicht.“, weil der Abschnitt nicht gefunden wurde. Die zwei von Hand probierten Fragen gehörten zu den drei, die alles bestehen.

Probier es aus

Ergänze

Unter welchen Abschnitten wird jede Quelle der Antwort gesucht? Was kommt anstelle von ___?

// stützt sich die Antwort auf die Abschnitte? "" oder Grund
static String unsupported(String answer, List<Hit> hits) {
    if (answer.toLowerCase().startsWith(DECLINE))
        return "";
    List<String> ids = sources(answer);
    if (ids.isEmpty())
        return "ohne Quelle";
    Set<String> words = new HashSet<>();
    for (String source : ids) {
        Hit hit = null;
        for (Hit h : )
            if (h.piece().id().equals(source))
                hit = h;
        if (hit == null)
            return "Quelle nicht im Prompt: " + source;
        String text = hit.piece().text();
        words.addAll(List.of(text.split(" ")));
    }
    for (String word : answer.split(" ")) {
        if (word.startsWith("["))
            continue;
        String n = word.replaceAll("[^0-9]", "");
        if (!n.isEmpty() && !words.contains(n))
            return "Zahl " + n + " fehlt in der Quelle";
    }
    return "";
}

Probier es aus

Finde den Fehler

Ein Kollege schreibt recall um. Jetzt ist recall@1 0/7 und recall@3 5/7. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Wann es wehtut

  • Das Set ist klein. Hier ist eine Frage 14 % von recall. Ein echtes Set hat Hunderte Fragen aus echten Gesprächen und wächst mit jedem Fehler, den ein Kunde findet.
  • Die Labels veralten. Der richtige Abschnitt ist über seine Quelle notiert. Werden die Dokumente neu zerlegt, ändern sich die Quellen, und das Set muss mit ihnen angepasst werden.
  • Belegt heißt nicht richtig. Die Antwort „14 Tage“ stützt sich auf den falschen Abschnitt. Darum gehören die drei Prüfungen zusammen: gefunden, belegt, richtig.
  • Die Prüfung ist grob. Sie schaut nur auf Ziffern und nur darauf, ob das Wort vorkommt. „fünf Tage“ in Worten wird nicht geprüft, und die „5“ aus „5 €“ besteht, auch wenn die Antwort 5 Tage sagt. Ein zweites Modell prüft genauer (siehe „Mehr“), macht aber auch Fehler.
  • Es kann mehr als einen richtigen Abschnitt geben. Die Antwort kann in zwei Abschnitten stehen. Dann hält das Set alle fest, und „gefunden“ heißt mindestens einer, oder alle, wenn beide gebraucht werden.
  • Das Set läuft bei jeder Änderung. Ein neues k, eine neue Schwelle, ein neues Vektormodell, neues Chunking, ein neuer Prompt: Jedes ändert die Zahlen. Darum ist das Set Code und läuft wie die Tests, mit einer Latte, unter der die Änderung nicht ausgeliefert wird, wie in der Lektion über die Evaluation.
Genauer, für Neugierige

Ein zweites Modell als Richter. Statt nach Ziffern zu suchen, kannst du ein zweites Modell fragen: „Folgt diese Antwort aus diesen Abschnitten? Ja oder nein.“ Es fängt mehr als die grobe Prüfung, kostet aber einen Aufruf mehr und muss selbst an einem Set mit bekannten Noten geprüft werden.

Andere Maße für die Suche. recall@k sagt nur, ob der Abschnitt drin ist. MRR (der Mittelwert von 1/Rang) sagt auch, wie weit oben er steht: Rang 1 ergibt 1, Rang 3 ergibt 0,33. precision@k sagt, wie viele der k Abschnitte zur Frage passen, also wie viele Tokens umsonst ausgegeben werden.

Das Set in CI. Wie das Set bei jeder Änderung automatisch läuft und eine schlechte Version stoppt, bevor sie die Kunden erreicht, ist Aufgabe von ZeroToOps.

HashSet hält die Wörter der zitierten Abschnitte, und contains prüft eine Zahl in konstanter Zeit. replaceAll("[^0-9]", "") behält nur die Ziffern des Wortes.

Übung

Wie groß soll k sein

Löse es in deinem Editor.

Schreibe eine Funktion, die das ganze Set für ein gegebenes k durchläuft und recall, belegt, richtig und die Zahl der Tokens ausgibt, die in die Prompts gegangen sind (die Tokens von Prompt und Frage, nur wenn das Modell aufgerufen wird). Führe sie für k von 1 bis 4 aus:

Ausgabe
k = 1: recall 3/7, belegt 6/8, richtig 5/8, 732 Tokens
k = 2: recall 5/7, belegt 6/8, richtig 7/8, 868 Tokens
k = 3: recall 6/7, belegt 7/8, richtig 7/8, 952 Tokens
k = 4: recall 7/7, belegt 8/8, richtig 8/8, 1024 Tokens

Welches k würdest du wählen, und warum? k = 4 behebt „rückgabe“ und kostet etwa 8 % mehr Tokens als k = 3. Denke auch daran, wie viel von diesen Zahlen nur an diesem kleinen Set liegt.

Weitere Aufgaben

  • Schwelle. Lass das Set mit der Schwelle 0,6 und 0,7 bei k = 3 laufen. Was passiert mit recall und mit „sonntag“?
  • MRR. Berechne den Mittelwert von 1/Rang für die sieben Fragen mit Abschnitt (Rang 0 ergibt 0).

Test

Teste dich

1. Der richtige Abschnitt für eine Frage ist der dritte nach Ähnlichkeit. Bei welchem k kommt er in den Prompt?
2. recall@3 ist 6/7. Was heißt das?
3. Die Antwort zitiert [lieferung-2], aber im Prompt steht nur „rückgabe-1“. Was sagt die Prüfung?
4. Die Antwort stützt sich auf den Abschnitt, der im Prompt ist. Heißt das, dass sie richtig ist?
5. Bei k = 4 sind alle drei Zahlen voll (7/7, 8/8, 8/8), und die Tokens liegen etwa 8 % über denen bei k = 3. Was folgt daraus?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·