Stufe 1: komplexe Fragen via Query-Planer, Per-Query-Slots, Scope-Filter (D12, M6)

- Query-Planer (agent/query_planner.py): Heuristik-Gate (Jahr, Vergleichs-/
  Aggregationsmarker, Laenge) entscheidet ueber einen kleinen LLM-Call,
  der komplexe Fragen in 1-3 Sub-Queries zerlegt (JSON, temp 0; Fehler ->
  Original als Einzel-Query). Je Sub-Query optional stand_year und scope.
- Multi-Query-Retrieval: je Sub-Query BM25+Dense mit RRF-Summe;
  Per-Query-Slots (2 je Sub-Query) sichern jeden Frageaspekt im Kontext
  (sonst dominieren Eintraege, die in mehreren Sub-Queries mittelgut
  matchen — q-113-Befund). Scope-Filter: 'gesetz' (nur Lexis/WIKU/RIS)
  und 'kv' (nur Branchen-KV) mit Fallback auf unscoped bei leerem
  Ergebnis. Temporal-Intent: stand_year + temporal_boost (default 0,
  FTS-jahr-Tag-Signal reichte).
- Grounding unveraendert: eine Retrieved-Menge (Union), eine Antwort,
  Post-Validierung ueber die Union, Verweigerungspflicht unveraendert.
- Goldset 42 -> 46: q-110 (Temporal 2023; 2024er-Lohnordnung existiert
  im Korpus nicht - Mantelvertrag ohne Lohntabelle, korrekt verweigert),
  q-111 (2025), q-112 (Abfertigung-Vergleich), q-113 (Gesetz+KV).
- Eval (46 Fragen): Zitier-Praezision 97,8 %, Verweigerung 97,8 %
  (Gate >94,3 % erfuellt), erwartete Quelle 90,2 %, Latenz mean 33,5 s.
- Tests 50 -> 57. Reports lokal: data/eval-qwen38-stage1*.json.
  API-first festgehalten (D13-Vorbereitung): Odoo bleibt duenner Client;
  Lohndaten-Zugriff in M4 erfordert Privacy-Neubewertung.
