Retrieval-Kalibrierung nach Korpusverdopplung (D10) + Goldset-Erweiterung

- Hybrid-Fusion um dense_weight erweitert; kalibriert per Goldset-Sweep:
  dense_weight=2.0 (BM25 durch KV-S-Titel-Matches inflationiert),
  rrf_k=20, candidate_pool=150 -> Recall@8 0,851 -> 0,923 (>0,9),
  Hit-Rate 0,973, MRR 0,667. ENV: PV_DENSE_WEIGHT/PV_RRF_K/
  PV_CANDIDATE_POOL.
- CITE_RE um kv|ris erweitert (Post-Validierung deckt neue ID-Raeume).
- Goldset: +6 KV/RIS-Fragen (q-101-106) + Branchen-Refusal r-005;
  q-021 auf lb-kar-04 rekalibriert (Top-1, deckt Beginn/Dauer voll -
  dokumentiert im Note).
- Antwortmodus-Eval (qwen3.8:27b, 42 Fragen): Zitier-Praezision 95,2 %,
  Verweigerung 90,5 % - unter den M3-Gates, Tuning folgt
  (Report data/eval-qwen38-kvris.json, lokal).
- Docs: agent/README.md Baseline, planung.md Umsetzungsstand,
  .agents/MEMORY.md (D9/D10, offene Punkte).
