M1+M2: RAG-Pipeline mit verbindlichem Grounding

agent/-Paket: Ingest (601 Layer-2-Eintraege -> 3005 Chunks, FTS5-BM25 +
Vektoren-Cache), Hybrid-Retrieval (RRF, Stand-Boost, cross_ref-Erweiterung),
Ollama-Client (embed/chat, think-Flag-Fallback, kurzes Connect-Budget),
Systemprompt mit Zitierpflicht, Post-Validierung (zitierte IDs gemaess
Retrieved-Set, 1x Regenerierung, dann Verweigerung), FastAPI (/ask, /health,
/reindex), CLI, Goldset (31 Fragen, IDs gegen kb.json verifiziert, inkl.
ATZ-Konfliktfall + 4 Verweigerungsfaelle), Eval-Suite, Test-Chat.

41 Offline-Tests gruen. Baseline BM25-only: Hit-Rate 0,871 / Recall@8 0,855 /
MRR 0,476. Hybrid-Messung, Antwortmodus-Eval und Modell-Bake-off (M3) auf
dem Host ausstaendig (Ollama aus der Zed-Sandbox nicht erreichbar).

MEMORY.md und planung.md Umsetzungsstand aktualisiert.
This commit is contained in:
2026-09-14 16:53:04 +02:00
parent bf8191b013
commit 2cba72aeb0
26 changed files with 2574 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
"""Deutsch-affine Textnormalisierung für FTS5-Index und -Anfragen.
Konvention (einmalig, konsistent): lowercase, Diakritika via NFKD entfernen
(ä→a, ü→u), ß→ss. Dies gilt für die FTS-Spalte `norm` und die Query gleich.
Die ASCII-Slug-Konvention der Wissensbasis (Umlaute "fallen") betrifft nur
`topic`/`tags`/Dateinamen, nicht die Volltextsuche.
"""
from __future__ import annotations
import re
import unicodedata
# Stopwörter in normalisierter Form (nach Fold), klein halten.
STOPWORDS = frozenset(
"""der die das und oder ein eine einen einem einer eines den dem des ist im in
von fur mit auf zu zum zur an am beim wie was wann wird werden kann muss
sind hat nicht man als auch aus bei sein ihre ihr es sie er doch noch nur
schon sehr mehr hier da durch fuer wird""".split()
)
_TOKEN_RE = re.compile(r"[a-z0-9]+")
def normalize_text(s: str) -> str:
"""Lowercase, Diakritika entfernen (NFKD), ß→ss."""
s = unicodedata.normalize("NFKD", s.casefold())
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
return s.replace("ß", "ss")
def tokenize(s: str) -> list[str]:
return _TOKEN_RE.findall(normalize_text(s))
def fts_query(question: str, min_len: int = 2) -> str:
"""OR-verknüpfte FTS5-Phrasen aus normalisierten Termen; '' wenn leer."""
terms: list[str] = []
seen: set[str] = set()
for t in tokenize(question):
if len(t) >= min_len and t not in STOPWORDS and t not in seen:
seen.add(t)
terms.append(t)
return " OR ".join(f'"{t}"' for t in terms)