M1+M2: RAG-Pipeline mit verbindlichem Grounding
agent/-Paket: Ingest (601 Layer-2-Eintraege -> 3005 Chunks, FTS5-BM25 + Vektoren-Cache), Hybrid-Retrieval (RRF, Stand-Boost, cross_ref-Erweiterung), Ollama-Client (embed/chat, think-Flag-Fallback, kurzes Connect-Budget), Systemprompt mit Zitierpflicht, Post-Validierung (zitierte IDs gemaess Retrieved-Set, 1x Regenerierung, dann Verweigerung), FastAPI (/ask, /health, /reindex), CLI, Goldset (31 Fragen, IDs gegen kb.json verifiziert, inkl. ATZ-Konfliktfall + 4 Verweigerungsfaelle), Eval-Suite, Test-Chat. 41 Offline-Tests gruen. Baseline BM25-only: Hit-Rate 0,871 / Recall@8 0,855 / MRR 0,476. Hybrid-Messung, Antwortmodus-Eval und Modell-Bake-off (M3) auf dem Host ausstaendig (Ollama aus der Zed-Sandbox nicht erreichbar). MEMORY.md und planung.md Umsetzungsstand aktualisiert.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Eval-Paket: Goldset + Metriken für Retrieval und belegte Antworten."""
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Goldset-Evaluation.
|
||||
|
||||
Modus 1 (offline, ohne Ollama): Retrieval-Metriken — Recall@k, Hit-Rate, MRR.
|
||||
Modus 2 (--answers, benötigt Ollama): Zitier-Präzision (validated),
|
||||
Verweigerungskorrektheit, erwartete Quelle zitiert, Latenz.
|
||||
|
||||
Kriterien laut Skill: Recall@8 > 0,9; Zitier-Präzision 100 %;
|
||||
Verweigerungen korrekt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ..config import Config
|
||||
from ..retrieve import Retriever
|
||||
|
||||
GOLDSET_PATH = Path(__file__).parent / "goldset.yaml"
|
||||
|
||||
|
||||
def load_goldset(path: Path = GOLDSET_PATH) -> list[dict]:
|
||||
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
||||
questions = data.get("questions", [])
|
||||
if not questions:
|
||||
raise SystemExit(f"goldset leer: {path}")
|
||||
return questions
|
||||
|
||||
|
||||
def evaluate_retrieval(cfg: Config, questions: list[dict], k: int = 8) -> list[dict]:
|
||||
retriever = Retriever(cfg)
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
for q in questions:
|
||||
if q.get("expect_refusal"):
|
||||
continue # Verweigerungsfälle werden nur im Antwortmodus gemessen
|
||||
expected = set(q.get("expected_ids", []))
|
||||
if not expected:
|
||||
continue
|
||||
results = retriever.search(q["question"], n_entries=k)
|
||||
retrieved = [r.entry_id for r in results]
|
||||
hits = expected & set(retrieved)
|
||||
rank = next(
|
||||
(retrieved.index(e) + 1 for e in expected if e in retrieved), None
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"id": q["id"],
|
||||
"question": q["question"],
|
||||
"expected": sorted(expected),
|
||||
"retrieved": retrieved,
|
||||
"recall": len(hits) / len(expected),
|
||||
"hit": bool(hits),
|
||||
"mrr": (1.0 / rank) if rank else 0.0,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
retriever.close()
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_retrieval(rows: list[dict]) -> dict:
|
||||
if not rows:
|
||||
return {"n": 0}
|
||||
return {
|
||||
"n": len(rows),
|
||||
"hit_rate": round(sum(r["hit"] for r in rows) / len(rows), 4),
|
||||
"mean_recall_at_k": round(statistics.mean(r["recall"] for r in rows), 4),
|
||||
"mrr": round(statistics.mean(r["mrr"] for r in rows), 4),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_answers(cfg: Config, questions: list[dict]) -> list[dict]:
|
||||
from ..generate import answer_question # Ollama nötig — hier erst importieren
|
||||
rows: list[dict] = []
|
||||
for q in questions:
|
||||
result = answer_question(q["question"], cfg)
|
||||
expected = set(q.get("expected_ids", []))
|
||||
should_refuse = bool(q.get("expect_refusal"))
|
||||
rows.append(
|
||||
{
|
||||
"id": q["id"],
|
||||
"question": q["question"],
|
||||
"refused": result["refused"],
|
||||
"should_refuse": should_refuse,
|
||||
"refusal_correct": result["refused"] == should_refuse,
|
||||
"verified": result["verified"],
|
||||
"citations": result["citations"],
|
||||
"expected_cited": (
|
||||
any(c in expected for c in result["citations"]) if expected else None
|
||||
),
|
||||
"regenerations": result["regenerations"],
|
||||
"latency_ms": result["latency_ms"],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_answers(rows: list[dict]) -> dict:
|
||||
if not rows:
|
||||
return {"n": 0}
|
||||
lat = [r["latency_ms"] for r in rows]
|
||||
return {
|
||||
"n": len(rows),
|
||||
"citation_precision": round(
|
||||
sum(r["verified"] for r in rows) / len(rows), 4
|
||||
),
|
||||
"refusal_correct_rate": round(
|
||||
sum(r["refusal_correct"] for r in rows) / len(rows), 4
|
||||
),
|
||||
"expected_cited_rate": round(
|
||||
sum(bool(r["expected_cited"]) for r in rows)
|
||||
/ max(1, sum(1 for r in rows if r["expected_cited"] is not None)),
|
||||
4,
|
||||
),
|
||||
"latency_ms_mean": round(statistics.mean(lat)),
|
||||
"latency_ms_p95": round(sorted(lat)[int(len(lat) * 0.95) - 1]),
|
||||
"regenerations_total": sum(r["regenerations"] for r in rows),
|
||||
}
|
||||
|
||||
|
||||
def run_eval(cfg: Config, args) -> int:
|
||||
questions = load_goldset()
|
||||
if args.limit:
|
||||
questions = questions[: args.limit]
|
||||
mode = "retrieval + Antworten" if args.answers else "nur Retrieval (offline)"
|
||||
print(f"Goldset: {len(questions)} Fragen · Modus: {mode} · k={args.k}")
|
||||
print(f"Antwortmodell: {cfg.answer_model} · Embedding: {cfg.embed_model}")
|
||||
print("-" * 78)
|
||||
|
||||
report: dict = {"mode": mode, "k": args.k, "model": cfg.answer_model}
|
||||
|
||||
rows = evaluate_retrieval(cfg, questions, k=args.k)
|
||||
for r in rows:
|
||||
mark = "✓" if r["hit"] else "✗"
|
||||
print(
|
||||
f"{mark} {r['id']:8s} recall={r['recall']:.2f} "
|
||||
f"mrr={r['mrr']:.2f} {r['question'][:56]}"
|
||||
)
|
||||
if not r["hit"]:
|
||||
print(f" erwartet: {', '.join(r['expected'])}")
|
||||
print(f" erhalten: {', '.join(r['retrieved'][:args.k])}")
|
||||
summary = summarize_retrieval(rows)
|
||||
report["retrieval"] = {"summary": summary, "rows": rows}
|
||||
print("-" * 78)
|
||||
print(f"Retrieval: n={summary['n']} Hit-Rate={summary['hit_rate']} "
|
||||
f"Recall@{args.k}={summary['mean_recall_at_k']} MRR={summary['mrr']}")
|
||||
|
||||
if args.answers:
|
||||
arows = evaluate_answers(cfg, questions)
|
||||
for r in arows:
|
||||
mark = "✓" if (r["verified"] and r["refusal_correct"]) else "✗"
|
||||
print(
|
||||
f"{mark} {r['id']:8s} refused={r['refused']} verified={r['verified']} "
|
||||
f"zit={','.join(r['citations'][:4])} {r['latency_ms']}ms"
|
||||
)
|
||||
asummary = summarize_answers(arows)
|
||||
report["answers"] = {"summary": asummary, "rows": arows}
|
||||
print("-" * 78)
|
||||
print(
|
||||
f"Antworten: n={asummary['n']} Zitier-Präzision={asummary['citation_precision']} "
|
||||
f"Verweigerung korrekt={asummary['refusal_correct_rate']} "
|
||||
f"erwartete Quelle zitiert={asummary['expected_cited_rate']}"
|
||||
)
|
||||
print(
|
||||
f"Latenz: mean={asummary['latency_ms_mean']}ms p95={asummary['latency_ms_p95']}ms "
|
||||
f"Regenerierungen={asummary['regenerations_total']}"
|
||||
)
|
||||
|
||||
if args.json_out:
|
||||
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.json_out).write_text(
|
||||
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
print(f"Report geschrieben: {args.json_out}")
|
||||
return 0
|
||||
@@ -0,0 +1,116 @@
|
||||
# Goldset für die Evaluation des PV RAG Agent.
|
||||
# expected_ids: verifiziert gegen wissensbasis/kb.json (Titel-Suche).
|
||||
# expect_refusal: Frage darf/darf nicht aus der Wissensbasis beantwortet
|
||||
# werden — Antwortmodus (eval --answers) muss verweigern.
|
||||
# q-002 ist der bekannte Korpuskonflikt (ATZ-Ersatzquote 28,5 vs. 27,5 %).
|
||||
|
||||
questions:
|
||||
- id: q-001
|
||||
question: "Was ist Altersteilzeit und wie funktioniert der Lohnausgleich?"
|
||||
expected_ids: [lb-atz-07, lb-atz-04]
|
||||
- id: q-002
|
||||
question: "Wie hoch ist die AMS-Ersatzquote bei geblockter Altersteilzeit?"
|
||||
expected_ids: [lb-atz-07, lb-atz-09, lb-atz-12]
|
||||
note: "Korpuskonflikt — beide Werte mit ⚠ nennen."
|
||||
- id: q-003
|
||||
question: "Für welche Arbeitnehmer ist die Altersteilzeit förderbar?"
|
||||
expected_ids: [lb-atz-03]
|
||||
- id: q-004
|
||||
question: "Wie viele Wochen gesetzlicher Urlaub stehen einem Arbeitnehmer zu?"
|
||||
expected_ids: [lb-url-05]
|
||||
- id: q-005
|
||||
question: "Wie wird das Urlaubsentgelt beim Wechsel zwischen Teilzeit und Vollzeit berechnet?"
|
||||
expected_ids: [lb-url-13]
|
||||
- id: q-006
|
||||
question: "Wie lange dauert die Entgeltfortzahlung im Krankenstand?"
|
||||
expected_ids: [lb-krs-08]
|
||||
- id: q-007
|
||||
question: "Welche Fristen gelten für die Auflösung in der Probezeit?"
|
||||
expected_ids: [lb-bnd-37]
|
||||
- id: q-008
|
||||
question: "Welche Verfügungsmöglichkeiten gibt es bei der Abfertigung neu?"
|
||||
expected_ids: [lb-end-03]
|
||||
- id: q-009
|
||||
question: "Welche Bezugsbestandteile sind beitragsfrei nach § 49 Abs. 3 ASVG?"
|
||||
expected_ids: [lb-sva-03, lb-sva-04]
|
||||
- id: q-010
|
||||
question: "Wie hoch sind die Sozialversicherungs-Beitragssätze für Dienstnehmer?"
|
||||
expected_ids: [lb-sva-06]
|
||||
- id: q-011
|
||||
question: "Was gilt als Nachtschwerarbeit und welche Folgen hat das?"
|
||||
expected_ids: [lb-nsc-01]
|
||||
- id: q-012
|
||||
question: "Wie sind Mitarbeiterrabatte abgabenrechtlich zu behandeln?"
|
||||
expected_ids: [lb-sac-02]
|
||||
- id: q-013
|
||||
question: "Wie wird die Privatnutzung eines Dienstwagens besteuert?"
|
||||
expected_ids: [lb-sac-03]
|
||||
- id: q-014
|
||||
question: "Unter welchen Voraussetzungen gibt es Pendlerförderung?"
|
||||
expected_ids: [lb-pen-01]
|
||||
- id: q-015
|
||||
question: "Wie werden Tagesgelder bei Dienstreisen abgerechnet?"
|
||||
expected_ids: [lb-rei-09]
|
||||
- id: q-016
|
||||
question: "Wie läuft eine GPLB ab?"
|
||||
expected_ids: [lb-gpl-01]
|
||||
- id: q-017
|
||||
question: "Was ist bei der Entsendung von Arbeitnehmern ins Ausland zu beachten?"
|
||||
expected_ids: [lb-grz-06, lb-grz-07]
|
||||
- id: q-018
|
||||
question: "Wie funktioniert die betriebliche Vorsorgekasse mit Beitragszahlung?"
|
||||
expected_ids: [lb-vor-02, lb-vor-03]
|
||||
- id: q-019
|
||||
question: "Welche Pflichten gelten bei der Einstellung von Lehrlingen?"
|
||||
expected_ids: [lb-leh-03]
|
||||
- id: q-020
|
||||
question: "Welche Beschäftigungsverbote gelten für Schwangere?"
|
||||
expected_ids: [lb-sch-04]
|
||||
- id: q-021
|
||||
question: "Wann beginnt die Elternkarenz und wie lange kann sie dauern?"
|
||||
expected_ids: [lb-kar-01]
|
||||
- id: q-022
|
||||
question: "Wie ist eine Überstundenpauschale zu behandeln?"
|
||||
expected_ids: [lb-ues-02]
|
||||
- id: q-023
|
||||
question: "Was muss ein Dienstzeugnis enthalten?"
|
||||
expected_ids: [lb-bso-04]
|
||||
- id: q-024
|
||||
question: "Was ist bei Kurzarbeit arbeitsrechtlich zu beachten?"
|
||||
expected_ids: [lb-azm-04, lb-azm-05, lb-azm-06]
|
||||
- id: q-025
|
||||
question: "Was passiert mit den Ansprüchen der Arbeitnehmer beim Betriebsübergang?"
|
||||
expected_ids: [lb-ins-01]
|
||||
- id: q-026
|
||||
question: "Wie sind Nachzahlungen abzurechnen?"
|
||||
expected_ids: [lb-naz-01]
|
||||
- id: q-027
|
||||
question: "Was ist beim Einlangen einer Lohnpfändung zu beachten?"
|
||||
expected_ids: [lb-pfa-01, lb-pfa-02]
|
||||
- id: q-028
|
||||
question: "Wird die Sonderzahlung bei Eintritt oder Austritt aliquotiert?"
|
||||
expected_ids: [lb-son-01]
|
||||
- id: q-029
|
||||
question: "Welche Neuerungen behandelt WIKU Personal aktuell 2026?"
|
||||
expected_ids: [wk-akt-01]
|
||||
- id: q-030
|
||||
question: "Wer zahlt das Wochengeld und wie wird es berechnet?"
|
||||
expected_ids: [lb-msf-03]
|
||||
- id: q-031
|
||||
question: "Was regelt das Mindestlohngesetz und für wen gilt es?"
|
||||
expected_ids: [lb-ent-09]
|
||||
|
||||
# --- Verweigerungsfälle (Antwortmodus) ---
|
||||
- id: r-001
|
||||
question: "Wie hoch ist der aktuelle EUR-USD-Wechselkurs?"
|
||||
expect_refusal: true
|
||||
- id: r-002
|
||||
question: "Wer war Bundeskanzler Österreichs im Jahr 2000?"
|
||||
expect_refusal: true
|
||||
- id: r-003
|
||||
question: "Bis wann muss die Umsatzsteuervoranmeldung abgegeben werden?"
|
||||
expect_refusal: true
|
||||
note: "Schwerer Fall: 13 Chunks erwähnen 'Umsatzsteuer' beiläufig — Retrieval nicht leer, aber inhaltlich nicht gedeckt."
|
||||
- id: r-004
|
||||
question: "Wie bereite ich einen Pitch für Investoren vor?"
|
||||
expect_refusal: true
|
||||
Reference in New Issue
Block a user