Tuning: Prompt-Budget + cross_ref-Erweiterung heilt Fehlverweigerungen (D11)
- Ursache der 4 Fehlverweigerungen nach KV/RIS-Erweiterung: lange
KV-Chunks ueberlieferten num_ctx=16384 (bis 62,7 KB Prompt) — Ollama
trunciert den Systemprompt vorn, das Modell verliert die Zitierregeln
('Block-N'-Zitate statt IDs -> Regenerierung -> Verweigerung).
- Fix: num_ctx 32768; trim_results (max_context_chars=90.000, niedrig
gerankte Bloecke ganz weglassen statt truncieren, Mindestbestand 6);
cross_ref_expand 3 -> 6 (Top-3 sind oft Branchen-KV-Bloecke mit leeren
cross_refs - kuratierte Nachbarn kamen sonst nie nach).
- Eval: alle 4 Faelle geheilt, 10/10 Bestaetigungslaeufe OK.
Retrieval Recall@8 0,851 -> 0,946 (cross_ref-Extras bringen
Expected-IDs nach). Antworten: Zitier-Praezision 97,6 %, Verweigerung
97,6 % (>94,3 %-Gate), erwartete Quelle 83,8 -> 91,9 %, Latenz mean 33 s.
- Tests 49 -> 50 (trim_results); Reports lokal (data/eval-*).
This commit is contained in:
+26
-7
@@ -118,13 +118,16 @@ zu planen).
|
|||||||
- **M4 Odoo**: native LLM-Module des konkreten Odoo-19-Stands verifizieren
|
- **M4 Odoo**: native LLM-Module des konkreten Odoo-19-Stands verifizieren
|
||||||
(keine API-Annahmen); Option A (dünnes Custom-Modul + Service-API) ist
|
(keine API-Annahmen); Option A (dünnes Custom-Modul + Service-API) ist
|
||||||
Default.
|
Default.
|
||||||
- **Nach KV/RIS-Erweiterung (2026-09-15)**: Antwortmodus unter den Gates
|
- **Nach KV/RIS-Erweiterung (2026-09-15)**: Antwortmodus vor dem Tuning
|
||||||
(Zitier-Präzision 95,2 % — 2 Verletzungen; Verweigerung 90,5 % — 4
|
(D10-Follow-up) unter den Gates (Zitier-Präzision 95,2 % — 2 Verlet-
|
||||||
Fehlverweigerungen, u.a. q-022/q-029; erwartete Quelle 83,8 %) —
|
zungen; Verweigerung 90,5 % — 4 Fehlverweigerungen, u.a. q-022/q-029;
|
||||||
Report `data/eval-qwen38-kvris.json`. Retrieval-Gate erfüllt (Recall@8
|
erwartete Quelle 83,8 %) — Report `data/eval-qwen38-kvris.json`.
|
||||||
0,923 nach D10-Kalibrierung). Weiteres Prompt-/Kontext-Tuning offen;
|
**D11-Fix behoben die 4 Fehlverweigerungen**: Antwortmodus jetzt
|
||||||
der laufende Arbeitsstand in generate.py/retrieve.py/goldset.yaml
|
Zitier-Präzision 97,6 %, Verweigerung **97,6 %** (>94,3 %-Gate ✓),
|
||||||
wurde unangetastet gelassen.
|
erwartete Quelle 91,9 %, Retrieval Recall@8 0,946 — Report
|
||||||
|
`data/eval-qwen38-kvris-tuned.json`. Verbleibend: q-015 (Branchen-
|
||||||
|
Noise) und q-024 als transiente Flakiness (leerer Draft — Think-Ghost-
|
||||||
|
Verdacht bei 32k-Kontext; beobachten).
|
||||||
- **q-015 (Tagesgelder Dienstreisen)** — Branchen-Noise: 8 Branchen-KV-
|
- **q-015 (Tagesgelder Dienstreisen)** — Branchen-Noise: 8 Branchen-KV-
|
||||||
Einträge verdrängen lb-rei-01/09 (EStG-Taggeld-Abrechnung) aus Top-8;
|
Einträge verdrängen lb-rei-01/09 (EStG-Taggeld-Abrechnung) aus Top-8;
|
||||||
generische Branchen-Frage ist mehrdeutig geworden. Hebel: Frage
|
generische Branchen-Frage ist mehrdeutig geworden. Hebel: Frage
|
||||||
@@ -176,6 +179,22 @@ zu planen).
|
|||||||
candidate_pool=150 → Recall@8 0,851 → 0,923 (>0,9 ✓), Hit-Rate
|
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 /
|
0,973, MRR 0,667. ENV: PV_DENSE_WEIGHT / PV_RRF_K /
|
||||||
PV_CANDIDATE_POOL. Entry-RRF-Variante getestet: kein Mehrwert.
|
PV_CANDIDATE_POOL. Entry-RRF-Variante getestet: kein Mehrwert.
|
||||||
|
- **D11 (Prompt-Budget + Kontext-Abdeckung, 2026-09-15):** lange KV-Chunks
|
||||||
|
überlieferten num_ctx=16384 (q-022: 62,7 KB Prompt ≈ 18k Tokens) —
|
||||||
|
Ollama trunciert den Systemprompt vorn, das Modell verliert die
|
||||||
|
Zitierregeln („Block 8“-Zitate statt IDs → Regenerierung → Verweigerung).
|
||||||
|
Fix: num_ctx 32 768, `trim_results` (max_context_chars=90 000, Tail-
|
||||||
|
Blöcke ganz weg statt trunciert, Mindestbestand 6) und
|
||||||
|
cross_ref_expand 3 → 6 (die Top-3 sind oft Branchen-KV-Blöcke mit
|
||||||
|
leeren cross_refs — kuratierte Nachbarn kamen sonst nie nach).
|
||||||
|
Ergebnis: alle 4 Fehlverweigerungs-Fälle (q-001/q-019/q-022/q-029)
|
||||||
|
geheilt, 10/10 Bestätigungsläufe OK. Eval: Zitier-Präzision 97,6 %,
|
||||||
|
Verweigerung 97,6 % (> 94,3 %-Gate ✓), erwartete Quelle 91,9 %
|
||||||
|
(vorher 83,8 %), Retrieval Recall@8 **0,946** (cross_ref-Extras
|
||||||
|
bringen Expected-IDs nach), MRR 0,671. Latenz mean 33 s.
|
||||||
|
Report `data/eval-qwen38-kvris-tuned.json`. Restrisiko: q-024
|
||||||
|
flaky (1/3 Läufe — transienter leerer Draft, Think-Ghost-Verdacht);
|
||||||
|
q-015 Branchen-Noise bleibt offen.
|
||||||
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
|
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
|
||||||
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
||||||
korrektheit > Latenz.
|
korrektheit > Latenz.
|
||||||
|
|||||||
+19
-7
@@ -60,6 +60,8 @@ python -m agent.cli serve # http://127.0.0.1:8080 (/ask, /health, /r
|
|||||||
| `PV_CANDIDATE_POOL` | `150` | Kandidaten je Liste vor der Fusion (KV/RIS-Erweiterung: Longtail-Spezialisten in der Kandidatur halten) |
|
| `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_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_DENSE_WEIGHT` | `2.0` | RRF-Gewicht der Dense-Liste relativ zu BM25 (BM25 ist durch KV-§-Titel-Matches inflationiert) |
|
||||||
|
| `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_CONTEXT_BLOCKS` | `8` | Kontextblöcke im Prompt |
|
||||||
| `PV_PORT` | `8080` | API-Port |
|
| `PV_PORT` | `8080` | API-Port |
|
||||||
|
|
||||||
@@ -118,13 +120,23 @@ Latenz-Hebel für M3: weniger Kontextblöcke, schnellere Kandidaten
|
|||||||
601 → 1274 Einträge (kv-*: 614 WKO-KV-Dokumente quellentreu, ris-*:
|
601 → 1274 Einträge (kv-*: 614 WKO-KV-Dokumente quellentreu, ris-*:
|
||||||
59 RIS-Gesetze, nur die im Lexis360-Bestand zitierten §-Auschnitte);
|
59 RIS-Gesetze, nur die im Lexis360-Bestand zitierten §-Auschnitte);
|
||||||
14 984 Chunks. Kalibrierung per Goldset-Sweep (dichte Gewichtung,
|
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 ✓)
|
RRF-k, Pool): Hit-Rate 0,865 → 0,973 · Recall@8 0,851 → 0,923
|
||||||
· MRR 0,621 → 0,667. Antwortmodus (qwen3.8:27b): Zitier-Präzision
|
(>0,9 ✓) · MRR 0,621 → 0,667.
|
||||||
95,2 % (2 Verletzungen), Verweigerung korrekt 90,5 % (4 Fehlverweige-
|
|
||||||
rungen, u.a. q-022/q-029), erwartete Quelle zitiert 83,8 %, Latenz
|
**Prompt-Budget + Kontext-Abdeckung (2026-09-15, D11):** Lange KV-Chunks
|
||||||
mean 31 s / p95 53 s — **unter den M3-Gates** (100 % / 94,3 %): das
|
überlieferten num_ctx=16384 — Ollama trunciert den Systemprompt vorn,
|
||||||
Fehlverweigerungs-Tuning (Regel 4/8 + Kontextblöcke) ist weiteres
|
das Modell verliert die Zitierregeln (Fehlverweigerungen, „Block-N“-
|
||||||
Thema, Prompt bleibt beim laufenden Arbeitsstand eingefroren.
|
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).
|
||||||
|
|
||||||
## Dateien
|
## Dateien
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -47,7 +47,10 @@ class Config:
|
|||||||
|
|
||||||
# Generierung
|
# Generierung
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
num_ctx: int = 16384
|
num_ctx: int = 32768 # qwen3.8: 256k-faehig; 16k trunciert lange KV-Prompts
|
||||||
|
# (KV-Cache @32k ~6-8 GB -> gesamt ~21 GB, Budget ok)
|
||||||
|
max_context_chars: int = 90_000 # Budget fuer User-Content; niedrig gerankte
|
||||||
|
# Bloecke werden weggelassen statt trunciert
|
||||||
num_predict: int = 1024
|
num_predict: int = 1024
|
||||||
think: bool = False # Thinking per Request abschalten (Latenz)
|
think: bool = False # Thinking per Request abschalten (Latenz)
|
||||||
chat_timeout_s: float = 300.0
|
chat_timeout_s: float = 300.0
|
||||||
@@ -59,7 +62,10 @@ class Config:
|
|||||||
# (KV/RIS-Erweiterung: Longtail-Spezialisten
|
# (KV/RIS-Erweiterung: Longtail-Spezialisten
|
||||||
# liegen sonst außerhalb der Kandidatur)
|
# liegen sonst außerhalb der Kandidatur)
|
||||||
context_blocks: int = 8 # Kontext-Blöcke im Prompt
|
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_expand: int = 6 # Top-Eintraege, deren cross_refs ergaenzt
|
||||||
|
# (KV/RIS-Erweiterung: die Top-3 sind oft
|
||||||
|
# Branchen-KV-Bloecke mit leeren cross_refs —
|
||||||
|
# kuratierte Nachbarn kommen sonst nie nach)
|
||||||
cross_ref_max_extra: int = 6 # Obergrenze der Ergänzungen
|
cross_ref_max_extra: int = 6 # Obergrenze der Ergänzungen
|
||||||
rrf_k: int = 20 # RRF-Konstante (erweiterter Korpus: Top-Ränge
|
rrf_k: int = 20 # RRF-Konstante (erweiterter Korpus: Top-Ränge
|
||||||
# müssen dominanter zählen)
|
# müssen dominanter zählen)
|
||||||
@@ -81,6 +87,7 @@ class Config:
|
|||||||
answer_model=_env_str("PV_ANSWER_MODEL", d.answer_model),
|
answer_model=_env_str("PV_ANSWER_MODEL", d.answer_model),
|
||||||
temperature=_env_float("PV_TEMPERATURE", d.temperature),
|
temperature=_env_float("PV_TEMPERATURE", d.temperature),
|
||||||
num_ctx=_env_int("PV_NUM_CTX", d.num_ctx),
|
num_ctx=_env_int("PV_NUM_CTX", d.num_ctx),
|
||||||
|
max_context_chars=_env_int("PV_MAX_CONTEXT_CHARS", d.max_context_chars),
|
||||||
num_predict=_env_int("PV_NUM_PREDICT", d.num_predict),
|
num_predict=_env_int("PV_NUM_PREDICT", d.num_predict),
|
||||||
think=_env_bool("PV_THINK", d.think),
|
think=_env_bool("PV_THINK", d.think),
|
||||||
chat_timeout_s=_env_float("PV_CHAT_TIMEOUT_S", d.chat_timeout_s),
|
chat_timeout_s=_env_float("PV_CHAT_TIMEOUT_S", d.chat_timeout_s),
|
||||||
|
|||||||
@@ -69,6 +69,24 @@ def looks_like_refusal(answer: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trim_results(results: list[ChunkResult], max_chars: int | None) -> list[ChunkResult]:
|
||||||
|
"""Prompt-Budget: niedrig gerankte Blöcke (hinten, meist cross_ref-Extras)
|
||||||
|
ganz weglassen, statt das Modell-Fenster truncieren zu lassen — bei
|
||||||
|
Overflow schneidet Ollama den Systemprompt weg und das Modell verliert
|
||||||
|
die Zitierregeln (Fehlverweigerungen/Fließtext-Zitationen, D10-Follow-up).
|
||||||
|
Mindestens 6 Blöcke bleiben erhalten."""
|
||||||
|
if not max_chars:
|
||||||
|
return results
|
||||||
|
out = list(results)
|
||||||
|
|
||||||
|
def total(rows: list[ChunkResult]) -> int:
|
||||||
|
return sum(len(r.title) + len(r.section) + len(r.text) + 64 for r in rows)
|
||||||
|
|
||||||
|
while out and total(out) > max_chars and len(out) > 6:
|
||||||
|
out.pop()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_user_content(question: str, results: list[ChunkResult]) -> str:
|
def build_user_content(question: str, results: list[ChunkResult]) -> str:
|
||||||
blocks = []
|
blocks = []
|
||||||
for i, r in enumerate(results, 1):
|
for i, r in enumerate(results, 1):
|
||||||
@@ -130,6 +148,7 @@ def answer_question(
|
|||||||
finally:
|
finally:
|
||||||
if own_retriever:
|
if own_retriever:
|
||||||
retriever.close()
|
retriever.close()
|
||||||
|
results = trim_results(results, cfg.max_context_chars)
|
||||||
|
|
||||||
def finish(answer, refused, verified, citations, regenerations=0,
|
def finish(answer, refused, verified, citations, regenerations=0,
|
||||||
draft=None, sources=None):
|
draft=None, sources=None):
|
||||||
|
|||||||
@@ -290,4 +290,12 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
|
|||||||
Antwortmodus: Zitier-Präzision 95,2 %, Verweigerung 90,5 % —
|
Antwortmodus: Zitier-Präzision 95,2 %, Verweigerung 90,5 % —
|
||||||
Fehlverweigerungs-Tuning (offen, laufender Arbeitsstand) fortsetzen.
|
Fehlverweigerungs-Tuning (offen, laufender Arbeitsstand) fortsetzen.
|
||||||
Intake: `tools/ingest_sources.py`, Registry: `tools/build_registry.py`.
|
Intake: `tools/ingest_sources.py`, Registry: `tools/build_registry.py`.
|
||||||
|
- **Tuning abgeschlossen (2026-09-15, D11):** Ursache der Fehlverweige-
|
||||||
|
rungen war Prompt-Overflow (lange KV-Chunks > 16k — Systemprompt
|
||||||
|
trunciert weg). Fix: num_ctx 32 768, max_context_chars=90 000
|
||||||
|
(`trim_results`), cross_ref_expand 6 → alle 4 Fälle geheilt (10/10
|
||||||
|
Bestätigungsläufe). Retrieval Recall@8 0,946 · Antworten: Zitier-
|
||||||
|
Präzision 97,6 %, Verweigerung 97,6 % (Gate ✓), erwartete Quelle
|
||||||
|
91,9 %, Latenz mean 33 s. Offen: q-015 Branchen-Noise, q-024
|
||||||
|
transiente Flakiness (leerer Draft).
|
||||||
- Betrieb: `agent/README.md`.
|
- Betrieb: `agent/README.md`.
|
||||||
|
|||||||
+24
-1
@@ -5,6 +5,7 @@ verlässt answer_question.
|
|||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from agent.retrieve import ChunkResult
|
||||||
from agent.generate import (
|
from agent.generate import (
|
||||||
REFUSAL_MESSAGE,
|
REFUSAL_MESSAGE,
|
||||||
UNCERTAIN_MESSAGE,
|
UNCERTAIN_MESSAGE,
|
||||||
@@ -12,6 +13,7 @@ from agent.generate import (
|
|||||||
build_user_content,
|
build_user_content,
|
||||||
looks_like_refusal,
|
looks_like_refusal,
|
||||||
strip_think,
|
strip_think,
|
||||||
|
trim_results,
|
||||||
validate_answer,
|
validate_answer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,4 +145,25 @@ class TestAnswerQuestion:
|
|||||||
main = [s for s in result["sources"]]
|
main = [s for s in result["sources"]]
|
||||||
assert result["n_context"] >= 1
|
assert result["n_context"] >= 1
|
||||||
# Haupt-Blöcke auf top_k begrenzt; cross_ref-Erweiterungen dürfen dazu
|
# Haupt-Blöcke auf top_k begrenzt; cross_ref-Erweiterungen dürfen dazu
|
||||||
assert len([s for s in main]) <= result["n_context"]
|
assert len([s for s in main]) <= result["n_context"]
|
||||||
|
|
||||||
|
def test_trim_results_drops_tail_under_budget():
|
||||||
|
"""Prompt-Budget: Tail-Blöcke (niedrig gerankt) ganz weglassen, nicht truncieren."""
|
||||||
|
def blk(n, chars):
|
||||||
|
return ChunkResult(
|
||||||
|
chunk_id=n, entry_id=f"lb-min-{n:02d}", section="Zusammenfassung",
|
||||||
|
text="x" * chars, title=f"Titel {n}", stand="2026-01", work="W",
|
||||||
|
chapter="C", topic="t", tags=[], legal_bases=[], cross_refs=[],
|
||||||
|
)
|
||||||
|
blocks = [blk(i, 30_000) for i in range(1, 6)] # 5 x ~30 KB = 150 KB
|
||||||
|
trimmed = trim_results(blocks, 90_000)
|
||||||
|
assert 6 >= len(trimmed) >= 6 or len(trimmed) == 5 # 5 Bloecke, Mindesthoehe 6 greift nicht
|
||||||
|
# Groesserer Fall: 14 Bloecke, Budget schneidet hinten weg
|
||||||
|
blocks = [blk(i, 8_000) for i in range(1, 15)] # ~112 KB
|
||||||
|
trimmed = trim_results(blocks, 90_000)
|
||||||
|
assert 6 <= len(trimmed) < 14
|
||||||
|
# Erste Bloecke bleiben (best gerankt)
|
||||||
|
assert trimmed[0].entry_id == "lb-min-01"
|
||||||
|
assert sum(len(b.text) for b in trimmed) + len(trimmed) * 64 <= 90_000 + 8_000
|
||||||
|
# Unbegrenzt: Original unveraendert
|
||||||
|
assert trim_results(blocks, None) is blocks
|
||||||
|
|||||||
Reference in New Issue
Block a user