Prompt v2 + Kontext-Section-Prioritaet: Fehlverweigerungen behoben (D8)

Diagnose: q-008 bekam die 'Verweise'-Navigationssektion von lb-end-03 als
Kontext (BM25-Langennormalisierung bevorzugt duenne Chunks) -> inhaltlose
Bloecke -> korrekte Verweigerung nach Regel 4. q-031 enthaelt eine falsche
Praemisse (kein 'Mindestlohngesetz' in Oesterreich; Wissensbasis sagt:
kein gesetzlich betraglich festgelegtes Mindestentgelt).

Fixes: (a) retrieve.py _representative_chunk - pro Eintrag beste Inhalts-
sektion (Zusammenfassung > Kernwerte > Rechtsgrundlagen > Payroll >
sonstige > Verweise), Section-Swap aus dem Index falls nur 'Verweise'
rangiert; (b) generate.py Prompt v2 - Regel 4 erlaubt Teilantworten,
Regel 8 verlangt Prämisse-Korrektur mit Muster-Beispiel.

Bestaetigungslauf qwen3.8:27b (35 Fragen): Zitier-Praezision 100 % (8
Regenerierungen, alle geheilt), Verweigerung korrekt 94,3 %, erwartete
Quelle 83,9 % (v1 80,6 %), mean 34 s. q-008 + q-031 behoben; verbleibender
bekannter Fall q-029 (breite Survey-Frage, sicherer Fehlermodus).
Retrieval-Metriken unveraendert (Recall@8 0,952). 41 Tests gruen.
This commit is contained in:
2026-09-15 00:22:41 +02:00
parent 6cd37429f3
commit eb1a768876
8 changed files with 227 additions and 15 deletions
+53 -5
View File
@@ -34,6 +34,29 @@ class ChunkResult:
source: str = "fused" # bm25 | dense | fused | cross_ref
def _section_priority(section: str) -> int:
"""Kontext-Sektionen priorisieren: Inhalt vor Navigation.
„Verweise“-Sektionen sind Navigationslisten (KB-IDs) — sie tragen
Retrieval-Signal (Stichworte), sind aber als Kontextblock wertlos und
provozieren Fehlverweigerungen. BM25-Längennormalisierung rangiert sie
bevorzugt, daher wird pro Eintrag bewusst die beste Inhaltssektion
gewählt (Fix 2026-09-14, q-008).
"""
s = (section or "").casefold()
if s.startswith("zusammenfassung"):
return 0
if s.startswith("kernwerte"):
return 1
if s.startswith("rechtsgrundlagen"):
return 2
if s.startswith("payroll"):
return 3
if s.startswith("verweise"):
return 5
return 4
class Retriever:
def __init__(self, cfg: Config, db_path: str | None = None, client=None):
self.cfg = cfg
@@ -182,6 +205,28 @@ class Retriever:
source=source,
)
def _representative_chunk(
self, entry_id: str, ranked: list[ChunkResult]
) -> ChunkResult:
"""Beste Inhaltssektion des Eintrags als Kontextblock.
Bevorzugt die rangierte (gefundene) Sektion mit bester Priorität;
traf der Eintrag nur über „Verweise“, wird seine beste Inhalts-
sektion aus dem Index nachgeladen (source="section-swap").
"""
content = [c for c in ranked if _section_priority(c.section) < 5]
if content:
return min(content, key=lambda c: (_section_priority(c.section), -c.score))
rows = self._con.execute(
"SELECT * FROM chunks WHERE entry_id = ? AND section NOT LIKE 'Verweise%' "
"ORDER BY CASE WHEN section LIKE 'Zusammenfassung%' THEN 0 "
"WHEN section LIKE 'Kernwerte%' THEN 1 ELSE 2 END, chunk_id LIMIT 1",
(entry_id,),
).fetchall()
if rows:
return self._row_to_result(rows[0], 0.0, "section-swap")
return ranked[0] # Eintrag hat nur Verweise-Sektionen
def _best_chunk_of_entry(self, entry_id: str) -> ChunkResult | None:
rows = self._con.execute(
"SELECT * FROM chunks WHERE entry_id = ? "
@@ -224,16 +269,19 @@ class Retriever:
results.append(self._row_to_result(rows[cid], score, source))
results.sort(key=lambda r: -r.score)
# Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup)
# Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup).
# Der Vertreter-Chunk ist die beste Inhaltssektion des Eintrags,
# nicht die Rangfolge-Beste (vgl. _section_priority).
main: list[ChunkResult] = []
seen: set[str] = set()
by_entry: dict[str, list[ChunkResult]] = {}
for r in results:
if r.entry_id in seen:
continue
seen.add(r.entry_id)
main.append(r)
by_entry.setdefault(r.entry_id, []).append(r)
for entry_id, chunks in by_entry.items():
if len(main) >= n:
break
seen.add(entry_id)
main.append(self._representative_chunk(entry_id, chunks))
# cross_ref-Erweiterung (kontrolliert, markiert, begrenzt)
extra: list[ChunkResult] = []