Add LLM judge to retrieval eval; switch chat to pypdf with 2000/400 chunks

- 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>
This commit is contained in:
2026-10-08 09:07:51 +02:00
co-authored by Claude Opus 5.5
parent 9ebcda1f55
commit e75716714e
7 changed files with 804 additions and 16 deletions
+102 -6
View File
@@ -1,10 +1,16 @@
#!/usr/bin/env python3
"""Bewertet die Retrieval-Qualität gegen einen Satz Referenzfragen. Kein LLM beteiligt.
"""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.
@@ -34,7 +40,7 @@ GOLDSET_PATH = Path("eval/goldset.csv")
REPORT_PATH = Path("eval/baseline.txt")
# Muss mit server.py übereinstimmen, sonst wird ein anderer Index gemessen.
VECTORSTORE_PATH = "./vectorstore_docling_hdr"
VECTORSTORE_PATH = "./vectorstore_pypdf_2000"
# Für diese k-Werte wird Recall@k berechnet.
K_VALUES = [5, 10, 20]
@@ -51,8 +57,27 @@ PAGE_OFFSET = 1
# 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
# -----------------------------------------------------------------------------
@@ -141,13 +166,74 @@ def evaluate(rag, goldset, max_k):
rank = position
break
results.append({**item, "rank": rank})
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:
@@ -216,19 +302,24 @@ def build_report(results, embedding_model):
"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 ohne LLM")
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()
@@ -240,8 +331,8 @@ def main():
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. Es wird kein LLM
# aufgerufen — nur die Ähnlichkeitssuche im Vektorspeicher.
# 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
@@ -262,6 +353,11 @@ def main():
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)