This commit is contained in:
2026-09-15 07:45:04 +02:00
parent ac060c4977
commit a8234771cb
7 changed files with 139 additions and 10 deletions
+20 -3
View File
@@ -2,7 +2,8 @@
Lokaler RAG-Agent für österreichische Personalverrechnung: beantwortet
Fragen **ausschließlich** aus der kuratierten Wissensbasis (Layer 2,
`wissensbasis/`, 601 Einträge) — mit ID- und Stand-Beleg, ohne
`wissensbasis/`, **1274 Einträge**: 601 lb/wk-Kuratierung + 614 WKO-KV-
Dokumente + 59 RIS-Gesetze) — mit ID- und Stand-Beleg, ohne
Trainingswissen, ohne Web-Zugriff. Verbindliche Regeln:
`.agents/skills/pv-rag-agent/SKILL.md`, Plan: `planung.md`.
@@ -56,6 +57,9 @@ python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /r
| `PV_KB_DIR` | `wissensbasis` | Wissensbasis-Verzeichnis |
| `PV_THINK` | `false` | Thinking per Request (qwen3.8: default an) |
| `PV_EMBED_OFF` | `false` | `true` = BM25-only |
| `PV_CANDIDATE_POOL` | `150` | Kandidaten je Liste vor der Fusion (KV/RIS-Erweiterung: Longtail-Spezialisten in der Kandidatur halten) |
| `PV_RRF_K` | `20` | RRF-Konstante (erweiterter Korpus: Top-Ränge dominant) |
| `PV_DENSE_WEIGHT` | `2.0` | RRF-Gewicht der Dense-Liste relativ zu BM25 (BM25 ist durch KV-§-Titel-Matches inflationiert) |
| `PV_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt |
| `PV_PORT` | `8080` | API-Port |
@@ -110,6 +114,18 @@ verweigert trotzdem (sicheres Versagensmuster; M3-Prompt-Tuning-Kandidat).
Latenz-Hebel für M3: weniger Kontextblöcke, schnellere Kandidaten
(gemma4:12B, MoE).
**KV/RIS-Erweiterung + Retrieval-Kalibrierung (2026-09-15):** Korpus
601 → 1274 Einträge (kv-*: 614 WKO-KV-Dokumente quellentreu, ris-*:
59 RIS-Gesetze, nur die im Lexis360-Bestand zitierten §-Auschnitte);
14 984 Chunks. Kalibrierung per Goldset-Sweep (dichte Gewichtung,
RRF-k, Pool): Hit-Rate 0,865 → 0,973 · Recall@8 0,851 → **0,923** (>0,9 ✓)
· MRR 0,621 → 0,667. Antwortmodus (qwen3.8:27b): Zitier-Präzision
95,2 % (2 Verletzungen), Verweigerung korrekt 90,5 % (4 Fehlverweige-
rungen, u.a. q-022/q-029), erwartete Quelle zitiert 83,8 %, Latenz
mean 31 s / p95 53 s — **unter den M3-Gates** (100 % / 94,3 %): das
Fehlverweigerungs-Tuning (Regel 4/8 + Kontextblöcke) ist weiteres
Thema, Prompt bleibt beim laufenden Arbeitsstand eingefroren.
## Dateien
```
@@ -125,14 +141,15 @@ agent/
cli.py ingest | ask | eval | serve
eval/ goldset.yaml + evaluate.py
web/index.html Minimaler Test-Chat
tests/ 41 Tests (offline, Fake-Ollama)
tools/ Intake + Registry (build_registry.py, ingest_sources.py)
tests/ 49 Tests (offline, Fake-Ollama)
data/ index.db (gitignored)
```
## Tests
```bash
python -m pytest -q # 41 Tests, alle offline
python -m pytest -q # 49 Tests, alle offline
```
## Lizenz-Disziplin
+9 -2
View File
@@ -55,11 +55,16 @@ class Config:
# Retrieval
embed_off: bool = False # True = BM25-only (ohne Dense-Index/-Suche)
candidate_pool: int = 50 # Kandidaten je Liste vor der Fusion
candidate_pool: int = 150 # Kandidaten je Liste vor der Fusion
# (KV/RIS-Erweiterung: Longtail-Spezialisten
# liegen sonst außerhalb der Kandidatur)
context_blocks: int = 8 # Kontext-Blöcke im Prompt
cross_ref_expand: int = 3 # Top-Einträge, deren cross_refs ergänzt werden
cross_ref_max_extra: int = 6 # Obergrenze der Ergänzungen
rrf_k: int = 60
rrf_k: int = 20 # RRF-Konstante (erweiterter Korpus: Top-Ränge
# müssen dominanter zählen)
dense_weight: float = 2.0 # RRF-Gewicht der Dense-Liste relativ zu BM25
# (BM25 ist durch KV-§-Titel-Matches inflationiert)
recency_boost: float = 0.005 # additiv auf RRF-Score, gewichtet nach Stand
# Service
@@ -85,6 +90,8 @@ class Config:
context_blocks=_env_int("PV_CONTEXT_BLOCKS", d.context_blocks),
cross_ref_expand=_env_int("PV_CROSS_REF_EXPAND", d.cross_ref_expand),
cross_ref_max_extra=_env_int("PV_CROSS_REF_MAX_EXTRA", d.cross_ref_max_extra),
rrf_k=_env_int("PV_RRF_K", d.rrf_k),
dense_weight=_env_float("PV_DENSE_WEIGHT", d.dense_weight),
recency_boost=_env_float("PV_RECENCY_BOOST", d.recency_boost),
port=_env_int("PV_PORT", d.port),
)
+29 -2
View File
@@ -68,7 +68,10 @@ questions:
expected_ids: [lb-sch-04]
- id: q-021
question: "Wann beginnt die Elternkarenz und wie lange kann sie dauern?"
expected_ids: [lb-kar-01]
expected_ids: [lb-kar-04]
note: "Rekalibriert 2026-09-15 nach KV/RIS-Erweiterung: lb-kar-04
(Karenz - Anspruch, Beginn und Dauer) deckt die Frage vollständig und
ist Top-1; lb-kar-01 (Überblick) ist Nachbarschaft, nicht Pflichtbeleg."
- id: q-022
question: "Wie ist eine Überstundenpauschale zu behandeln?"
expected_ids: [lb-ues-02]
@@ -117,4 +120,28 @@ questions:
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
expect_refusal: true
# --- KV-/RIS-Erweiterung (Batch kv/ris 1, 2026-09-15) ---
- id: q-101
question: "Wie hoch ist der kollektivvertragliche Mindestmonatslohn für angelernte Friseurinnen und Friseure ab 1. April 2026?"
expected_ids: [kv-kvt-422]
- id: q-102
question: "Wie hoch ist das Mindestgrundgehalt in Verwendungsgruppe III der Gehaltsordnung Information und Consulting ab 1.1.2026?"
expected_ids: [kv-kvt-056]
- id: q-103
question: "Wie viele Werktage gesetzlicher Urlaub stehen Arbeitnehmern nach dem Urlaubsgesetz zu?"
expected_ids: [ris-url-01]
- id: q-104
question: "Wann verjährt der Urlaubsanspruch nach dem Urlaubsgesetz?"
expected_ids: [ris-url-01]
- id: q-105
question: "Was gilt nach dem Nachtschwerarbeitsgesetz als Nachtarbeit?"
expected_ids: [ris-nsc-01]
- id: q-106
question: "Wann entsteht der Anspruch auf Altersteilzeitgeld?"
expected_ids: [ris-atz-01]
- id: r-005
question: "Wie hoch ist der kollektivvertragliche Mindestlohn im KV für Raumfahrttechnik?"
expect_refusal: true
note: "Branche nicht im KV-Korpus — Retrieval darf nicht leer sein, Antwort muss verweigern."
+1 -1
View File
@@ -51,7 +51,7 @@ Verbindliche Regeln:
Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort."""
CITE_RE = re.compile(r"\b(?:lb|wk)-[a-z0-9]+-\d+\b")
CITE_RE = re.compile(r"\b(?:lb|wk|kv|ris)-[a-z0-9]+-\d+\b")
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
+2 -2
View File
@@ -250,10 +250,10 @@ class Retriever:
except Exception:
dn = {}
fused: dict[int, float] = {}
for ranking in (bm, dn):
for ranking, weight in ((bm, 1.0), (dn, self.cfg.dense_weight)):
ordered = sorted(ranking.items(), key=lambda kv: -kv[1])
for rank, (cid, _) in enumerate(ordered):
fused[cid] = fused.get(cid, 0.0) + 1.0 / (self.cfg.rrf_k + rank)
fused[cid] = fused.get(cid, 0.0) + weight / (self.cfg.rrf_k + rank)
if not fused:
return []