En bref. Mon RAG de nutrition féline refusait 9 fois sur 12 des questions pourtant couvertes par sa base, et inventait des faits sur des sujets absents. Ce n’était pas le prompt : un bug d’alignement dans l’indexation avait attaché à de nombreux chunks le vecteur de leur voisin, sur un index qui paraissait complet. Réparé, il restait trois problèmes mesurables : l’échelle du score Upstash, un seuil qui ne sépare pas les sujets voisins, une recherche approchée qui rate le meilleur chunk. Mesuré sur le vrai index avant la fusion : 39 questions couvertes sur 39 correctes en local, 38 sur 38 sur la preview déployée, 57 à 60 réponses sur 64 correctes hors base. Les corrections (PR #26) sont fusionnées depuis le 19 octobre 2026. Une évaluation complète rejouée ensuite (52 questions) est moins flatteuse : 90 % de comportement correct sur les questions couvertes, dont trois refus à tort sur les sujets du symptôme initial.

Quel système, et quel symptôme ?
Berlioz répond aux questions d’alimentation d’un chat atteint d’insuffisance rénale chronique, à partir de 3 fichiers Markdown de littérature vétérinaire (154 chunks). Indexation par titres (cible de 500 tokens), mistral-embed (1024 dimensions), Upstash Vector en cosinus, top-4 filtré par profil, mistral-small-latest à 0,05 de température. Symptôme sur la preview de la branche, 3 runs sur 4 questions : les questions sur le BARF, les repas faits maison et le régime végétarien refusaient 3 fois sur 3, soit 9 faux refus sur 12 (0 sur 11 en production). Trois hypothèses : le seuil (écartée : meilleur chunk à 0,95 en local), la consigne de prompt (en partie vraie, voir plus bas), l’index (vraie).
Pourquoi un index complet pouvait-il être faux ?
L’index avait 154 vecteurs, comme le local, avec les mêmes textes (153 sur 154 identiques). Pourtant, la question sur le BARF ne remontait pas sa fiche : le top-4 donnait quatre fiches voisines avec des scores plats (environ 0,91). En comparant chaque vecteur stocké à l’embedding actuel de son propre texte, la médiane du cosinus valait 0,865 pour kb_sains (66 chunks sur 78 sous 0,99).
Deux fausses pistes : un autre modèle d’embedding, jamais prouvé, et la taille de lot. Passer BATCH_SIZE de 100 à 10 a donné exactement les mêmes statistiques à trois décimales (médiane 0,8589, 20 chunks sur 154 identiques) : quand deux mesures sortent identiques, ce n’est pas le paramètre, il faut relire le code.
if (!cache[hash]) {
const embedding = newEmbeddings[embeddingIdx++];
cache[hash] = { embedding, timestamp: ... };
}Avec un cache froid, un chunk au texte identique à un précédent (12 sur 154) voyait son hash déjà inscrit : il était sauté sans avancer embeddingIdx, et tous les chunks suivants recevaient l’embedding de leur voisin. Rien ne se voit : l’index est complet, les textes sont corrects, seules les positions dans l’espace vectoriel sont fausses.
Correctifs : vecteurs rattachés par hash, pas par position ; le modèle fait partie de la clé de cache. Contrôles ajoutés : un check-index compare 8 chunks ré-embeddés à leurs vecteurs stockés (cosinus au moins 0,999 ; testé avec un décalage volontaire, il échoue avec un minimum de 0,70), et un test de non-régression que j’ai vu échouer en rejouant l’ancien algorithme. Résultat : 154 vecteurs sur 154 à 0,999 ou plus, Hit@4 de 9 sur 39 (index régénéré avec le bug) à 39 sur 39, faux refus de 9 sur 9 à 0 sur 3.
Pourquoi le seuil de similarité ne séparait-il pas les sujets voisins ?
Même avec un index aligné, 13 questions hors base sur 13 recevaient des chunks, alors que mon balayage local annonçait 5 sur 13 vidées. Vérifié sur 5 questions : le score Upstash en cosinus vaut (1 + cos) / 2 (documentation). Mon seuil de 0,80 correspondait donc à un cosinus de 0,60, et mon balayage comparait des cosinus bruts à des scores Upstash. Refait sur la bonne échelle :
| Seuil | Rappel (39 questions couvertes) | Hors base vidées |
|---|---|---|
| 0,85 | 39/39 | 0/13 |
| 0,88 | 39/39 | 1/13 |
| 0,90 | 38/39 | 5/13 |
| 0,91 | 37/39 | 7/13 |
| 0,93 | 32/39 | 13/13 |
Les meilleurs scores hors base vont de 0,875 à 0,927 ; le plus bas des meilleurs chunks attendus est 0,882. Sur une base étroite, le seuil ne sépare pas le hors sujet du sujet voisin. À 3 runs, la question sur la vaccination invente 2 fois sur 3 quel que soit le seuil. Le dépôt est passé à 0,88 (rappel 39 sur 39).
Comment obtenir un « je ne sais pas » fiable ?
Sur 3 questions hors base sur 8 (chocolat, survie, vaccination), le modèle répondait avec ses connaissances générales quel que soit le seuil. Ma consigne « vérifie d’abord que les informations répondent à la question » provoquait en plus des refus (2 fois sur 3, fiche en tête à 0,926). Reformulée (« utiliser toute section qui répond même en partie »), elle donne 0 refus sur 12.
Réponse retenue : 8 fiches « sujet non couvert » dans la base (vaccination, médicaments, intoxications, pronostic, comportement, marques, interprétation d’analyses, reproduction), indexées comme des fiches ordinaires. Elles listent le vocabulaire du sujet et donnent la consigne : ne rien affirmer, orienter vers le vétérinaire. Une fiche a d’abord affirmé « aucune durée de survie médiane », ce qui était faux, la base cite une survie médiane sous aliment rénal : toute fiche de refus se vérifie contre la base.
Premier run de bout en bout : 1 échec sur 69 passages quand la fiche de refus était dans le contexte, 15 sur 42 quand elle manquait. Or en calcul exact, elle était première pour la question sur la survie (0,912). La recherche d’Upstash est approchée : avec topK = 4 elle était absente, avec topK = 20 elle remontait au rang 1. Le retriever interroge maintenant 20 chunks, applique le seuil, trie, puis garde 4.
| Mesure | Avant | Local, vrai index | Preview déployée |
|---|---|---|---|
| Couvertes : comportement correct | 9 faux refus sur 12 | 39/39 | 38/38 |
| Hors base : comportement correct | 3 questions sur 8 inventaient | 60/64 | 57/64 |
| Durée, âge ou dose hors fiches | présents | 0 sur 74 | 1 sur 74 |
Lignes --- ou URL hors fiches | 4/11 et 12/21 (prod) | 0 | 0 |
Que donne l’évaluation complète, et que reste-t-il d’inconnu ?
Rejouée après la fusion sur 52 questions (juge LLM et contrôles sans LLM) : comportement correct à 90 % sur 39 questions couvertes, 88 % sur 8 hors base, 100 % sur 5 adversariales. Aucune URL hors base sur 61 réponses ; 88 % des réponses sont sans pourcentage absent des fiches, le point faible. Le défaut dominant : le modèle complète avec ses connaissances générales, chiffres et références (Harper 1998, WSAVA 2013) compris, et le garde-fou vérifie les URLs, pas les références sans lien. Le défaut inverse : trois refus à tort, sur le BARF, les repas faits maison et le régime végétarien, les sujets du symptôme initial ; je n’ai pas analysé le retrieval. C’est moins bon que le tableau précédent, sans que j’aie réconcilié l’écart.
Avant la fusion, deux échecs réels restaient : l’interprétation d’une créatinine à 280 µmol/L et d’un épaississement de la vessie, où la fiche de refus est classée 5ᵉ ou 17ᵉ et coupée par k = 4. Limites de la mesure : 2 runs hors base seulement ; les fiches ont été ajustées en regardant les questions de test, qui ne sont donc plus indépendantes ; les questions sont écrites par l’auteur des fiches ; le juge, de la même famille que le générateur, a des angles morts. Je n’ai qu’un run de l’évaluation complète après la fusion, sans répétition. Le coût en euros n’est pas calculé (environ 1 300 appels, non instrumentés). Berlioz est une vitrine sans trafic réel. Voir la fiche du projet.
- Une panne de RAG est souvent une panne d’index, pas de prompt. Un index complet n’est pas un index correct : comparez chaque vecteur à l’embedding de son texte.
- Vérifiez l’échelle du score de votre base vectorielle avant de calibrer un seuil.
- Un seuil ne sépare pas les sujets voisins d’une base étroite : traitez le hors sujet par le contenu (fiches de refus).
- Mesurez un correctif avant de l’écrire, et voyez un test échouer sur le bug avant de lui faire confiance.
- Un garde-fou de citation doit vérifier aussi les références sans lien, pas seulement les URLs.
À propos. Je suis Camille Lebrun, Data & IA engineer à Toulouse. Je bâtis et fiabilise des plateformes data : fondations, migration, IA vérifiable. Je documente ici ce que j’applique en mission et dans mes projets. Une migration ou une plateforme à fiabiliser ? Écrivez-moi. camillelebrun.fr · LinkedIn
Article publié à l’origine en anglais sur Medium, réécrit en octobre 2026.