depiedencap-rag/docs/rag/PIPELINE.md

10 KiB
Raw Blame History

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é.