depiedencap-rag/docs/rag/PIPELINE.md

207 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Pipeline RAG — de la base Postgres à la réponse du bot
Document d'architecture : le chemin complet d'une question utilisateur,
des données brutes du forum jusqu'à la réponse citée. Voir `DESIGN.md`
pour les choix structurants et `EVAL.md` pour les métriques mesurées.
## Vue d'ensemble
```
POSTGRES (posts, topics) ← réplique/prod Discourse
│
│ [offline, une fois par contenu]
▼
bge-m3 → vecteur 1024-d ← modèle d'embedding (Ollama)
│ ai_posts_embeddings (pgvector)
│
QUESTION UTILISATEUR ← à la volée
│
├─► ÉTAGE 0 — gate d'entrée (optionnel, déterministe)
│ regex sur l'INTENTION (pas les sujets) → déclinaison immédiate
│ si danger/illégal/détournement détecté — voir SAFETY.md
│
├─► ÉTAGE 1 — rappel (recall)
│ question → bge-m3 → cosine sur 425k vecteurs post → top-25
│ (+ canal keyword BM25 / semantic topic en fusion RRF)
│
├─► ÉTAGE 2 — précision (rerank)
│ cross-encoder mmarco : score (question, candidat) ×25 → re-tri
│
├─► agrégation : dédup par topic → top-5/6 sources
│ + zone graduée sur score CE top-1 :
│ < −4 → bloc « evidence vide » (abstention)
│ [−4, 0) → bloc WEAK (sources + « assume la faiblesse »)
│ ≥ 0 → bloc normal « STABLE FORUM EVIDENCE »
│ (config D : ± voisins du post pour le contexte)
│
▼
PROMPT système = persona + clause refus (SAFETY.md)
│ + bloc evidence : titres + extraits + règles de citation
│ [[1]]..[[6]] strictes + « l'evidence n'élargit pas ton périmètre »
▼
LLM (mini PC) → réponse rédigée + marqueurs [[n]]
│
▼
Sanitizer serveur : [[n]] → URLs /t/slug/id ← liens générés côté serveur,
jamais par le LLM
```
## Les modèles en jeu
| Modèle | Type | Rôle | Où ça tourne |
|---|---|---|---|
| **bge-m3** | bi-encoder (embedding) | texte → vecteur 1024-dim. Encode chaque post **une fois** (backfill) et chaque question **à la volée** | Ollama — RTX 2060 laptop en dev ; Ollama mini PC `:32134` en prod |
| **cross-encoder mmarco-mMiniLMv2-L12-H384** | cross-encoder | score de pertinence d'une **paire** (question, candidat) — lit les deux ensemble | dev : `sentence-transformers` CPU laptop ; **prod : TEI `/rerank` sur le mini PC** (pod k3s `reranker`, NodePort 32136, Tailscale only) |
| **LLM chat `qwen3:30b-a3b-q6k`** | génératif (MoE ~3B actifs) | rédige la réponse à partir des 6 sources fournies **+** réécriture de requête multi-tour (`condense_query`, `think:false`) | mini PC (Ollama `:32134`) — **un seul modèle résident** pour les deux rôles |
| Postgres/pgvector | — | stockage + distance cosine/hamming | conteneur Discourse (VPS en prod) |
### Pourquoi un MoE 30B sur un iGPU modeste — le choix structurel
La iGPU 780M du mini PC n'a **pas de VRAM dédiée** : les poids vivent dans
la RAM unifiée (GTT, plusieurs dizaines de Gio). Deux conséquences :
- **Capacité** : un 30B quantifié (~20+ Gio) tient là où une dGPU du même
format plafonnerait à 8-16 Go de VRAM — le facteur limitant n'est pas la
VRAM mais la RAM système, dont on a 48 Go.
- **Vitesse** : l'inférence sur iGPU est **bound par la bande passante
mémoire** (chaque token relit les poids actifs). Un MoE n'active que
~3B paramètres par token → le débit reste élevé malgré la taille totale
du modèle. Mesuré : réécriture de requête en **~1.0 s** à chaud vs 1.6 s
pour un llama3.1:8B dense — le « gros » modèle est plus rapide que le
petit, **parce qu'il est MoE**.
C'est pourquoi la réécriture de requête a basculé de `llama3.1:8b` vers le
LLM principal (D10, 17/09) : plus rapide, qualité égale ou meilleure, et un
modèle résident de moins à garder chaud en RAM.
### Bi-encoder vs cross-encoder — pourquoi les deux
Ce ne sont **pas des concurrents** mais deux étages complémentaires :
- **bge-m3 (bi-encoder)** : question et post sont encodés **séparément**.
Le vecteur du post est pré-calculé → comparer 425k vecteurs = quelques ms.
Mais le modèle ne « voit » jamais la paire ensemble : la pertinence est
indirecte (proximité géométrique).
- **cross-encoder** : prend la paire `(question, candidat)` **concaténée**
en entrée → jugement de pertinence direct, mot à mot. Plus précis, mais
impossible à pré-calculer : l'utiliser seul coûterait 425k scorings par
question. Il ne sert donc qu'à **re-trier le top-25** fourni par pgvector.
D'où : cosine pour le **rappel** (ne rien rater), cross-encoder pour le
**classement fin** (bien ordonner). Le cosine n'est jamais abandonné.
## Latence mesurée (i5-10300H laptop)
| Étape | Coût |
|---|---|
| embedding question (bge-m3) | ~ms (un vecteur) |
| cosine pgvector top-25 sur 425k lignes | ~ms (index HNSW/scan) |
| rerank cross-encoder, 25 paires | **~75-100 ms** (~3-4 ms/paire) |
| chargement modèle (une fois, au boot) | ~5 s |
| génération LLM | **plusieurs secondes** (goulot réel) |
→ Le rerank ajoute ~100 ms par requête : **négligeable** face à la
génération LLM. En prod il tourne sur le **mini PC** (pod k3s
`rag-depiedencap/reranker`, TEI `cpu-1.6`, NodePort 32136 — pas sur le
VPS dont la RAM est tendue ~300-400 Mio libres). Vérifié : les logits
TEI/ONNX sont **identiques** à ceux de `sentence-transformers` PyTorch
→ les seuils mesurés en éval (-4 / 0) restent valides en prod, le plugin
reconvertit le score sigmoïdé TEI en logit.
## Étapes détaillées
### 1. Indexation (offline)
Chaque post éligible → `ai_posts_embeddings` (une ligne par post) :
- texte embeddé = `titre du fil + catégorie + tags + contenu du post`
(stratégie native `post_truncation`, `strategy_id=1`, tronqué à
`max_sequence_length - 2`)
- le **grain post** est la décision structurante : un fil de 2 700 posts
a un vecteur topic dilué et invisible, alors que ses posts individuels
restent nets (mesuré : Hit@5 0.68 → 0.94, cf. `EVAL.md`)
- rejeté après mesure : texte enrichi contexte OP/parent (`strategy_id=2`,
-9 pts Hit@5 — dilue le post, §4quater EVAL.md)
### 2. Retrieval (par question)
- **posts** : cosine direct sur `ai_posts_embeddings` → top-25 posts
- **keyword** : `Search.execute` (BM25 Discourse) sur les mots distinctifs
- **semantic** : `SemanticSearch` natif sur embeddings de topics
- **hybrid (fallback prod)** : fusion custom `Retrieval.retrieve` +
gate titre + boosts — l'état de prod avant ce chantier, conservé
comme chemin de repli (`depiedencap_ai_citations_posts_pipeline=false`
ou table posts vide ou erreur)
- **fusions RRF** : `Σ 1/(60+rang)` sur les listes — posts×hybrid est la
meilleure combinaison mesurée (+0.01 MRR vs posts seul)
- rejeté : routing par taille de fil (le RRF route déjà implicitement)
### 3. Rerank (config E)
Top-25 posts → `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` score
`(question, titre + extrait 300c)` → re-tri → dédup par topic.
Mesuré : Hit@5 0.94 → **0.97**, MRR 0.84 → **0.89** (avec fusion hybrid).
### 4. Génération (évaluée — `run_full_eval.py`, vrai LLM mini PC)
- les 6 meilleures sources → prompt `STABLE FORUM EVIDENCE` avec règles
strictes (citer `[[n]]`, ne jamais écrire d'URL, abstention si vide)
- **trois zones d'evidence** selon le score CE top-1 (EVAL.md §4.4) :
abstention `< −4`, cadrage prudent `[−4, 0)`, normal `≥ 0`
- **clause de refus** dans le system prompt + rappel de limite de rôle
dans le template d'evidence (SAFETY.md — 20/20 sondes)
- le LLM rédige ; le sanitizer résout `[[n]]` → `/t/slug/id` **côté
serveur** — le LLM ne peut pas inventer de lien
- mesuré : citations valides, abstention OOD, refus/abus, injection
d'evidence, régression qualité (A/B n=133 — aucune dégradation hors
zone faible)
## Carte des fichiers
| Quoi | Où |
|---|---|
| Retrieval prod (fusion, gate, prompt) | `discourse_plugin/depiedencap-ai-citations/lib/retrieval.rb` |
| Backfill enrichi (config C, rejetée) | `rag/eval/backfill_enriched.rb` |
| Éval retrieval (dans le conteneur) | `rag/eval/eval_retrieval.rb` |
| Harness métriques + fusions + rerank | `rag/eval/run_eval.py` |
| Éval génération bout-en-bout | `rag/eval/eval_full.rb` + `run_full_eval.py` |
| Jeu de questions + goldens | `rag/eval/questions.yml` |
| Sondes sécurité (refus/injection) | `rag/eval/safety.yml` |
| Gate d'entrée (règles regex) | `rag/eval/input_gate.yml` |
| Variantes de system prompt | `rag/eval/prompt_clauses/` |
| Résultats horodatés | `rag/eval/results/*.json` |
| Mesures et décisions | `rag/EVAL.md` + `rag/SAFETY.md` |
| Environnement forum local | `rag/LOCAL_DEV.md` |
| Snapshots prod pré-déploiement | `rag/prod_snapshots/` |
## Portage production (15/09/2026)
Le canal v2 vit dans `Retrieval` derrière deux site settings
(`depiedencap_ai_citations_posts_pipeline`, `depiedencap_ai_citations_input_gate`,
les deux **false par défaut** → le déploiement du code ne change rien tant
que les flags ne sont pas activés) :
- `retrieve_posts_pack` : `vector_from(question)` (bge-m3 mini PC) → cosine
`ai_posts_embeddings` top-25 → `POST /rerank` (mini PC :32136) → dédup
topic → 6 sources avec URL grain post `/t/slug/id/post_number`
- **exclusions ajoutées vs éval** (prod seulement) : `post_type=regular`,
posts/topics non supprimés, `archetype='regular'` (pas de MP),
catégories `read_restricted` exclues — le canal legacy bénéficiait de
`Guardian`/`Search`, le SQL direct doit filtrer soi-même
- **gate d'entrée** : `Retrieval.gate_match` dans `PlaygroundHook`
(reply_to + schedule_bot_reply) → `publish_canned_reply!` poste la
réponse fixe sans LLM ; marquée `depiedencap_ai_citations=t` → le
sanitizer la saute
- **fallbacks mesurés** : flag off → legacy ; `ai_posts_embeddings` vide
→ legacy (garde `posts_table_ready?`, cache 60 s) ; endpoint
embeddings down → legacy ; reranker down → ordre cosine seul, zone
`:ok` (config B mesurée 0.94)
- déploiement : `docker cp` plugin → conteneur + `/shared/…` + `sv
restart unicorn` (Ruby only, pas de precompile)
Le backfill posts natif (`Jobs::EmbeddingsBackfill`, 5 min, queue `low`)
a été activé avec `ai_embeddings_per_post_enabled=true` +
`ai_embeddings_backfill_batch_size` monté temporairement — il produit du
`(model_id=1, strategy_id=1, strategy_version=1)`, exactement la
stratégie évaluée gagnante. Le flip des flags n'intervient qu'une fois
le backfill terminé.