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:
@@ -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_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_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 |
|
||||
@@ -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,
|
||||
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).
|
||||
|
||||
## Dateien
|
||||
|
||||
```
|
||||
|
||||
@@ -73,6 +73,16 @@ class Config:
|
||||
# (BM25 ist durch KV-§-Titel-Matches inflationiert)
|
||||
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
|
||||
port: int = 8080
|
||||
|
||||
@@ -100,5 +110,11 @@ class Config:
|
||||
rrf_k=_env_int("PV_RRF_K", d.rrf_k),
|
||||
dense_weight=_env_float("PV_DENSE_WEIGHT", d.dense_weight),
|
||||
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),
|
||||
)
|
||||
+21
-1
@@ -144,4 +144,24 @@ questions:
|
||||
- id: r-005
|
||||
question: "Wie hoch ist der kollektivvertragliche Mindestlohn im KV für Raumfahrttechnik?"
|
||||
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
@@ -13,6 +13,7 @@ import time
|
||||
from .config import Config
|
||||
from .normalize import normalize_text
|
||||
from .ollama_client import OllamaClient
|
||||
from .query_planner import SubQuery, plan_queries
|
||||
from .retrieve import ChunkResult, Retriever
|
||||
|
||||
REFUSAL_MESSAGE = "Dazu enthält die Wissensbasis keine Aussage."
|
||||
@@ -138,13 +139,33 @@ def answer_question(
|
||||
retriever: Retriever | None = None,
|
||||
top_k: int | None = None,
|
||||
) -> 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()
|
||||
own_retriever = retriever is None
|
||||
if retriever is None:
|
||||
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:
|
||||
results = retriever.search(question, n_entries=top_k)
|
||||
results = retriever.search_multi(sub_queries, n_entries=top_k)
|
||||
finally:
|
||||
if own_retriever:
|
||||
retriever.close()
|
||||
@@ -164,6 +185,10 @@ def answer_question(
|
||||
"regenerations": regenerations,
|
||||
"latency_ms": round((time.perf_counter() - t0) * 1000),
|
||||
"draft": draft,
|
||||
"planned_queries": [
|
||||
{"text": sq.text, "stand_year": sq.stand_year}
|
||||
for sq in sub_queries
|
||||
],
|
||||
}
|
||||
|
||||
if not results:
|
||||
@@ -173,12 +198,6 @@ def answer_question(
|
||||
allowed = [r.entry_id 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 = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_user_content(question, results)},
|
||||
|
||||
@@ -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
|
||||
+159
-18
@@ -13,6 +13,7 @@ import numpy as np
|
||||
from .config import Config
|
||||
from .normalize import fts_query
|
||||
from .ollama_client import OllamaClient
|
||||
from .query_planner import SubQuery
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -71,6 +72,7 @@ class Retriever:
|
||||
self._owns_client = client is None
|
||||
self._mat: np.ndarray | 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()
|
||||
self._stand_min = int((row[0] or "2026-01").replace("-", ""))
|
||||
self._stand_max = int((row[1] or "2026-01").replace("-", ""))
|
||||
@@ -104,19 +106,38 @@ class Retriever:
|
||||
|
||||
# -- 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)
|
||||
if not q:
|
||||
return {}
|
||||
fetch = limit * 4 if scope else limit
|
||||
rows = self._con.execute(
|
||||
"SELECT rowid, bm25(chunks_fts) AS rank FROM chunks_fts "
|
||||
"WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?",
|
||||
(q, limit),
|
||||
(q, fetch),
|
||||
).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"
|
||||
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:
|
||||
return {}
|
||||
self._ensure_matrix()
|
||||
@@ -139,8 +160,17 @@ class Retriever:
|
||||
if qn == 0:
|
||||
return {}
|
||||
sims = self._mat_norm @ (qvec / qn)
|
||||
order = np.argsort(-sims)[:limit]
|
||||
return {self._mat_chunk_ids[i]: float(sims[i]) for i in order}
|
||||
order = np.argsort(-sims)
|
||||
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:
|
||||
if self._mat is not None:
|
||||
@@ -240,46 +270,102 @@ class Retriever:
|
||||
|
||||
# -- öffentliche Suche --------------------------------------------------
|
||||
|
||||
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
|
||||
pool = self.cfg.candidate_pool
|
||||
bm = self._bm25(question, pool)
|
||||
def _search_one(
|
||||
self, sq: SubQuery, pool: int
|
||||
) -> tuple[dict[int, float], dict[int, float], dict[int, float]]:
|
||||
"""BM25+Dense für EINE Sub-Query inkl. Scope-Filter (mit Fallback auf
|
||||
unscoped, wenn die gefilterte Suche leer bleibt) und Query-RRF."""
|
||||
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:
|
||||
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)):
|
||||
ordered = sorted(ranking.items(), key=lambda kv: -kv[1])
|
||||
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:
|
||||
return []
|
||||
|
||||
rows = self._fetch_chunks(list(fused))
|
||||
results: list[ChunkResult] = []
|
||||
for cid, score in fused.items():
|
||||
if cid not in rows:
|
||||
continue
|
||||
source = "fused" if (cid in bm and cid in dn) else (
|
||||
"bm25" if cid in bm else "dense"
|
||||
source = (
|
||||
"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"])
|
||||
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.sort(key=lambda r: -r.score)
|
||||
|
||||
# Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup).
|
||||
# Der Vertreter-Chunk ist die beste Inhaltssektion des Eintrags,
|
||||
# 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] = []
|
||||
seen: set[str] = set()
|
||||
by_entry: dict[str, list[ChunkResult]] = {}
|
||||
for r in results:
|
||||
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():
|
||||
if len(main) >= n:
|
||||
break
|
||||
if entry_id in seen:
|
||||
continue
|
||||
seen.add(entry_id)
|
||||
main.append(self._representative_chunk(entry_id, chunks))
|
||||
|
||||
@@ -297,4 +383,59 @@ class Retriever:
|
||||
extra.append(er)
|
||||
seen.add(ref)
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user