feat(stt-server): grounding RAG — le cerveau répond depuis la doc Funk (#27)

Sans contexte, le modèle (qwen3-8b) hallucinait sur le homelab (« stockage
décentralisé », « worker nodes »…) car il ignore tout du cluster réel. Le
cerveau interroge désormais la collection funk-docs (la doc admin/ indexée) et
injecte les passages pertinents au prompt → il répond à partir de la vraie doc.

Coût ~nul : funk-docs et stt-memory partagent le même embedder nomic (:1238,
768 dim) → le vecteur de requête calculé pour le recall mémoire est réutilisé
pour la recherche doc (une embed, deux recherches). Dégrade en silence si
Qdrant/embed down (réponse sans doc plutôt qu'échec).

Module knowledge.py (STT_DOCS_RAG, TOPK=6, MIN_SCORE=0.45). Log /v1/ask
expose docs=<n>. Serveur 0.2.1 → 0.3.0.

Validé : « c'est quoi le nom des nœuds ? » → avant « worker/master nodes »
(inventé) ; après « Kubernetes ; nœuds storage-01, compute-01, compute-02 »
(tiré de la doc).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-19 21:53:30 +02:00 committed by GitHub
parent 767a94b1d0
commit 1db84e9041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 132 additions and 6 deletions

View file

@ -19,6 +19,7 @@ from stt_server import __version__
from stt_server import brain
from stt_server.brain import ask as brain_ask
from stt_server.config import settings
from stt_server.knowledge import Knowledge
from stt_server.longterm import LongTermMemory
from stt_server.memory import SessionStore
@ -34,6 +35,7 @@ if not log.handlers:
sessions = SessionStore()
longterm = LongTermMemory() if settings.memory_longterm else None
knowledge = Knowledge() if settings.docs_rag else None
@asynccontextmanager
@ -43,6 +45,8 @@ async def lifespan(app: FastAPI):
await brain.aclose()
if longterm:
await longterm.aclose()
if knowledge:
await knowledge.aclose()
app = FastAPI(title="STT-server", version=__version__, lifespan=lifespan)
@ -97,11 +101,13 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
)
t0 = time.perf_counter()
history = sessions.history(req.session_id) if req.session_id else None
# recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé par store)
# recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé ci-dessous)
memories, qvec = await longterm.recall(text) if longterm else ([], None)
# RAG doc : on réutilise qvec (même embedder nomic) → ancre la réponse dans funk-docs
docs = await knowledge.search(text, qvec) if knowledge else []
t_recall = time.perf_counter()
try:
reply = await brain_ask(text, model, history, memories)
reply = await brain_ask(text, model, history, memories, docs)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
t_gen = time.perf_counter()
@ -112,12 +118,13 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
if longterm:
background.add_task(longterm.store, req.session_id or "anon", text, qvec)
log.info(
"ask model=%s recall=%.0fms gen=%.0fms total=%.0fms mem=%d",
"ask model=%s recall=%.0fms gen=%.0fms total=%.0fms mem=%d docs=%d",
model,
(t_recall - t0) * 1000,
(t_gen - t_recall) * 1000,
(t_gen - t0) * 1000,
len(memories),
len(docs),
)
return AskReply(reply=reply, model=model)