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:
2026-09-15 08:31:02 +02:00
parent a8234771cb
commit c594c553b5
6 changed files with 105 additions and 17 deletions
+19 -7
View File
@@ -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_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_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 |
@@ -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-*:
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.
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).
## Dateien
+9 -2
View File
@@ -47,7 +47,10 @@ class Config:
# Generierung
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
think: bool = False # Thinking per Request abschalten (Latenz)
chat_timeout_s: float = 300.0
@@ -59,7 +62,10 @@ class Config:
# (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_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
rrf_k: int = 20 # RRF-Konstante (erweiterter Korpus: Top-Ränge
# müssen dominanter zählen)
@@ -81,6 +87,7 @@ class Config:
answer_model=_env_str("PV_ANSWER_MODEL", d.answer_model),
temperature=_env_float("PV_TEMPERATURE", d.temperature),
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),
think=_env_bool("PV_THINK", d.think),
chat_timeout_s=_env_float("PV_CHAT_TIMEOUT_S", d.chat_timeout_s),
+19
View File
@@ -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:
blocks = []
for i, r in enumerate(results, 1):
@@ -130,6 +148,7 @@ def answer_question(
finally:
if own_retriever:
retriever.close()
results = trim_results(results, cfg.max_context_chars)
def finish(answer, refused, verified, citations, regenerations=0,
draft=None, sources=None):