This commit is contained in:
2026-09-15 09:58:25 +02:00
parent c594c553b5
commit 062e010d7b
10 changed files with 532 additions and 33 deletions
+33 -6
View File
@@ -180,21 +180,48 @@ zu planen).
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 - **D11 (Prompt-Budget + Kontext-Abdeckung, 2026-09-15):** lange KV-Chunks
überlieferten num_ctx=16384 (q-022: 62,7 KB Prompt ≈ 18k Tokens) — ueberlieferten num_ctx=16384 (q-022: 62,7 KB Prompt ≈ 18k Tokens) —
Ollama trunciert den Systemprompt vorn, das Modell verliert die Ollama trunciert den Systemprompt vorn, das Modell verliert die
Zitierregeln („Block 8“-Zitate statt IDs → Regenerierung → Verweigerung). Zitierregeln („Block 8“-Zitate statt IDs → Regenerierung → Verweigerung).
Fix: num_ctx 32 768, `trim_results` (max_context_chars=90 000, Tail- Fix: num_ctx 32 768, `trim_results` (max_context_chars=90 000, Tail-
Blöcke ganz weg statt trunciert, Mindestbestand 6) und Bloecke ganz weg statt trunciert, Mindestbestand 6) und
cross_ref_expand 3 → 6 (die Top-3 sind oft Branchen-KV-Blöcke mit cross_ref_expand 3 → 6 (die Top-3 sind oft Branchen-KV-Bloecke mit
leeren cross_refs — kuratierte Nachbarn kamen sonst nie nach). leeren cross_refs — kuratierte Nachbarn kamen sonst nie nach).
Ergebnis: alle 4 Fehlverweigerungs-Fälle (q-001/q-019/q-022/q-029) Ergebnis: alle 4 Fehlverweigerungs-Faelle (q-001/q-019/q-022/q-029)
geheilt, 10/10 Bestätigungsläufe OK. Eval: Zitier-Präzision 97,6 %, geheilt, 10/10 Bestaetigungslaeufe OK. Eval: Zitier-Praezision 97,6 %,
Verweigerung 97,6 % (> 94,3 %-Gate ✓), erwartete Quelle 91,9 % Verweigerung 97,6 % (> 94,3 %-Gate ✓), erwartete Quelle 91,9 %
(vorher 83,8 %), Retrieval Recall@8 **0,946** (cross_ref-Extras (vorher 83,8 %), Retrieval Recall@8 **0,946** (cross_ref-Extras
bringen Expected-IDs nach), MRR 0,671. Latenz mean 33 s. bringen Expected-IDs nach), MRR 0,671. Latenz mean 33 s.
Report `data/eval-qwen38-kvris-tuned.json`. Restrisiko: q-024 Report `data/eval-qwen38-kvris-tuned.json`. Restrisiko: q-024
flaky (1/3 Läufe — transienter leerer Draft, Think-Ghost-Verdacht); flaky (1/3 Laeufe — transienter leerer Draft, Think-Ghost-Verdacht);
q-015 Branchen-Noise bleibt offen. q-015 Branchen-Noise bleibt offen.
- **D12 (Komplexe-Fragen-Stufe 1 / M6, 2026-09-15):** Query-Planer vor dem
Retrieval: Heuristik-Gate (Jahreszahl, Vergleichs-/Aggregationsmarker,
Laenge) entscheidet, ob ein kleiner LLM-Call die Frage in 1-3 Sub-Queries
zerlegt (JSON, temp 0, num_predict 220; Fehler -> Original als Einzel-
Query). Multi-Query-Retrieval: je Sub-Query BM25+Dense, Beitraege
summieren; **Per-Query-Slots** (2 je Sub-Query) sichern jeden Frage-
aspekt im Kontext — ohne sie dominieren Eintraege, die in mehreren
Sub-Queries mittelgut matchen (q-113: 5 KV-Urlaubs-SS schlugen
lb-url-05 (Rang 6) und ris-url-01 (>Top-12) ueber beide Sub-Queries).
**Scope-Filter** je Sub-Query: Planer markiert "gesetz" (nur Lexis/WIKU/
RIS-Chunks) bzw. "kv" (nur Branchen-KV) — q-113 geheilt (ris-url-01/
lb-url-05 + KV-Ss im selben Kontext). Scope-Fallback: gefilterte Suche
leer -> unscoped. Temporal-Intent: stand_year je Sub-Query, kv-Eintraege
im Geltungsjahr erhalten temporal_boost (default 0 — FTS-Tag-Signal
`jahr-YYYY` reichte in der Messung). Grounding unveraendert: ein
Kontext aus der Union, Post-Validierung ueber die gesamte Retrieved-
Menge, Verweigerungspflicht unveraendert.
Eval (46 Fragen): Zitier-Praezision 97,8 %, Verweigerung 97,8 %
(Gate > 94,3 % erfuellt), erwartete Quelle 90,2 %, Latenz mean 33,5 s.
Temporal-Fragen laufen sogar ohne Planer gut (Jahr-Toknen matchen
`jahr-YYYY`-Tags). Neue komplexe Fragen im Goldset: q-110 (Temporal 2023,
nach Korrektur — eine 2024er-Friseur-Lohnordnung existiert im Korpus
NICHT: kv-kvt-161 ist der Mantelvertrag ohne Lohntabelle; korrekt
verweigert), q-111 (Temporal 2025), q-112 (Abfertigung Vergleich),
q-113 (Gesetz+KV Multi-Source). Tests 50 -> 57.
Offen: q-029 Survey (Stufe-2-Hebel: Map-Reduce), q-113 Planer-
Scope flaky (1/2 Laeufe ohne gesetz-Marker).
- **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.
+17
View File
@@ -60,6 +60,10 @@ 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_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_NUM_CTX` | `32768` | Modell-Kontextfenster (KV-Chunks überschreiten 16k — Overflow trunciert den Systemprompt) | | `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_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 |
@@ -138,6 +142,19 @@ erwartete Quelle zitiert 91,9 % · Latenz mean 33 s / p95 57 s ·
`data/eval-qwen38-kvris-tuned.json`. Offen: q-015 Branchen-Noise, `data/eval-qwen38-kvris-tuned.json`. Offen: q-015 Branchen-Noise,
q-024 transiente Flakiness (leerer Draft). 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-110113
(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).
## Dateien ## Dateien
``` ```
+16
View File
@@ -73,6 +73,16 @@ class Config:
# (BM25 ist durch KV-§-Titel-Matches inflationiert) # (BM25 ist durch KV-§-Titel-Matches inflationiert)
recency_boost: float = 0.005 # additiv auf RRF-Score, gewichtet nach Stand recency_boost: float = 0.005 # additiv auf RRF-Score, gewichtet nach Stand
# Query-Planer (Stufe 1, M6): komplexe Fragen -> 1-3 Sub-Queries
planner_enabled: bool = True # Heuristik-Gate entscheidet je Frage
planner_max_queries: int = 3
planner_num_predict: int = 220
planner_model: str = "" # leer = Antwortmodell
per_query_slots: int = 2 # Multi-Query: garantierte Kontext-Slots
# je Sub-Query (Multi-Hop-Abdeckung)
temporal_boost: float = 0.0 # Bonus fuer kv-Eintraege im gefragten
# Geltungsjahr (0 = nur FTS-Tag-Signal)
# Service # Service
port: int = 8080 port: int = 8080
@@ -100,5 +110,11 @@ class Config:
rrf_k=_env_int("PV_RRF_K", d.rrf_k), rrf_k=_env_int("PV_RRF_K", d.rrf_k),
dense_weight=_env_float("PV_DENSE_WEIGHT", d.dense_weight), dense_weight=_env_float("PV_DENSE_WEIGHT", d.dense_weight),
recency_boost=_env_float("PV_RECENCY_BOOST", d.recency_boost), recency_boost=_env_float("PV_RECENCY_BOOST", d.recency_boost),
planner_enabled=_env_bool("PV_QUERY_PLANNER", d.planner_enabled),
planner_max_queries=_env_int("PV_PLANNER_MAX_QUERIES", d.planner_max_queries),
planner_num_predict=_env_int("PV_PLANNER_NUM_PREDICT", d.planner_num_predict),
planner_model=_env_str("PV_PLANNER_MODEL", d.planner_model),
per_query_slots=_env_int("PV_PER_QUERY_SLOTS", d.per_query_slots),
temporal_boost=_env_float("PV_TEMPORAL_BOOST", d.temporal_boost),
port=_env_int("PV_PORT", d.port), port=_env_int("PV_PORT", d.port),
) )
+20
View File
@@ -145,3 +145,23 @@ questions:
question: "Wie hoch ist der kollektivvertragliche Mindestlohn im KV für Raumfahrttechnik?" question: "Wie hoch ist der kollektivvertragliche Mindestlohn im KV für Raumfahrttechnik?"
expect_refusal: true expect_refusal: true
note: "Branche nicht im KV-Korpus — Retrieval darf nicht leer sein, Antwort muss verweigern." note: "Branche nicht im KV-Korpus — Retrieval darf nicht leer sein, Antwort muss verweigern."
# --- Komplexe Fragen (Stufe 1, M6: Multi-Query + Temporal-Intent) ---
- id: q-110
question: "Wie hoch war der kollektivvertragliche Mindestmonatslohn für angelernte Friseurinnen und Friseure ab 1.4.2023?"
expected_ids: [kv-kvt-014]
note: "Temporal: historische KV-Fassung 2023 muss vor den 2025/2026er-
Versionen liegen. (Eine 2024er-Lohnordnung existiert im Korpus nicht:
kollektivvertrag-friseur-2024 ist der Mantelvertrag ohne Lohntabelle.)"
- id: q-111
question: "Welche Lehrlingseinkommen galten im Friseurgewerbe ab 1.4.2025?"
expected_ids: [kv-kvt-001]
note: "Temporal: KV-Fassung 2025 (nicht 2026)."
- id: q-112
question: "Was ist der Unterschied zwischen Abfertigung neu und Abfertigung alt?"
expected_ids: [lb-end-02, lb-end-03]
note: "Multi-Hop/Vergleich: beide Regime aus getrennten Eintraegen belegen."
- id: q-113
question: "Wie viele Werktage gesetzlicher Urlaub stehen Arbeitnehmern zu und welche Rolle spielt dabei der Kollektivvertrag?"
expected_ids: [ris-url-01, lb-url-05]
note: "Multi-Source: Gesetz + Kuratierung; UrlG § 2 (1) erlaubt KV-Abweichungen (§ 2 Abs. 4)."
+27 -8
View File
@@ -13,6 +13,7 @@ import time
from .config import Config from .config import Config
from .normalize import normalize_text from .normalize import normalize_text
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
from .query_planner import SubQuery, plan_queries
from .retrieve import ChunkResult, Retriever from .retrieve import ChunkResult, Retriever
REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage." REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage."
@@ -138,13 +139,33 @@ def answer_question(
retriever: Retriever | None = None, retriever: Retriever | None = None,
top_k: int | None = None, top_k: int | None = None,
) -> dict: ) -> dict:
"""Vollständiger Ask-Zyklus: Retrieval -> Prompt -> LLM -> Post-Validierung.""" """Vollständiger Ask-Zyklus: Query-Planung -> Retrieval -> Prompt -> LLM ->
Post-Validierung. Der Planer läuft vor dem Retrieval (Heuristik-Gate,
nur bei komplexen Fragen); seine Sub-Queries fusionieren in EINER
Retrieved-Menge, gegen die die Post-Validierung prüft."""
t0 = time.perf_counter() t0 = time.perf_counter()
own_retriever = retriever is None own_retriever = retriever is None
if retriever is None: if retriever is None:
retriever = Retriever(cfg) retriever = Retriever(cfg)
if client is None:
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
sub_queries = [SubQuery(text=question)]
planned = False
if cfg.planner_enabled:
try:
sub_queries, planned = plan_queries(question, client, cfg)
sub_queries = sub_queries[: cfg.planner_max_queries] or sub_queries[:1]
except Exception:
sub_queries, planned = [SubQuery(text=question)], False
try: try:
results = retriever.search(question, n_entries=top_k) results = retriever.search_multi(sub_queries, n_entries=top_k)
finally: finally:
if own_retriever: if own_retriever:
retriever.close() retriever.close()
@@ -164,6 +185,10 @@ def answer_question(
"regenerations": regenerations, "regenerations": regenerations,
"latency_ms": round((time.perf_counter() - t0) * 1000), "latency_ms": round((time.perf_counter() - t0) * 1000),
"draft": draft, "draft": draft,
"planned_queries": [
{"text": sq.text, "stand_year": sq.stand_year}
for sq in sub_queries
],
} }
if not results: if not results:
@@ -173,12 +198,6 @@ def answer_question(
allowed = [r.entry_id for r in results] allowed = [r.entry_id for r in results]
by_id = {r.entry_id: r for r in results} by_id = {r.entry_id: r for r in results}
if client is None:
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
messages = [ messages = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_content(question, results)}, {"role": "user", "content": build_user_content(question, results)},
+122
View File
@@ -0,0 +1,122 @@
"""Query-Planer für komplexe Fragen (Stufe 1, M6).
Ein kleiner LLM-Call zerlegt komplexe Fragen in 1-3 unabhängige Sub-Queries
(+ optional Geltungsjahr). Ein Heuristik-Gate entscheidet, ob der Planer-Call
überhaupt läuft — einfache Fragen bleiben deterministischer Single-Shot
(keine Zusatz-Latenz). Parse-/Call-Fehler fallen auf die Original-Frage
zurück; die Grounding-Regeln werden dadurch nie berührt.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
AGGREGATION_RE = re.compile(
r"neuerungen|übersicht|zusammenfassung|alle\s|übersicht", re.I
)
COMPARISON_RE = re.compile(r"unterschied|vergleic|\bbzw\.|\bsowie\b", re.I)
PLANNER_PROMPT = """Du planst Suchanfragen für eine Wissensbasis zur österreichischen
Personalverrechnung (kuratierte Briefings, Kollektivverträge je Branche und
Geltungsjahr, Gesetzesparagraphen).
Frage: "{question}"
Zerlege die Frage in 1-3 unabhängige Suchanfragen, die zusammen die Frage
beantworten. Regeln:
- Besteht die Frage aus einem Suchthema, gib genau eine Suchanfrage zurück
(dann die Frage unverändert, leicht stichwortartig gekürzt).
- Suchbegriffe statt Sätze (ohne Frageformulierung, ohne "Kollektivvertrag"-
Wortballast).
- Fragt die Frage nach einem bestimmten Jahr (Geltung/Stand), setze
"stand_year" auf dieses vierstellige Jahr, sonst null.
- Setze "scope" je Suchanfrage: "gesetz", wenn nach der gesetzlichen/
allgemeinen Grundlage gefragt ist (nur Gesetze und Fachbriefings, ohne
Branchen-Kollektivverträge); "kv", wenn ausdrücklich nach kollektivvertrag-
lichen Branchenregelungen gefragt ist; null für alles andere.
- Antworte ausschließlich mit JSON, ohne Erklärung:
{{"queries": [{{"text": "...", "stand_year": null, "scope": null}}]}}"""
@dataclass
class SubQuery:
text: str
stand_year: str | None = None # YYYY, wenn nach einem Geltungsjahr gefragt
scope: str | None = None # "gesetz" (ohne Branchen-KV) | "kv" | None
def should_plan(question: str) -> bool:
"""Heuristik-Gate: nur komplexe Fragen bekommen einen Planer-Call.
Signale: Jahreszahl, Vergleichs-/Aggregationsmarker, langer Text,
Mehrfach-Konjunktion. Einfache Fragen bleiben Single-Shot (Latenz)."""
q = question.strip()
if YEAR_RE.search(q):
return True
words = q.split()
if len(words) >= 14:
return True
if COMPARISON_RE.search(q):
return True
if AGGREGATION_RE.search(q):
return True
if " und " in q.casefold() and len(words) >= 10:
return True
return False
def parse_plan(raw: str, original: str) -> list[SubQuery]:
"""Robustes JSON-Parsing; jeder Fehler → [Originalfrage]."""
try:
match = JSON_RE.search(raw)
if not match:
raise ValueError("kein JSON-Objekt")
data = json.loads(match.group(0))
items = data.get("queries")
if not isinstance(items, list) or not items:
raise ValueError("leeres Plan-Array")
subs: list[SubQuery] = []
for item in items[:3]:
text = str(item.get("text", "")).strip()
if not text:
raise ValueError("leere Sub-Query")
year = item.get("stand_year")
year = str(year) if year and re.fullmatch(r"20\d{2}", str(year)) else None
scope = item.get("scope")
scope = scope if scope in ("gesetz", "kv") else None
subs.append(SubQuery(text=text, stand_year=year, scope=scope))
return subs
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return [SubQuery(text=original)]
def plan_queries(
question: str,
client,
cfg,
) -> tuple[list[SubQuery], bool]:
"""Liefert (Sub-Queries, geplant?) — Call-/Parse-Fehler → Original als
Einzel-Query. Der Planer-Call ist klein (Frage ohne Kontext, kurzes
num_predict); Temperature 0."""
if not should_plan(question):
return [SubQuery(text=question)], False
prompt = PLANNER_PROMPT.format(question=question.strip())
try:
raw = client.chat(
cfg.planner_model or cfg.answer_model,
[
{"role": "system", "content": "Du antwortest ausschließlich mit JSON."},
{"role": "user", "content": prompt},
],
temperature=0.0,
num_ctx=cfg.num_ctx,
num_predict=cfg.planner_num_predict,
think=False,
)
except Exception:
return [SubQuery(text=question)], False
return parse_plan(raw, question), True
+158 -17
View File
@@ -13,6 +13,7 @@ import numpy as np
from .config import Config from .config import Config
from .normalize import fts_query from .normalize import fts_query
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
from .query_planner import SubQuery
@dataclass @dataclass
@@ -71,6 +72,7 @@ class Retriever:
self._owns_client = client is None self._owns_client = client is None
self._mat: np.ndarray | None = None self._mat: np.ndarray | None = None
self._mat_chunk_ids: list[int] | None = None self._mat_chunk_ids: list[int] | None = None
self._scope_cache: dict[str, set[int]] = {}
row = self._con.execute("SELECT MIN(stand), MAX(stand) FROM chunks").fetchone() row = self._con.execute("SELECT MIN(stand), MAX(stand) FROM chunks").fetchone()
self._stand_min = int((row[0] or "2026-01").replace("-", "")) self._stand_min = int((row[0] or "2026-01").replace("-", ""))
self._stand_max = int((row[1] or "2026-01").replace("-", "")) self._stand_max = int((row[1] or "2026-01").replace("-", ""))
@@ -104,19 +106,38 @@ class Retriever:
# -- Einzelverfahren ---------------------------------------------------- # -- Einzelverfahren ----------------------------------------------------
def _bm25(self, question: str, limit: int) -> dict[int, float]: def _scope_ids(self, scope: str | None) -> set[int] | None:
"""Chunk-ID-Menge je Sub-Query-Scope (Cache je Instanz): 'gesetz' =
alles außer Branchen-KV, 'kv' = nur Branchen-KV."""
if scope is None:
return None
if scope not in self._scope_cache:
if scope == "kv":
sql = "SELECT chunk_id FROM chunks WHERE entry_id LIKE 'kv-%'"
else: # gesetz
sql = "SELECT chunk_id FROM chunks WHERE entry_id NOT LIKE 'kv-%'"
self._scope_cache[scope] = {
row[0] for row in self._con.execute(sql).fetchall()
}
return self._scope_cache[scope]
def _bm25(self, question: str, limit: int, scope: str | None = None) -> dict[int, float]:
q = fts_query(question) q = fts_query(question)
if not q: if not q:
return {} return {}
fetch = limit * 4 if scope else limit
rows = self._con.execute( rows = self._con.execute(
"SELECT rowid, bm25(chunks_fts) AS rank FROM chunks_fts " "SELECT rowid, bm25(chunks_fts) AS rank FROM chunks_fts "
"WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?", "WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?",
(q, limit), (q, fetch),
).fetchall() ).fetchall()
scope_ids = self._scope_ids(scope)
if scope_ids is not None:
rows = [r for r in rows if r["rowid"] in scope_ids][:limit]
# bm25(): kleinere Werte = besser -> negieren für "größer = besser" # bm25(): kleinere Werte = besser -> negieren für "größer = besser"
return {r["rowid"]: -float(r["rank"]) for r in rows} return {r["rowid"]: -float(r["rank"]) for r in rows}
def _dense(self, question: str, limit: int) -> dict[int, float]: def _dense(self, question: str, limit: int, scope: str | None = None) -> dict[int, float]:
if self.cfg.embed_off: if self.cfg.embed_off:
return {} return {}
self._ensure_matrix() self._ensure_matrix()
@@ -139,8 +160,17 @@ class Retriever:
if qn == 0: if qn == 0:
return {} return {}
sims = self._mat_norm @ (qvec / qn) sims = self._mat_norm @ (qvec / qn)
order = np.argsort(-sims)[:limit] order = np.argsort(-sims)
return {self._mat_chunk_ids[i]: float(sims[i]) for i in order} scope_ids = self._scope_ids(scope)
out: dict[int, float] = {}
for i in order:
if len(out) >= limit:
break
cid = self._mat_chunk_ids[i]
if scope_ids is not None and cid not in scope_ids:
continue
out[cid] = float(sims[i])
return out
def _ensure_matrix(self) -> None: def _ensure_matrix(self) -> None:
if self._mat is not None: if self._mat is not None:
@@ -240,46 +270,102 @@ class Retriever:
# -- öffentliche Suche -------------------------------------------------- # -- öffentliche Suche --------------------------------------------------
def search(self, question: str, n_entries: int | None = None) -> list[ChunkResult]: def _search_one(
"""Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung).""" self, sq: SubQuery, pool: int
n = n_entries or self.cfg.context_blocks ) -> tuple[dict[int, float], dict[int, float], dict[int, float]]:
pool = self.cfg.candidate_pool """BM25+Dense für EINE Sub-Query inkl. Scope-Filter (mit Fallback auf
bm = self._bm25(question, pool) unscoped, wenn die gefilterte Suche leer bleibt) und Query-RRF."""
try: try:
dn = self._dense(question, pool) bm = self._bm25(sq.text, pool, scope=sq.scope)
except Exception:
bm = {}
try:
dn = self._dense(sq.text, pool, scope=sq.scope)
except Exception: except Exception:
dn = {} dn = {}
fused: dict[int, float] = {} if sq.scope and not bm and not dn:
try:
bm = self._bm25(sq.text, pool)
dn = self._dense(sq.text, pool)
except Exception:
pass
fused_q: dict[int, float] = {}
for ranking, weight in ((bm, 1.0), (dn, self.cfg.dense_weight)): for ranking, weight in ((bm, 1.0), (dn, self.cfg.dense_weight)):
ordered = sorted(ranking.items(), key=lambda kv: -kv[1]) ordered = sorted(ranking.items(), key=lambda kv: -kv[1])
for rank, (cid, _) in enumerate(ordered): for rank, (cid, _) in enumerate(ordered):
fused[cid] = fused.get(cid, 0.0) + weight / (self.cfg.rrf_k + rank) fused_q[cid] = fused_q.get(cid, 0.0) + weight / (
self.cfg.rrf_k + rank
)
return bm, dn, fused_q
def _fuse_queries(
self, sub_queries: list, pool: int
) -> tuple[dict[int, float], set[int], set[int]]:
"""RRF-Fusion über BM25+Dense je Sub-Query (Beiträge summieren);
liefert (fused, bm25-Chunk-IDs, dense-Chunk-IDs) für die Quelle-
Markierung. Bei einer einzelnen Sub-Query identisch zum bisherigen
Verhalten."""
fused: dict[int, float] = {}
bm_all: set[int] = set()
dn_all: set[int] = set()
for sq in sub_queries:
bm, dn, fused_q = self._search_one(sq, pool)
bm_all.update(bm)
dn_all.update(dn)
for cid, score in fused_q.items():
fused[cid] = fused.get(cid, 0.0) + score
return fused, bm_all, dn_all
def _context_from_fused(
self,
fused: dict[int, float],
n: int,
bm_all: set[int],
dn_all: set[int],
stand_years: set[str] | None = None,
reserved: list[str] | None = None,
) -> list[ChunkResult]:
"""Fusion -> Score (+Recency +Temporal-Boost) -> Entry-Dedup ->
Vertreter-Chunk -> cross_ref-Erweiterung."""
if not fused: if not fused:
return [] return []
rows = self._fetch_chunks(list(fused)) rows = self._fetch_chunks(list(fused))
results: list[ChunkResult] = [] results: list[ChunkResult] = []
for cid, score in fused.items(): for cid, score in fused.items():
if cid not in rows: if cid not in rows:
continue continue
source = "fused" if (cid in bm and cid in dn) else ( source = (
"bm25" if cid in bm else "dense" "fused" if (cid in bm_all and cid in dn_all)
else ("bm25" if cid in bm_all else "dense")
) )
score += self.cfg.recency_boost * self._stand_factor(rows[cid]["stand"]) score += self.cfg.recency_boost * self._stand_factor(rows[cid]["stand"])
if stand_years and rows[cid]["entry_id"].startswith("kv-"):
if rows[cid]["stand"][:4] in stand_years:
score += self.cfg.temporal_boost
results.append(self._row_to_result(rows[cid], score, source)) results.append(self._row_to_result(rows[cid], score, source))
results.sort(key=lambda r: -r.score) 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, # Der Vertreter-Chunk ist die beste Inhaltssektion des Eintrags,
# nicht die Rangfolge-Beste (vgl. _section_priority). # nicht die Rangfolge-Beste (vgl. _section_priority).
# Per-Query-Slots (Multi-Query): reservierte Einträge zuerst —
# jeder Sub-Query-Aspekt hält garantiert seine Top-Quelle(n).
main: list[ChunkResult] = [] main: list[ChunkResult] = []
seen: set[str] = set()
by_entry: dict[str, list[ChunkResult]] = {} by_entry: dict[str, list[ChunkResult]] = {}
for r in results: for r in results:
by_entry.setdefault(r.entry_id, []).append(r) by_entry.setdefault(r.entry_id, []).append(r)
seen: set[str] = set()
reserved = reserved or []
for entry_id in reserved:
if len(main) >= n or entry_id not in by_entry:
continue
seen.add(entry_id)
main.append(self._representative_chunk(entry_id, by_entry[entry_id]))
for entry_id, chunks in by_entry.items(): for entry_id, chunks in by_entry.items():
if len(main) >= n: if len(main) >= n:
break break
if entry_id in seen:
continue
seen.add(entry_id) seen.add(entry_id)
main.append(self._representative_chunk(entry_id, chunks)) main.append(self._representative_chunk(entry_id, chunks))
@@ -298,3 +384,58 @@ class Retriever:
seen.add(ref) seen.add(ref)
budget -= 1 budget -= 1
return main + extra return main + extra
def search(self, question: str, n_entries: int | None = None) -> list[ChunkResult]:
"""Liefert die Top-Kontextblöcke (Hauptretrieval + cross_ref-Erweiterung)."""
n = n_entries or self.cfg.context_blocks
fused, bm_all, dn_all = self._fuse_queries(
[SubQuery(text=question)], self.cfg.candidate_pool
)
return self._context_from_fused(fused, n, bm_all, dn_all)
def search_multi(
self, sub_queries: list, n_entries: int | None = None
) -> list[ChunkResult]:
"""Multi-Query-Retrieval (Stufe 1): je Sub-Query BM25+Dense, RRF-
Beiträge summieren. Per-Query-Slots: jede Sub-Query sichert ihre
Top-Einträge im Kontext (Multi-Hop: jeder Frageaspekt kommt mit
seiner besten Quelle hinein — sonst dominieren Einträge, die in
mehreren Sub-Queries mittelgut matchen). Temporal-Intent: kv-
Einträge im gefragten Geltungsjahr erhalten temporal_boost."""
n = n_entries or self.cfg.context_blocks
if len(sub_queries) == 1:
return self.search(sub_queries[0].text, n_entries=n)
pool = self.cfg.candidate_pool
fused_total: dict[int, float] = {}
bm_all: set[int] = set()
dn_all: set[int] = set()
per_query_entries: list[list[str]] = []
for sq in sub_queries:
bm, dn, fused_q = self._search_one(sq, pool)
bm_all.update(bm)
dn_all.update(dn)
for cid, score in fused_q.items():
fused_total[cid] = fused_total.get(cid, 0.0) + score
rows_q = self._fetch_chunks(list(fused_q))
entry_best: dict[str, float] = {}
for cid, score in fused_q.items():
row = rows_q.get(cid)
if row is not None:
eid = row["entry_id"]
entry_best[eid] = max(entry_best.get(eid, 0.0), score)
per_query_entries.append(
[e for e, _ in sorted(entry_best.items(), key=lambda kv: -kv[1])]
)
reserved: list[str] = []
for entries in per_query_entries:
taken = 0
for eid in entries:
if taken >= self.cfg.per_query_slots:
break
if eid not in reserved:
reserved.append(eid)
taken += 1
years = {sq.stand_year for sq in sub_queries if sq.stand_year}
return self._context_from_fused(
fused_total, n, bm_all, dn_all, stand_years=years, reserved=reserved
)
+8
View File
@@ -298,4 +298,12 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
Präzision 97,6 %, Verweigerung 97,6 % (Gate ✓), erwartete Quelle Präzision 97,6 %, Verweigerung 97,6 % (Gate ✓), erwartete Quelle
91,9 %, Latenz mean 33 s. Offen: q-015 Branchen-Noise, q-024 91,9 %, Latenz mean 33 s. Offen: q-015 Branchen-Noise, q-024
transiente Flakiness (leerer Draft). transiente Flakiness (leerer Draft).
- **Komplexe-Fragen Stufe 1 (2026-09-15, M6/D12):** Query-Planer
(Heuristik-Gate → 1-3 Sub-Queries mit Stand-Jahr und Scope), Multi-
Query-Retrieval mit Per-Query-Slots und Scope-Filtern („gesetz“/„kv“).
Eval (46 Fragen): Zitier-Präzision 97,8 %, Verweigerung 97,8 %
(Gate ✓), erwartete Quelle 90,2 %, Latenz mean 33,5 s. Goldset 42 →
46 Fragen (q-110113). Offen für Stufe 2: Aggregation → Map-Reduce
(q-029), Rückfragen statt Verweigerung (API-first), danach
Rechtsprechung-Intake (`rj-*`, Lexis-md/json).
- Betrieb: `agent/README.md`. - Betrieb: `agent/README.md`.
+2
View File
@@ -181,6 +181,8 @@ def mini_cfg(tmp_path, mini_kb) -> Config:
kb_dir=str(mini_kb), kb_dir=str(mini_kb),
db_path=str(tmp_path / "index.db"), db_path=str(tmp_path / "index.db"),
embed_off=True, embed_off=True,
planner_enabled=False, # Tests skripten Chat-Antworten; Planer
# separat testbar (siehe test_generate/test_query_planner)
) )
+127
View File
@@ -0,0 +1,127 @@
"""Tests: Query-Planer (Stufe 1) — Heuristik-Gate, JSON-Parsing, Fallback,
Multi-Query-Fusion und Integration in answer_question."""
import json
import pytest
from agent.config import Config
from agent.generate import answer_question
from agent.query_planner import SubQuery, parse_plan, plan_queries, should_plan
from tests.conftest import FakeOllama
def test_should_plan_gate():
# Jahreszahl -> temporale Frage -> planen
assert should_plan("Wie hoch war der Mindestlohn für Friseure im Jahr 2024?")
# Vergleich
assert should_plan("Was ist der Unterschied zwischen Abfertigung neu und alt?")
# lang / mehrfach
assert should_plan(
"Wie wird die Überstundenpauschale behandelt und wie wirkt sie sich "
"auf die Sozialversicherung und die Lohnsteuer aus?"
)
# einfach -> Single-Shot
assert not should_plan("Wie viele Werktage Urlaub stehen Arbeitnehmern zu?")
assert not should_plan("Was ist Altersteilzeit?")
assert not should_plan("Wie hoch ist der KV-Mindestlohn im Friseurgewerbe?")
def test_parse_plan_valid_and_fallback():
subs = parse_plan(
'Vorab: {"queries": [{"text": "mindestlohn friseur", "stand_year": "2024"}, '
'{"text": "lohnberechnung friseur", "stand_year": null}]}',
original="Originalfrage?",
)
assert [s.text for s in subs] == ["mindestlohn friseur", "lohnberechnung friseur"]
assert subs[0].stand_year == "2024"
assert subs[1].stand_year is None
# Fallbacks: kaputtes JSON, leeres Array, leere Texte
for raw in ("kein json", '{"queries": []}', '{"queries": [{"text": ""}]}'):
subs = parse_plan(raw, original="Originalfrage?")
assert len(subs) == 1 and subs[0].text == "Originalfrage?"
# Ungueltiges Jahr -> None erzwingen, Text bleibt
subs = parse_plan(
'{"queries": [{"text": "x", "stand_year": "98"}]}', original="orig"
)
assert subs == [SubQuery(text="x", stand_year=None)]
def test_parse_plan_caps_at_three_queries():
raw = json.dumps(
{"queries": [{"text": f"q{i}"} for i in range(5)]}
)
subs = parse_plan(raw, original="orig")
assert len(subs) == 3
def test_plan_queries_simple_question_no_llm_call():
client = FakeOllama(answers=[]) # darf nicht aufgerufen werden
cfg = Config(planner_enabled=True)
subs, planned = plan_queries("Was ist Altersteilzeit?", client, cfg)
assert planned is False
assert subs == [SubQuery(text="Was ist Altersteilzeit?")]
assert client.calls == 0
def test_plan_queries_uses_planner_and_falls_back_on_error():
cfg = Config(planner_enabled=True)
client = FakeOllama(
answers=['{"queries": [{"text": "atz lohnausgleich"}, {"text": "atz altersteilzeitgeld", "stand_year": null}]}']
)
subs, planned = plan_queries(
"Wie funktioniert der Lohnausgleich bei Altersteilzeit und was ersetzt das AMS?", client, cfg
)
assert planned is True and len(subs) == 2
assert subs[0].text == "atz lohnausgleich"
# Fehler -> Originalfrage, geplant False
failing = FakeOllama(answers=[])
failing.chat = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline"))
subs, planned = plan_queries("Wie funktioniert der Lohnausgleich 2026?", failing, cfg)
assert planned is False
assert subs[0].text.startswith("Wie funktioniert")
def test_answer_question_planner_integration(mini_index):
"""Planer an: 1. Chat = Planer-JSON, 2. Chat = Antwort. Beide Sub-Queries
fusionieren in einer Retrieved-Menge (Zitier-Validierung ueber die Union)."""
cfg = Config(
kb_dir=mini_index.kb_dir,
db_path=mini_index.db_path,
embed_off=True,
planner_enabled=True,
planner_max_queries=2,
)
client = FakeOllama(answers=[
json.dumps({"queries": [
{"text": "altersteilzeit lohnausgleich", "stand_year": None},
{"text": "urlaubsanspruch", "stand_year": None},
]}),
"Antwort mit Beleg [lb-min-01] und [lb-min-02].",
])
result = answer_question(
"Wie funktioniert der Lohnausgleich bei Altersteilzeit und wie viel Urlaub bleibt?",
cfg, client=client,
)
assert client.calls == 2 # Planer + Antwort
assert result["verified"] is True
assert result["refused"] is False
assert set(result["citations"]) == {"lb-min-01", "lb-min-02"}
assert len(result["planned_queries"]) == 2
def test_search_multi_fuses_across_queries(mini_index):
"""Multi-Query-Retrieval: Sub-Queries summieren Beitraege; Treffer aus
beiden Themen erscheinen im Kontext."""
from agent.retrieve import Retriever
cfg = Config(kb_dir=mini_index.kb_dir, db_path=mini_index.db_path, embed_off=True)
r = Retriever(cfg)
subs = [SubQuery(text="Altersteilzeit Lohnausgleich"),
SubQuery(text="Urlaubsanspruch")]
results = r.search_multi(subs, n_entries=8)
ids = {res.entry_id for res in results}
assert {"lb-min-01", "lb-min-02"} <= ids # beide Themen vertreten
r.close()