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
+124
View File
@@ -0,0 +1,124 @@
"""CLI des PV RAG Agent.
python -m agent.cli ingest [--no-embed] Index (neu) aufbauen
python -m agent.cli ask "Frage?" [--top-k N] [--json]
python -m agent.cli eval [--answers] [--limit N] [--k 8] [--json-out FILE]
python -m agent.cli serve [--host 0.0.0.0]
"""
from __future__ import annotations
import argparse
import json
import sys
from .config import Config
def _cmd_ingest(args: argparse.Namespace, cfg: Config) -> int:
import dataclasses
from .ingest import build_index
client = None
if args.no_embed:
cfg = dataclasses.replace(cfg, embed_off=True)
elif not cfg.embed_off:
from .ollama_client import OllamaClient
client = OllamaClient(
cfg.ollama_url,
embed_timeout_s=cfg.embed_timeout_s,
chat_timeout_s=cfg.chat_timeout_s,
)
if not client.is_up():
print(
f"[warn] Ollama unter {cfg.ollama_url} nicht erreichbar — "
"Index wird BM25-only aufgebaut.",
file=sys.stderr,
)
try:
stats = build_index(cfg, client=client)
finally:
if client is not None:
client.close()
print(json.dumps(stats.as_dict(), indent=2, ensure_ascii=False))
if stats.embed_error:
print(f"[warn] {stats.embed_error}", file=sys.stderr)
return 0
def _cmd_ask(args: argparse.Namespace, cfg: Config) -> int:
from .generate import answer_question
try:
result = answer_question(args.question, cfg, top_k=args.top_k)
except Exception as e:
print(
f"[Fehler] Antwortgenerierung fehlgeschlagen: "
f"{type(e).__name__}: {e}\n"
f"Ollama erreichbar unter {cfg.ollama_url}? 'curl {cfg.ollama_url}/api/tags'",
file=sys.stderr,
)
return 2
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
print(result["answer"])
if result["sources"]:
print("\nQuellen:")
for s in result["sources"]:
print(f" - {s['id']}{s['title']} ({s['stand']})")
status = "VERWEIGERT" if result["refused"] else (
"OK" if result["verified"] else "UNVERIFIZIERT"
)
print(
f"\n[{status} · {result['model']} · {result['latency_ms']} ms · "
f"{result['n_context']} Kontextblöcke]"
)
return 0
def _cmd_eval(args: argparse.Namespace, cfg: Config) -> int:
from .eval.evaluate import run_eval
return run_eval(cfg, args)
def _cmd_serve(args: argparse.Namespace, cfg: Config) -> int:
import uvicorn
uvicorn.run("agent.api:app", host=args.host, port=cfg.port, log_level="info")
return 0
def main(argv: list[str] | None = None) -> int:
cfg = Config.from_env()
parser = argparse.ArgumentParser(prog="agent.cli", description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
p_ingest = sub.add_parser("ingest", help="Index (neu) aufbauen")
p_ingest.add_argument("--no-embed", action="store_true",
help="Keine Embeddings erzeugen (BM25-only)")
p_ask = sub.add_parser("ask", help="Frage stellen")
p_ask.add_argument("question")
p_ask.add_argument("--top-k", type=int, default=None)
p_ask.add_argument("--json", action="store_true")
p_eval = sub.add_parser("eval", help="Goldset-Evaluation")
p_eval.add_argument("--answers", action="store_true",
help="inkl. Antwortgenerierung (benötigt Ollama)")
p_eval.add_argument("--limit", type=int, default=None)
p_eval.add_argument("--k", type=int, default=8, help="K für Recall@k")
p_eval.add_argument("--json-out", default=None)
p_serve = sub.add_parser("serve", help="HTTP-API starten")
p_serve.add_argument("--host", default="127.0.0.1")
args = parser.parse_args(argv)
handlers = {
"ingest": _cmd_ingest,
"ask": _cmd_ask,
"eval": _cmd_eval,
"serve": _cmd_serve,
}
return handlers[args.cmd](args, cfg)
if __name__ == "__main__":
raise SystemExit(main())