Apogée Consult
Retour au blog
Jules Ginhac
Jules GinhacCo-Founder & ingénieur IA

Je suis Jules Ginhac, Co-Founder & ingénieur IA chez Apogée Consult à Lyon. Je conçois et déploie des architectures IA génératives (RAG, agents, LLMOps) pour des PME, startups et organisations publiques.

Post-mortem : pourquoi notre RAG retournait du contenu hors-sujet 1 fois sur 4

  • rag

Analyse d'un incident RAG en production : 25 % de réponses hors-sujet, cause racine identifiée, correctifs déployés. Ce que ce post-mortem a changé dans nos pratiques.

Post-mortem : pourquoi notre RAG retournait du contenu hors-sujet 1 fois sur 4

Six semaines après le passage en production d'un assistant documentaire pour un client dans le secteur de la formation professionnelle, les retours terrain montrent un taux de réponses non pertinentes autour de 25 %. Le client l'a découvert en interne avant que nos métriques ne l'alertent. Mauvais signal.

Ce post-mortem documente la séquence d'investigation, les causes identifiées et les correctifs déployés. L'objectif n'est pas d'excuser la situation mais de rendre les erreurs reproductibles, pour les éviter sur le projet suivant.

Contexte du système

Le RAG indexait une base documentaire de 1 200 fichiers : conventions de formation, catalogues de stages, fiches RNCP, guides administratifs CPF. Format mixte : PDF exportés depuis Word, HTML exportés depuis un CMS, quelques fichiers texte brut.

Pipeline au moment de l'incident :

  • Embedding : text-embedding-ada-002
  • Store : Pinecone (plan starter)
  • Chunking : 1000 tokens / 200 overlap, RecursiveCharacterTextSplitter
  • Retrieval : top-5 par similarité cosinus
  • LLM : GPT-4o, prompt système de 800 tokens environ

Pas de reranking. Pas d'évaluation automatisée en production. Monitoring limité aux logs d'erreur applicatifs.

Chronologie

J+0 : go-live, tests manuels sur 30 requêtes, tout semble correct.

J+14 : premier retour client informel ("parfois les réponses parlent d'autres formations que celle qu'on cherche").

J+28 : le client remonte 12 exemples concrets. On commence l'investigation.

J+35 : cause racine identifiée, première correction déployée.

J+42 : taux de hors-sujets mesuré à 6 % sur les mêmes types de requêtes.

Investigation : les trois hypothèses initiales

Hypothèse 1 : contamination cross-tenant

Le client partageait l'index Pinecone avec un autre projet en développement. On a vérifié en premier : tous les vecteurs étaient correctement namespacés. Hypothèse invalidée.

Hypothèse 2 : hallucination LLM

On a isolé la couche retrieval en loguant les chunks remontés avant de passer au LLM. Sur les 12 exemples fournis par le client, les chunks retrieval étaient déjà hors-sujet dans 10 cas. Le problème était en amont du LLM. Hypothèse invalidée pour ce cas.

Hypothèse 3 : qualité du retrieval

On a rejoué les requêtes problématiques et inspecté les chunks retournés. Deux patterns distincts ont émergé.

Cause racine 1 : ambiguïté lexicale dans l'espace embedding

Les requêtes du type "formation management équipe" remontaient des chunks de fiches RNCP liées au management de projet informatique et au management de production industrielle, alors que l'utilisateur cherchait des formations en soft skills managériaux.

Le terme "management" est sémantiquement dense dans l'espace d'embedding. La similarité cosinus ne distingue pas les sous-domaines quand le reste de la requête est court.

Sur ces requêtes courtes (3 à 5 tokens), la distance entre le vecteur de la requête et des chunks de domaines différents était inférieure à 0,08, en dessous du bruit de mesure utile.

# Ce qu'on faisait
results = index.query(
    vector=embed(query),
    top_k=5,
    include_metadata=True
)

# Ce qu'on aurait dû faire dès le départ
# Filtrage metadata pour restreindre au domaine déclaré par l'utilisateur
results = index.query(
    vector=embed(query),
    top_k=10,  # plus large pour compenser le filtre
    filter={"domain": user_context["domain"]},  # metadata injectée à l'ingestion
    include_metadata=True
)

Le correctif principal : ajouter un champ domain à chaque chunk à l'ingestion (catégorie de formation, code CPF famille de métiers) et filtrer systématiquement par ce champ quand le contexte utilisateur le permet.

Cause racine 2 : chunks orphelins issus des en-têtes de PDF

Le parseur extrayait les en-têtes de page (nom de l'organisme, numéro de page, titre de section répété) comme du contenu normal. Ces fragments produisaient des chunks de 40 à 80 tokens qui se retrouvaient en index.

Un chunk typique ressemblait à :

RNCP 36004, Responsable de Projet
Version 1.2, Mise à jour janvier 2024
Page 3 / 12
Bloc de compétences 2 : Piloter...

Ce chunk avait une similarité élevée avec des requêtes mentionnant "RNCP" ou "responsable de projet", sans porter aucune information utile sur les blocs de compétences en question.

Le correctif : filtrage post-extraction des chunks dont la densité informationnelle est trop faible. On a utilisé une heuristique simple, ratio nombre de mots uniques / nombre de tokens total. En dessous de 0,4, le chunk est écarté.

def is_informative(chunk_text: str, threshold: float = 0.4) -> bool:
    tokens = chunk_text.lower().split()
    if len(tokens) < 20:
        return False
    unique_ratio = len(set(tokens)) / len(tokens)
    return unique_ratio >= threshold

Ce filtre n'est pas parfait, il écarte aussi des chunks légitimement répétitifs comme des tableaux de valeurs numériques. On l'a calibré sur notre corpus pour minimiser les faux positifs.

Ce que ça a changé dans nos pratiques

Trois décisions durables issues de cet incident :

Évaluation offline avant go-live. On constitue maintenant un jeu de 100 requêtes de test représentatives avant chaque déploiement, avec les réponses attendues annotées manuellement. On mesure le recall@5 et on fixe un seuil minimal. Pas de go-live en dessous.

Logging structuré du retrieval en production. Chaque appel RAG logue les chunks retrieval avec leur score de similarité, le query original et un identifiant de session. Ce log est analysable sans rejouer les requêtes. Un dashboard simple suffit à détecter une dérive du score moyen de similarité.

Metadata de domaine systématique à l'ingestion. Tout chunk indexé reçoit des métadonnées fonctionnelles (domaine, type de document, date de validité). Le filtrage par metadata est activé dès que le contexte utilisateur le permet.

Ce qui n'a pas été corrigé

Le retrieval sur des requêtes très courtes (1 à 2 mots) reste fragile. La similarité cosinus sur un espace sémantique dense ne discrimine pas assez. On a commencé à évaluer un hybrid search BM25 + embeddings pour ces cas, mais ce n'est pas encore en production sur ce projet.

La question qui reste posée : à quel point peut-on se fier à une évaluation offline sur 100 requêtes pour prédire le comportement sur des milliers d'utilisateurs réels avec des formulations qu'on n'a pas anticipées ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Post-mortem RAG production : hors-sujet 1 fois sur 4 | Apogée Consult