229 lines
12 KiB
Markdown
229 lines
12 KiB
Markdown
# PV RAG Agent
|
||
|
||
Lokaler RAG-Agent für österreichische Personalverrechnung: beantwortet
|
||
Fragen **ausschließlich** aus der Wissensbasis (Layer 2,
|
||
`wissensbasis/`, **1622 Einträge**: 601 lb/wk-Kuratierung + 614 WKO-KV-
|
||
Dokumente + 59 RIS-Gesetze + 348 Rechtsprechungs-/Normquellen) — mit ID-
|
||
und Stand-Beleg, ohne Trainingswissen, ohne Web-Zugriff. Verbindliche Regeln:
|
||
`.agents/skills/pv-rag-agent/SKILL.md`, Plan: `planung.md`.
|
||
|
||
## Architektur (Kurzfassung)
|
||
|
||
```
|
||
wissensbasis/dokumente/*.md ──ingest──▶ data/index.db
|
||
├─ chunks (FTS5, BM25, Umlaut-Folding)
|
||
├─ vectors (bge-m3, Content-Hash-Cache)
|
||
└─ Metadaten (stand, topic, tags, …)
|
||
Frage ──retrieve──▶ Hybrid BM25+Dense (RRF) + cross_ref-Erweiterung
|
||
──generate──▶ Ollama (Systemprompt, Zitierpflicht)
|
||
──validate──▶ zitierte IDs ⊆ Retrieved-Set? sonst 1× regenerieren, dann verweigern
|
||
```
|
||
|
||
- **Nur Layer 2** als Korpus (kuratiert, lizenzkonform). Layer-1-Volltexte
|
||
(`.lexis360/`, `.wiku/`) bleiben außen vor (offener Lizenzpunkt).
|
||
- **Kein Ausweg nach außen:** keine Tools, kein Browsing — der einzige
|
||
HTTP-Client spricht mit Ollama.
|
||
- **Verweigerungspflicht:** leeres/schwaches Retrieval → deterministische
|
||
Antwort „Dazu enthält die Wissensbasis keine Aussage." (kein LLM-Call).
|
||
|
||
## Schnellstart
|
||
|
||
```bash
|
||
pip install -r requirements.txt
|
||
|
||
# 1) Index bauen (mit Embeddings, wenn Ollama erreichbar)
|
||
python -m agent.cli ingest # --no-embed erzwingt BM25-only
|
||
|
||
# 2) Frage im Terminal
|
||
python -m agent.cli ask "Wie hoch ist die AMS-Ersatzquote bei geblockter Altersteilzeit?"
|
||
|
||
# 3) Goldset-Evaluation (offline: Retrieval-Metriken)
|
||
python -m agent.cli eval
|
||
# inkl. Antworten + Verweigerungsfälle (benötigt Ollama):
|
||
python -m agent.cli eval --answers --json-out data/eval-report.json
|
||
|
||
# 4) HTTP-API + Test-Chat
|
||
python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /reindex)
|
||
```
|
||
|
||
## Konfiguration (Umgebungsvariablen)
|
||
|
||
| Variable | Default | Bedeutung |
|
||
|---|---|---|
|
||
| `OLLAMA_URL` | `http://100.103.83.12:11435` | Ollama-Ziel-Instanz — Remote-GPU-Maschine im Tailscale-Netz (nicht localhost:11434 — das ist ein anderer, lokaler Ollama) |
|
||
| `PV_ANSWER_MODEL` | `qwen3.8:27b` | Antwortmodell (provisorisch bis Bake-off M3) |
|
||
| `PV_EMBED_MODEL` | `bge-m3` | Embedding-Modell |
|
||
| `PV_DB_PATH` | `data/index.db` | SQLite-Index |
|
||
| `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_PER_QUERY_SLOTS` | `2` | Multi-Query: garantierte Kontext-Slots je Sub-Query (Multi-Hop-Abdeckung) |
|
||
| `PV_QUERY_PLANNER` | `true` | Query-Planer an (Heuristik-Gate entscheidet je Frage) |
|
||
| `PV_PLANNER_MODEL` | leer = Antwortmodell | Modell des Planer-Calls |
|
||
| `PV_TEMPORAL_BOOST` | `0.0` | Bonus für kv-Einträge im gefragten Geltungsjahr |
|
||
| `PV_SURVEY_BLOCKS` | `16` | Map-Reduce: breiteres Retrieval für Survey-Fragen (Typ `survey` vom Planer) |
|
||
| `PV_NUM_CTX` | `32768` | Modell-Kontextfenster (KV-Chunks überschreiten 16k — Overflow trunciert den Systemprompt) |
|
||
| `PV_MAX_CONTEXT_CHARS` | `90000` | User-Content-Budget; niedrig gerankte Blöcke werden ganz weggelassen (`trim_results`) |
|
||
| `PV_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt |
|
||
| `PV_PORT` | `8080` | API-Port |
|
||
|
||
## Deployment auf dem Host (Ollama-Maschine)
|
||
|
||
```bash
|
||
# Ollama-Ziel-Instanz prüfen (Custom-Port! Achtung: auf dem Host läuft
|
||
# zusätzlich eine fast leere Instanz auf 11434 — nicht verwexseln)
|
||
curl http://100.103.83.12:11435/api/tags # qwen3.8:27b, bge-m3, Bake-off-Feld installiert
|
||
|
||
# Vollständiger Index (BM25 + Dense)
|
||
python -m agent.cli ingest
|
||
python -m agent.cli eval
|
||
python -m agent.cli eval --answers --json-out data/eval-report.json # Zitier-Präzision, Verweigerungen, Latenz
|
||
```
|
||
|
||
## Baseline (2026-09-14, Hybrid BM25 + bge-m3, Ollama :11435)
|
||
|
||
**Retrieval** (Goldset, 31 Fragen): Hit-Rate 0,968 · **Recall@8 0,952** ·
|
||
MRR 0,690 — M1-Ziel >0,9 erreicht (BM25-only war 0,855; die vier
|
||
BM25-Fehltreffer behebt die Dense-Suche alle).
|
||
|
||
**Antworten** (Bake-off-Sieger qwen3.8:27b, Thinking aus, Temperatur 0,1):
|
||
**Zitier-Präzision 100 %** · Verweigerung korrekt 94,3 % · erwartete Quelle
|
||
zitiert **83,9 %** · Latenz mean 34 s / p95 54 s.
|
||
|
||
**Prompt v2 + Kontext-Section-Priorität (2026-09-14):** Teilantworten bei
|
||
unvollständiger Deckung erlaubt (Regel 4), Prämisse-Korrektur statt
|
||
Verweigerung (Regel 8), pro Eintrag beste Inhaltssektion als Kontextblock
|
||
(Zusammenfassung > Kernwerte > … > Verweise zuletzt — Navigations-Chunks
|
||
lösen keine Fehlverweigerungen mehr aus). v1→v2: q-008 + q-031 behoben,
|
||
erwartete Quelle 80,6 % → 83,9 %, Zitier-Präzision unverändert 100 %.
|
||
|
||
**Modell-Bake-off (M3, 2026-09-14)** — Entscheidung: **qwen3.8:27b**
|
||
(Protokoll: Zitier-Präzision → Verweigerungskorrektheit → Latenz):
|
||
|
||
| Kandidat | Zitier-Präz. | Verweig. korrekt | Erw. Quelle | mean/p95 |
|
||
|---|---|---|---|---|
|
||
| **qwen3.8:27b** | **100 %** | **94,3 %** | **80,6 %** | 32 s / 53 s |
|
||
| gemma4:26b | 100 % | 91,4 % | 67,7 % | **7,9 s** / 12 s |
|
||
| gemma4:12B | 94,3 % | 91,4 % | 77,4 % | 21 s / 40 s |
|
||
| qwen3.6:27B | 94,3 % | 85,7 % | 80,6 % | 43 s / 89 s |
|
||
| muse-glimmer:latest | 100 % | 77,1 % | 74,2 % | 37,5 s / 51 s |
|
||
| mistral-small3.1:24b | 100 % | 71,4 % | 58,1 % | 20 s / 43 s |
|
||
|
||
`gemma4:26b` bleibt als dokumentierter Latenz-Kandidat für späteres
|
||
interaktives Tuning.
|
||
|
||
Bekannte Fehlverweigerungen: q-008 (Abfertigung Verfügungsmöglichkeiten),
|
||
q-031 (Mindestlohngesetz) — breite Fragen, Retrieval erfolgreich, Modell
|
||
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.
|
||
|
||
**Prompt-Budget + Kontext-Abdeckung (2026-09-15, D11):** Lange KV-Chunks
|
||
überlieferten num_ctx=16384 — Ollama trunciert den Systemprompt vorn,
|
||
das Modell verliert die Zitierregeln (Fehlverweigerungen, „Block-N“-
|
||
Zitate). Fix: num_ctx 32768, `trim_results` (max_context_chars=90 000,
|
||
Tail-Blöcke ganz weg) und cross_ref_expand 6 (Top-3 sind oft Branchen-
|
||
KV-Blöcke mit leeren cross_refs — kuratierte Nachbarn kamen nie nach).
|
||
Alle 4 Fehlverweigerungs-Fälle geheilt; 10/10 Bestätigungsläufe OK.
|
||
**Retrieval:** Recall@8 **0,946** (>0,9 ✓) · Hit-Rate 0,973 · MRR 0,671.
|
||
**Antworten (qwen3.8:27b, 42 Fragen):** Zitier-Präzision **97,6 %** (1
|
||
transienter Fall) · Verweigerung korrekt **97,6 %** (>94,3 %-Gate ✓) ·
|
||
erwartete Quelle zitiert 91,9 % · Latenz mean 33 s / p95 57 s ·
|
||
6 Regenerierungen. Reports: `data/eval-qwen38-kvris.json` (vor Tuning),
|
||
`data/eval-qwen38-kvris-tuned.json`. Offen: q-015 Branchen-Noise,
|
||
q-024 transiente Flakiness (leerer Draft).
|
||
|
||
**Komplexe-Fragen Stufe 1 (2026-09-15, M6/D12):** Query-Planer (Heuristik-
|
||
Gate → kleiner LLM-Call, 1-3 Sub-Queries als JSON; Stand-Jahr + Scope
|
||
je Sub-Query), Multi-Query-Retrieval mit Per-Query-Slots (2 je Sub-Query)
|
||
und Scope-Filter („gesetz“ = nur Lexis/WIKU/RIS, „kv“ = nur Branchen-KV;
|
||
Fallback unscoped). Grounding unverändert: eine Retrieved-Menge, eine
|
||
Antwort, Post-Validierung über die Union. Eval (46 Fragen): Zitier-
|
||
Präzision 97,8 % · Verweigerung korrekt 97,8 % (Gate ✓) · erwartete
|
||
Quelle 90,2 % · Latenz mean 33,5 s. Komplexe Goldset-Fragen: q-110–113
|
||
(Temporal 2023/2025, Abfertigung-Vergleich, Gesetz+KV-Multi-Source).
|
||
Report: `data/eval-qwen38-stage1-final.json`. Offen: q-029 Survey
|
||
(Stufe-2-Hebel: Map-Reduce), q-015 Branchen-Noise → API-first-Rückfrage
|
||
(Stufe 2).
|
||
|
||
**Antworttyp-Routing Stufe 2 (2026-09-15, M6/D13):** Planer liefert
|
||
`type: survey|specific`. Survey-Fragen („Welche Neuerungen …“) →
|
||
**Map-Reduce**: Retrieval auf `survey_blocks` (16) erweitert, ein Map-Call
|
||
destilliert jeden Block als Stichpunkte mit seiner KB-ID, ein Reduce-Call
|
||
synthetisiert die Endantwort — Zitier-Validierung weiterhin strikt über
|
||
die Retrieved-Union. **Regel 9** (Kontext-Abhängigkeit): belegte allgemeine
|
||
Aussage + eine Rückfrage statt Verweigerung (Branche/Bundesland/Zeitraum).
|
||
Ergebnisse: q-029 geheilt (5 belegte Hefte, ~95 s Map-Reduce-Latenz),
|
||
q-015 antwortet mit KV-Abhängigkeit + Rückfrage. Eval (46 Fragen):
|
||
Zitier-Präzision 97,8 % · Verweigerung 97,8 % (Gate ✓) · erwartete
|
||
Quelle 90,2 % · Latenz mean 34,2 s. Report: `data/eval-qwen38-stage2.json`.
|
||
|
||
**Output-Budget + length-Retry (2026-09-15, D14):** Die q-024-Flakiness
|
||
war kein Thinking, sondern `num_predict=1024`: lange belegte Antworten
|
||
wurden bei `done_reason=length` abgeschnitten → unvollständige Zitationen
|
||
→ Verletzungs-/Eskalationsspirale. Fix: `num_predict=2048`, `chat_full()`
|
||
liefert `done_reason`, bei `length` ein technischer Retry mit 2× Budget
|
||
(kein Regel-Regenerierungs-Zähler). **Voll-Eval: Zitier-Präzision
|
||
100 % · Verweigerung korrekt 100 %** (46/46, alle M3-Gates erstmals
|
||
voll erfüllt) · erwartete Quelle 92,7 % · Latenz mean 39,6 s / p95 78 s.
|
||
Report: `data/eval-qwen38-lengthfix.json`. Offene Restfälle: keine
|
||
Fehlverweigerungen/Verletzungen mehr; 3-4 Fragen zitiert gültige,
|
||
aber nicht die erwartete Quelle (q-015-Klasse).
|
||
|
||
**Rechtsprechungs-Intake (2026-09-15, D15):** Korpus 1.274 → **1.622**
|
||
Einträge: 320 RIS-OGD-Entscheidungen/Rechtssätze, 23 zitierte RIS-
|
||
Normauszüge und 5 als nichtamtlich markierte EuGH-Textwiedergaben im neuen
|
||
`rj-rjs-*`-ID-Raum. Der Reindex erzeugte **16.480 Chunks** (1.496 neue
|
||
`bge-m3`-Embeddings, 75 s). Retrieval mit dem erweiterten 50-Fragen-Goldset:
|
||
Hit-Rate **0,956** · Recall@8 **0,922** · MRR 0,661; das Recall-Gate >0,9
|
||
bleibt erfüllt, alle vier neuen Rechtsprechungsfälle werden gefunden. Vier
|
||
gezielte Antwortläufe (q-120–123) sind zitiergültig, ohne Verweigerung und
|
||
ohne Regenerierung; der volle Antwortmodus-Eval für den erweiterten Korpus
|
||
steht noch aus. EuGH-Texte sind im Kontext und in der Quelle explizit als
|
||
nichtamtliche lexetius-Wiedergabe gekennzeichnet; der Systemprompt verlangt
|
||
nun dieselbe Einschränkung auch in der Antwort.
|
||
|
||
## Dateien
|
||
|
||
```
|
||
agent/
|
||
config.py Env-Konfiguration
|
||
kb.py Layer-2-Parsing + kb.json-Gate
|
||
normalize.py Umlaut-Folding, FTS-Query-Bau
|
||
ingest.py Index-Bau (chunks + FTS5 + Vektoren-Cache)
|
||
retrieve.py Hybrid-Retrieval (BM25 + Dense, RRF, cross_refs)
|
||
ollama_client.py Ollama-HTTP (embed + chat, think-Fallback)
|
||
generate.py Systemprompt, Post-Validierung, Verweigerung
|
||
api.py FastAPI (/ask, /health, /reindex)
|
||
cli.py ingest | ask | eval | serve
|
||
eval/ goldset.yaml + evaluate.py
|
||
web/index.html Minimaler Test-Chat
|
||
tools/ Intake + Registry (build_registry.py, ingest_sources.py)
|
||
tests/ 64 Tests (offline, Fake-Ollama)
|
||
data/ index.db (gitignored)
|
||
```
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
python -m pytest -q # 49 Tests, alle offline
|
||
```
|
||
|
||
## Lizenz-Disziplin
|
||
|
||
`.lexis360/`, `.wiku/`, `.firecrawl/`, `.firecrawl/ris/gesetze/`, `.rechtsprechung/` sind lokal
|
||
und unversioniert (`.gitignore`). Der Index enthält ausschließlich Layer-2-
|
||
Inhalte; Layer-1-Prompts wären ein Lizenzverstoß und sind im Code nicht
|
||
vorgesehen. Die quellentreuen `rj_*.md` sind vor einer Versionierung anhand
|
||
von Provenance und Lizenzfreigabe der gelieferten Volltexte zu prüfen; die
|
||
EuGH-Wiedergaben sind ausdrücklich nicht amtlich. |