- eval_retrieval.py: --judge MODEL lets an OpenAI model decide whether the top-5 chunks answer each question, regardless of source and page; reports Answer@5, answer-level MRR and agreement with the page check; verdicts are cached in eval/judge_cache.json - On the curated goldset, gpt-6-luna rates pypdf and Docling equal (Answer@5 0.93 each, MRR 0.90 vs 0.92), so the chat stays on pypdf for now - local_rag.py: chunks 2000/400, chunk headers off by default, OpenAI model gpt-6-luna; server.py loads ./vectorstore_pypdf_2000 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
372 lines
16 KiB
Python
372 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""Bewertet die Retrieval-Qualität gegen einen Satz Referenzfragen.
|
||
|
||
Das Skript stellt jede Frage aus eval/goldset.csv an den Vektorspeicher und prüft,
|
||
ob die erwartete Fundstelle unter den ersten k Treffern auftaucht. Ergebnis sind
|
||
Recall@k und MRR, ausgegeben auf dem Bildschirm und in eval/baseline.txt.
|
||
|
||
Optional (--judge MODELL) beurteilt zusätzlich ein Sprachmodell über die OpenAI-API,
|
||
ob die Antwort in den ersten JUDGE_K Treffern steht — gleich in welchem Buch und auf
|
||
welcher Seite. Das misst, was für den Chat zählt, und wertet gleichwertige Stellen
|
||
aus anderen Quellen nicht als Fehler. Braucht OPENAI_API_KEY; die Urteile werden in
|
||
eval/judge_cache.json zwischengespeichert, damit Wiederholungen nichts kosten.
|
||
|
||
Aus dem Projektordner starten: python eval_retrieval.py
|
||
|
||
Fragen, Index und Bericht lassen sich auf der Kommandozeile überschreiben, z. B.
|
||
python eval_retrieval.py --goldset eval/goldset_kafka.csv --index ./vectorstore_docling \
|
||
--report eval/kafka_docling.txt
|
||
"""
|
||
import argparse
|
||
import csv
|
||
import sys
|
||
import unicodedata
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from local_rag import (
|
||
CHUNK_OVERLAP,
|
||
CHUNK_SIZE,
|
||
QUERY_ADDITIONAL_INSTRUCTIONS,
|
||
LocalRAG,
|
||
)
|
||
|
||
# --- Einstellungen -----------------------------------------------------------
|
||
|
||
# Datei mit den Referenzfragen (Spalten siehe eval/goldset.csv).
|
||
GOLDSET_PATH = Path("eval/goldset.csv")
|
||
|
||
# Wohin der Bericht geschrieben wird. Diese Datei gehört ins Repository.
|
||
REPORT_PATH = Path("eval/baseline.txt")
|
||
|
||
# Muss mit server.py übereinstimmen, sonst wird ein anderer Index gemessen.
|
||
VECTORSTORE_PATH = "./vectorstore_pypdf_2000"
|
||
|
||
# Für diese k-Werte wird Recall@k berechnet.
|
||
K_VALUES = [5, 10, 20]
|
||
|
||
# Den Zusatztext aus local_rag.QUERY_ADDITIONAL_INSTRUCTIONS an jede Frage anhängen?
|
||
# Einmal mit True und einmal mit False laufen lassen zeigt, ob er tatsächlich hilft.
|
||
USE_QUERY_INSTRUCTIONS = False
|
||
|
||
# PyPDFLoader zählt Seiten ab 0, Menschen ab 1. Bei 1 bedeutet Seite 42 in der
|
||
# CSV also die Seite, die im PDF-Betrachter als 42 angezeigt wird.
|
||
PAGE_OFFSET = 1
|
||
|
||
# Ein Chunk kann über einen Seitenumbruch reichen: so viele Seiten Abweichung
|
||
# gelten noch als Treffer. 0 verlangt die exakte Seite.
|
||
PAGE_TOLERANCE = 1
|
||
|
||
# Wie viele Treffer das Sprachmodell bei --judge zu sehen bekommt (wie RETRIEVAL_K im Chat).
|
||
JUDGE_K = 5
|
||
JUDGE_CACHE = Path("eval/judge_cache.json")
|
||
|
||
JUDGE_PROMPT = """Du prüfst eine Literatursuche zu Franz Kafka. Unten stehen eine Frage und \
|
||
{n} nummerierte Textauszüge aus Primär- und Sekundärliteratur (teils mit OCR-Fehlern).
|
||
|
||
Entscheide für jeden Auszug, ob er die Frage beantwortet: ganz oder in ihrem wesentlichen \
|
||
Kern, so dass man allein mit diesem Auszug eine zutreffende Antwort geben könnte. Ein Auszug, \
|
||
der nur das Thema berührt, zählt nicht. Nutze kein eigenes Wissen über Kafka, nur die Auszüge.
|
||
|
||
Antworte ausschließlich mit JSON in dieser Form:
|
||
{{"auszuege": [Nummern der Auszüge, die die Frage beantworten], "begruendung": "ein Satz"}}
|
||
|
||
Frage: {frage}
|
||
|
||
{auszuege}"""
|
||
|
||
RETRIEVAL_SETUP = "" # wird in main() gesetzt und im Bericht vermerkt
|
||
INDEX_INFO = {} # Einstellungen, mit denen der Index gebaut wurde (index_info.json)
|
||
JUDGE_MODEL = None # wird in main() aus --judge gesetzt
|
||
|
||
# -----------------------------------------------------------------------------
|
||
|
||
|
||
def read_goldset(path):
|
||
"""Liest die Referenzfragen. Erwartet die Spalten frage, korpus,
|
||
erwartete_quelle, erwartete_seite, anmerkung (die letzten beiden optional)."""
|
||
if not path.exists():
|
||
sys.exit(
|
||
f"Keine Referenzfragen gefunden: {path}\n"
|
||
"Lege die Datei an (Vorlage liegt im Repository) und trage echte Fragen ein."
|
||
)
|
||
|
||
rows = []
|
||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||
# Deutsches Excel speichert CSV oft mit Semikolon statt Komma.
|
||
first_line = handle.readline()
|
||
delimiter = ";" if first_line.count(";") > first_line.count(",") else ","
|
||
handle.seek(0)
|
||
|
||
for line_no, row in enumerate(csv.DictReader(handle, delimiter=delimiter), start=2):
|
||
question = (row.get("frage") or "").strip()
|
||
source = (row.get("erwartete_quelle") or "").strip()
|
||
if not question or not source:
|
||
print(f" Zeile {line_no} übersprungen: frage oder erwartete_quelle fehlt")
|
||
continue
|
||
|
||
page_raw = (row.get("erwartete_seite") or "").strip()
|
||
page = None
|
||
if page_raw:
|
||
try:
|
||
page = int(page_raw)
|
||
except ValueError:
|
||
print(f" Zeile {line_no}: '{page_raw}' ist keine Seitenzahl, wird ignoriert")
|
||
|
||
rows.append({
|
||
"question": question,
|
||
"corpus": (row.get("korpus") or "ohne").strip() or "ohne",
|
||
"source": source,
|
||
"page": page,
|
||
})
|
||
|
||
if not rows:
|
||
sys.exit(f"{path} enthält keine verwertbaren Zeilen.")
|
||
return rows
|
||
|
||
|
||
def same_file(found, expected):
|
||
"""Gleiche Datei? Groß-/Kleinschreibung, Unicode-Form (macOS speichert Umlaute zerlegt)
|
||
und Unterstriche, Punkte oder Leerzeichen am Ende des Namens spielen keine Rolle."""
|
||
def norm(name):
|
||
stem = unicodedata.normalize("NFC", Path(name).stem.lower())
|
||
return stem.rstrip("_. ")
|
||
return norm(found) == norm(expected)
|
||
|
||
|
||
def is_hit(doc, expected_source, expected_page):
|
||
"""Passt dieser Treffer zur erwarteten Fundstelle? Dateiname ohne Pfad,
|
||
Groß-/Kleinschreibung egal; Seite nur, wenn eine angegeben wurde."""
|
||
if not same_file(doc.metadata.get("source", ""), expected_source):
|
||
return False
|
||
|
||
if expected_page is None:
|
||
return True
|
||
|
||
found_page = doc.metadata.get("page")
|
||
if found_page is None:
|
||
return False
|
||
return abs(int(found_page) - (expected_page - PAGE_OFFSET)) <= PAGE_TOLERANCE
|
||
|
||
|
||
def evaluate(rag, goldset, max_k):
|
||
"""Stellt jede Frage und merkt sich den Rang des ersten korrekten Treffers."""
|
||
results = []
|
||
for i, item in enumerate(goldset, start=1):
|
||
query = item["question"]
|
||
if USE_QUERY_INSTRUCTIONS:
|
||
query = f"{query}\n\n{QUERY_ADDITIONAL_INSTRUCTIONS}"
|
||
|
||
# Dieselbe Suche wie im Chat: Vektorsuche, ggf. Stichwortsuche und Reranking
|
||
docs = [doc for doc, _ in rag.retrieve(query, k=max_k)]
|
||
|
||
rank = None
|
||
for position, doc in enumerate(docs, start=1):
|
||
if is_hit(doc, item["source"], item["page"]):
|
||
rank = position
|
||
break
|
||
|
||
results.append({**item, "rank": rank, "docs": docs})
|
||
marker = f"Rang {rank}" if rank else "nicht gefunden"
|
||
print(f" [{i}/{len(goldset)}] {marker:<15} {item['question'][:60]}")
|
||
|
||
return results
|
||
|
||
|
||
def judge(results, model):
|
||
"""Lässt ein Sprachmodell prüfen, welche der ersten JUDGE_K Treffer die Frage
|
||
beantworten. Ergänzt je Frage judge_rank (erster passender Treffer) und judge_note."""
|
||
import hashlib
|
||
import json
|
||
import re
|
||
from langchain_openai import ChatOpenAI
|
||
|
||
cache = json.loads(JUDGE_CACHE.read_text(encoding="utf-8")) if JUDGE_CACHE.exists() else {}
|
||
llm = ChatOpenAI(model=model)
|
||
for i, r in enumerate(results, start=1):
|
||
docs = r["docs"][:JUDGE_K]
|
||
excerpts = "\n\n".join(
|
||
f"[{n}] ({Path(d.metadata.get('source', '')).name}, Seite {d.metadata.get('page')})\n"
|
||
f"{' '.join(d.page_content.split())}" for n, d in enumerate(docs, start=1))
|
||
prompt = JUDGE_PROMPT.format(n=len(docs), frage=r["question"], auszuege=excerpts)
|
||
key = hashlib.sha256(f"{model}\n{prompt}".encode()).hexdigest()
|
||
if key not in cache:
|
||
reply = llm.invoke(prompt)
|
||
text = reply.content if hasattr(reply, "content") else str(reply)
|
||
if isinstance(text, list): # manche Modelle liefern Inhaltsblöcke
|
||
text = " ".join(part.get("text", "") if isinstance(part, dict) else str(part) for part in text)
|
||
match = re.search(r"\{.*\}", text, re.S)
|
||
try:
|
||
verdict = json.loads(match.group(0)) if match else {}
|
||
except json.JSONDecodeError:
|
||
verdict = {}
|
||
cache[key] = {"auszuege": sorted(int(n) for n in verdict.get("auszuege", []) if str(n).isdigit()),
|
||
"begruendung": verdict.get("begruendung", text[:200])}
|
||
JUDGE_CACHE.write_text(json.dumps(cache, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
hits = [n for n in cache[key]["auszuege"] if 1 <= n <= len(docs)]
|
||
r["judge_rank"] = hits[0] if hits else None
|
||
r["judge_note"] = cache[key]["begruendung"]
|
||
marker = f"Treffer {r['judge_rank']}" if hits else "keine Antwort"
|
||
print(f" [{i}/{len(results)}] {marker:<15} {r['question'][:60]}")
|
||
|
||
|
||
def judge_report(results, model):
|
||
"""Berichtsteil für die Prüfung durch das Sprachmodell."""
|
||
n = len(results)
|
||
found = [r for r in results if r.get("judge_rank")]
|
||
page_hits = {id(r) for r in results if r["rank"] is not None and r["rank"] <= JUDGE_K}
|
||
both = sum(1 for r in found if id(r) in page_hits)
|
||
out = [
|
||
"",
|
||
f"Antwort im Kontext (Prüfung durch {model}, erste {JUDGE_K} Treffer)",
|
||
f" Antwort@{JUDGE_K:<11} {len(found) / n:.2f} ({len(found)} von {n})",
|
||
f" MRR (Antwort){'':<6} {sum(1 / r['judge_rank'] for r in found) / n:.2f}",
|
||
f" Abgleich mit Seitenprüfung: beide ja {both}, nur Modell ja {len(found) - both}, "
|
||
f"nur Seite ja {len(page_hits) - both}",
|
||
"",
|
||
" Einzelne Fragen (Treffer = erster Auszug, der die Frage beantwortet; Seite = Rang laut Fundstelle)",
|
||
]
|
||
for r in sorted(results, key=lambda r: (r.get("judge_rank") is None, r.get("judge_rank") or 0)):
|
||
jr = r.get("judge_rank")
|
||
pr = r["rank"] if r["rank"] is not None and r["rank"] <= JUDGE_K else None
|
||
out.append(f" Treffer {jr if jr else '–':>2} | Seite {pr if pr else '–':>2} | {r['question']}")
|
||
out.append(f" {r.get('judge_note', '')}")
|
||
return out
|
||
|
||
|
||
def recall_at(results, k):
|
||
"""Anteil der Fragen, deren Fundstelle unter den ersten k Treffern liegt."""
|
||
if not results:
|
||
return 0.0
|
||
found = sum(1 for r in results if r["rank"] is not None and r["rank"] <= k)
|
||
return found / len(results)
|
||
|
||
|
||
def mrr(results):
|
||
"""Mean Reciprocal Rank: belohnt Treffer weit oben. 1.0 = immer Platz 1."""
|
||
if not results:
|
||
return 0.0
|
||
return sum(1 / r["rank"] for r in results if r["rank"] is not None) / len(results)
|
||
|
||
|
||
def build_report(results, embedding_model):
|
||
"""Baut den Berichtstext, der auf dem Bildschirm und in der Datei landet."""
|
||
line = "=" * 68
|
||
out = [
|
||
line,
|
||
f"Retrieval-Auswertung — {datetime.now():%Y-%m-%d %H:%M}",
|
||
line,
|
||
"",
|
||
"Konfiguration",
|
||
f" Vektorspeicher {VECTORSTORE_PATH}",
|
||
f" Embedding-Modell {embedding_model}",
|
||
f" Chunk-Größe {INDEX_INFO.get('chunk_size', CHUNK_SIZE)} (Overlap {INDEX_INFO.get('chunk_overlap', CHUNK_OVERLAP)})",
|
||
f" Loader {INDEX_INFO.get('pdf_loader', '?')}, Kopfzeilen {'an' if INDEX_INFO.get('chunk_headers') else 'aus'}",
|
||
f" Query-Zusatztext {'an' if USE_QUERY_INSTRUCTIONS else 'aus'}",
|
||
f" Suche {RETRIEVAL_SETUP}",
|
||
f" Seitentoleranz ±{PAGE_TOLERANCE}",
|
||
f" Fragen {len(results)}",
|
||
"",
|
||
"Ergebnis",
|
||
]
|
||
|
||
for k in K_VALUES:
|
||
value = recall_at(results, k)
|
||
found = sum(1 for r in results if r["rank"] is not None and r["rank"] <= k)
|
||
out.append(f" Recall@{k:<12} {value:.2f} ({found} von {len(results)})")
|
||
out.append(f" MRR{'':<16} {mrr(results):.2f}")
|
||
|
||
corpora = sorted({r["corpus"] for r in results})
|
||
if len(corpora) > 1:
|
||
out += ["", "Nach Korpus"]
|
||
smallest_k = min(K_VALUES)
|
||
for corpus in corpora:
|
||
subset = [r for r in results if r["corpus"] == corpus]
|
||
wort = "Frage" if len(subset) == 1 else "Fragen"
|
||
out.append(
|
||
f" {corpus:<12} Recall@{smallest_k} {recall_at(subset, smallest_k):.2f}"
|
||
f" MRR {mrr(subset):.2f} ({len(subset)} {wort})"
|
||
)
|
||
|
||
out += ["", "Einzelne Fragen (Rang = Position des ersten korrekten Treffers)"]
|
||
for r in sorted(results, key=lambda r: (r["rank"] is None, r["rank"] or 0)):
|
||
rank = f"{r['rank']:>3}" if r["rank"] is not None else " –"
|
||
out.append(f" Rang {rank} {r['corpus']:<10} {r['question']}")
|
||
|
||
nowhere = [r for r in results if r["rank"] is None]
|
||
if nowhere:
|
||
out += [
|
||
"",
|
||
f"{len(nowhere)} Frage(n) ohne Treffer unter den ersten {max(K_VALUES)}.",
|
||
"Prüfen: Steht die Fundstelle wirklich dort? Stimmt der Dateiname?",
|
||
"Ist das Dokument überhaupt im Index (rag.list_documents())?",
|
||
]
|
||
|
||
if any("judge_rank" in r for r in results):
|
||
out += judge_report(results, JUDGE_MODEL)
|
||
|
||
out.append("")
|
||
return "\n".join(out)
|
||
|
||
|
||
def main():
|
||
global GOLDSET_PATH, VECTORSTORE_PATH, REPORT_PATH, USE_QUERY_INSTRUCTIONS
|
||
parser = argparse.ArgumentParser(description="Retrieval-Auswertung")
|
||
parser.add_argument("--goldset", type=Path, default=GOLDSET_PATH)
|
||
parser.add_argument("--index", default=VECTORSTORE_PATH)
|
||
parser.add_argument("--report", type=Path, default=REPORT_PATH)
|
||
parser.add_argument("--mode", choices=["dense", "hybrid", "union"], default=None,
|
||
help="Suchmodus, Standard aus local_rag.RETRIEVAL_MODE")
|
||
parser.add_argument("--no-rerank", action="store_true", help="Cross-Encoder abschalten")
|
||
parser.add_argument("--judge", metavar="MODELL", default=None,
|
||
help="zusätzlich prüfen lassen, ob die Antwort in den ersten Treffern steht")
|
||
parser.add_argument("--query-instructions", action="store_true", default=USE_QUERY_INSTRUCTIONS,
|
||
help="Zusatztext aus local_rag.QUERY_ADDITIONAL_INSTRUCTIONS anhängen")
|
||
args = parser.parse_args()
|
||
GOLDSET_PATH, VECTORSTORE_PATH, REPORT_PATH = args.goldset, args.index, args.report
|
||
USE_QUERY_INSTRUCTIONS = args.query_instructions
|
||
|
||
print(f"Lese Referenzfragen aus {GOLDSET_PATH} ...")
|
||
goldset = read_goldset(GOLDSET_PATH)
|
||
print(f"{len(goldset)} Frage(n) geladen.\n")
|
||
|
||
# Lädt Embeddings und Vektorspeicher genau wie die Chat-Anwendung, damit
|
||
# gemessen wird, was dort auch tatsächlich passiert. Ein LLM wird nur mit
|
||
# --judge aufgerufen, zur Beurteilung der Treffer, nicht zum Antworten.
|
||
options = {}
|
||
if args.mode:
|
||
options["retrieval_mode"] = args.mode
|
||
if args.no_rerank:
|
||
options["reranker_model"] = None
|
||
rag = LocalRAG(vectorstore_path=VECTORSTORE_PATH, **options)
|
||
if rag.vectorstore is None:
|
||
sys.exit(
|
||
f"\nKein Vektorspeicher unter {VECTORSTORE_PATH} gefunden.\n"
|
||
"Erst den Index bauen (python add_pdfs.py), dann erneut auswerten."
|
||
)
|
||
|
||
print(f"\nStelle {len(goldset)} Frage(n) an den Vektorspeicher ...")
|
||
global RETRIEVAL_SETUP, INDEX_INFO
|
||
info_file = Path(VECTORSTORE_PATH) / "index_info.json"
|
||
if info_file.exists():
|
||
import json
|
||
INDEX_INFO = json.loads(info_file.read_text(encoding="utf-8"))
|
||
RETRIEVAL_SETUP = f"{rag.retrieval_mode}, Reranker {rag.reranker_model or 'aus'}"
|
||
results = evaluate(rag, goldset, max_k=max(K_VALUES))
|
||
if args.judge:
|
||
global JUDGE_MODEL
|
||
JUDGE_MODEL = args.judge
|
||
print(f"\nLasse {args.judge} prüfen, ob die Antwort in den ersten {JUDGE_K} Treffern steht ...")
|
||
judge(results, args.judge)
|
||
|
||
report = build_report(results, rag.embeddings.model_name)
|
||
print("\n" + report)
|
||
|
||
REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
REPORT_PATH.write_text(report, encoding="utf-8")
|
||
print(f"Bericht gespeichert: {REPORT_PATH}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|