Votre RAG est instable parce que la recherche n’est pas déterministe : le playbook du CTO

Par Diogo Hudson Dias
Two engineers in a São Paulo office reviewing vector retrieval diagrams with a server rack behind them.

Si, pour une même question, les réponses de votre RAG oscillent entre excellentes et catastrophiques, cessez d’accuser le modèle. Neuf fois sur dix, le coupable est une recherche non déterministe. L’article dev.to qui circule — « Your RAG Eval Isn’t Flaky. Your Retrieval Is Non-Deterministic. » — a raison. Votre top‑k n’est pas stable, votre index n’est pas versionné et vos critères de départage au classement ne sont pas déterministes. Vous évaluez une cible mouvante.

Ce billet vous propose un playbook au niveau CTO pour y remédier. Nous préciserons où le non‑déterminisme s’infiltre, quoi instrumenter, et comment verrouiller votre chaîne de recherche pour qu’une même requête sur un même corpus renvoie le même contexte — aujourd’hui, demain, et après votre prochaine reconstruction d’index.

Où se cache le non‑déterminisme de la recherche (à plus d’endroits que vous ne le pensez)

Les pipelines RAG dérivent parce que de nombreux « petits » composants sont probabilistes, parallèles ou dépendants du temps. Quelques coupables fréquents :

  • Instabilité de l’index : Les mises à jour incrémentales remanient les voisinages du graphe (HNSW), re‑clustérisent les centroïdes (IVF) ou modifient les cartes de suppression. À deux minutes d’intervalle, même requête, voisins différents.
  • Variabilité de la recherche approximative : Les recherches HNSW et IVF dépendent du chemin. ef_search, nprobe, largeur de faisceau, ordonnancement des threads — tout cela modifie les ensembles de candidats.
  • Tri non stable : Si vous triez uniquement par score, les ex æquo produisent un ordre aléatoire selon les threads/hôtes. Votre LLM voit 2–3 premiers chunks différents et répond différemment.
  • Dérive du tokenizer/segmenteur : Mettre à jour le tokenizer ou le chunker change les frontières de chunks. L’unité sémantique qui était classée n°3 s’étale désormais sur deux chunks et sort du top‑k.
  • Boost de récence via now() : Si vous boostez la « fraîcheur » avec l’horloge murale, la même requête, à quelques heures d’écart, obtient des classements différents.
  • Bruit en virgule flottante : Mélanger les schémas de quantification (index en FP32, requêtes en INT8), des backends BLAS différents et des nombres de threads variés peut réordonner subtilement les scores à la marge.
  • Roulette de prétraitement : Casefolding, normalisation Unicode, suppression des diacritiques (crucial en portugais/espagnol), listes de stopwords — de minuscules changements altèrent les embeddings et les candidats.

L’effet en aval n’a rien de subtil. Sur le terrain, nous avons vu la similarité de Jaccard du top‑5 entre deux exécutions de la même requête varier de 0,55 à 0,9 selon les réglages d’index. À 0,6, votre harnais d’éval criera « dérive du modèle » alors que le LLM est innocent.

Objectif : une stabilité de recherche mesurable

On ne gouverne pas ce qu’on ne mesure pas. Définissez une métrique et un SLO :

  • Indice de stabilité de la recherche (RSI) : Pour un instantané de corpus figé, exécutez chaque requête N fois et calculez la similarité moyenne de Jaccard des ensembles de résultats top‑k (ou un chevauchement tenant compte de l’ordre si cela compte pour vous). Ciblez RSI ≥ 0,90 pour la production Q&A. Pour les flux sensibles à la conformité, poussez à ≥ 0,95.
  • SLO de stabilité : 95 % des requêtes en production doivent avoir un RSI ≥ 0,9 sur 3 exécutions indépendantes contre le même instantané.

Ensuite, bâtissez votre pipeline pour réellement tenir cet SLO.

Playbook du CTO pour rendre la recherche déterministe

1) Considérez votre corpus comme des artefacts immuables et versionnés

  • Adressage par contenu : Assignez à chaque document un hash de contenu (p. ex. SHA‑256). Stockez un manifeste mappant doc_id → content_hash, language, et des timestamps effective_from.
  • Mises à jour append‑only : Les mises à jour créent une nouvelle version de document. Les anciennes versions restent interrogeables jusqu’à la coupe et l’activation du prochain snapshot. Cela évite la dérive en plein milieu d’une requête.
  • Discipline des snapshots : Toutes les requêtes frappent un snapshot de corpus nommé (p. ex., index_2026‑07‑12T19:00Z). Les snapshots sont immuables et font l’objet de rollback atomiques.

Résultat : quand quelqu’un se plaint que « les réponses ont changé », vous pouvez les corréler à un changement de snapshot, pas à un fantôme dans la machine.

2) Découpage canonique et déterministe

  • Figez le tokenizer : Choisissez un tokenizer et sa version (p. ex. cl100k_base vX) et n’activez pas de mises à jour automatiques. Les mises à niveau de tokenizer sont des migrations d’index, pas des correctifs à chaud.
  • Pas fixe, politique fixe : Utilisez des fenêtres de tokens fixes (p. ex. 512 tokens avec 64 de chevauchement) ou une politique sensible à la langue — mais figez‑la. Passer de 64 à 80 d’overlap équivaut à une nouvelle version de corpus.
  • Normaliser avant le découpage : Unicode NFKC, casefolding, traitement standard de la ponctuation et politique cohérente sur les diacritiques. En portugais/espagnol, décidez si vous indexez des variantes sans accents ou non. Soyez cohérents.

Astuce pro : n’engendrez pas de « smart » chunks avec un LLM si vous tenez au déterminisme. Si vous devez le faire, stockez le prompt du chunker, la version du modèle et la graine de sampling dans le manifeste, et traitez tout changement comme une migration.

3) Embeddings déterministes

  • Figez l’embedder : Le nom du modèle, le fournisseur et la checksum entrent dans le manifeste d’index. Passer de text‑embedding‑3‑small à text‑embedding‑3‑large implique un nouveau snapshot de corpus — point final.
  • Normalisez de façon cohérente : Toujours normaliser en L2 soit à l’écriture, soit à la requête, pas les deux. Documentez la politique.
  • Alignement de quantification : Si l’index utilise la quantification (p. ex. INT8), appliquez la même transformation aux vecteurs de requête. La précision mixte est une source fréquente de chaos dans le départage des ex æquo.

Stockez une empreinte (p. ex. xxh3) du vecteur float avant quantification à côté du vecteur stocké. Cela vous donne un garde‑fou pour vérifier qu’aucun prétraitement n’a changé en silence.

4) Index ANN avec des seeds, pas à l’instinct

Les méthodes d’approximate nearest neighbor sont rapides parce qu’elles éludent du travail. Elles sont reproductibles si vous en prenez le contrôle.

  • HNSW (FAISS, Qdrant, Milvus) : Fixez M et efConstruction, définissez une seed de build si la bibliothèque le permet, et évitez les mutations concurrentes. À la requête, fixez explicitement efSearch. Ne comptez pas sur les modes « auto » des fournisseurs.
  • IVF (FAISS, Milvus, pgvector ivfflat) : Entraînez les centroïdes sur exactement le même échantillon, avec une seed et des paramètres (nlist) figés. Pour pgvector, entraînez les listes une fois par snapshot ; n’autorisez pas un auto‑train opportuniste.
  • Index exacts pour petits K : Si votre collection ≤ 5 M de vecteurs et que le débit le permet, utilisez la recherche exacte (p. ex. FAISS IndexFlatIP/L2 ou mode exact de Qdrant) pour les workloads à haut risque. C’est 4–10× plus lent mais parfaitement stable.

Quel que soit le moteur choisi, exportez les paramètres d’index dans le manifeste du snapshot. Ne « tunez pas en prod » sans créer un nouveau snapshot.

5) Classement et départage stables

  • Tri stable : Classez par (score DESC, doc_id ASC, chunk_offset ASC). Documentez‑le et imposez‑le partout (service, SDK, SQL).
  • Aucune aléa caché : Désactivez les modes « autocut », « sampling » ou « hybrid blending » qui élaguent les quasi‑ex æquo. Si vous devez hybrider (BM25 + vecteur), figez alpha et les poids de termes.
  • Re‑rankers déterministes : Si vous utilisez des cross‑encoders pour reclasser, utilisez un décodage greedy (glouton) ou fixez une seed avec température 0, et figez la version du modèle.

6) Le temps est un paramètre, pas une variable globale

  • Requêtes as_of : Si vous faites du boost de récence, fournissez un timestamp as_of avec la requête et incluez‑le dans la clé de cache. N’appelez pas now() dans la fonction de ranking.
  • Corpora fenêtrés dans le temps : Pour des données très dynamiques, construisez des shards quotidiens ou horaires et routez les requêtes par une politique déterministe (p. ex., 30 derniers jours + documents de politique). Les frontières de shard font partie du snapshot.

7) Architecture single‑writer, snapshot‑reader

  • Build hors du chemin critique : Créez l’index en isolation, validez‑le, puis basculez atomiquement les lecteurs vers le nouveau snapshot.
  • Aucune écriture entremêlée : N’autorisez pas de fusions ou compactages en arrière‑plan qui modifient les topologies en journée. Si votre fournisseur force la compaction, cadrez‑la dans le temps et traitez‑la comme une rotation de snapshot.

8) Un cache de recherche fiable

  • Des clés de cache qui comptent : Empreinte du texte de requête, as_of, top‑k, version du re‑ranker, identifiant de snapshot de corpus. Si l’un diffère, c’est un miss.
  • Cachez l’ensemble, pas le texte : Cachez des identifiants et positions de résultats canoniques ; réhydratez le contenu depuis le snapshot. Cela détecte les mutations de texte accidentelles.

9) La réalité bilingue (le portugais et l’espagnol comptent)

Les données de support en Amérique latine ne sont pas qu’en anglais, et les diacritiques comptent. Si vous opérez en portugais ou en espagnol :

  • Choisissez une stratégie de normalisation : Soit indexer les variantes qui préservent les accents et celles qui les suppriment, soit standardiser sur une seule. Mélanger des règles ad hoc injecte une dérive silencieuse.
  • Découpage sensible à la langue : La segmentation en phrases en portugais/espagnol (p. ex., gestion des abréviations comme « Sr. ») nécessite un jeu de règles figé. N’héritez pas de règles locales de l’OS hôte.
  • Embeddings cross‑linguistiques : Si vous utilisez des embeddings multilingues, figez la version, car la couverture et les vocabulaires de sous‑mots évoluent plus agressivement que sur les modèles anglais uniquement.

Instrumentez : quoi journaliser et comparer

Vous n’améliorerez pas le RSI sans visibilité. A minima, journalisez ceci pour chaque requête :

  • Empreinte de requête : Empreinte du texte de requête normalisé et code langue.
  • Identifiant de snapshot de corpus : Nom et hash du manifeste.
  • Versions d’embedding et d’index : ID de modèle, ID de tokenizer, paramètres ANN.
  • Ensemble de candidats et scores : Top‑k des IDs et scores bruts avant tout re‑ranking.
  • Version du re‑ranker et ordre final : Les 10 dernières lignes de l’échelle sont là où vivent les ex æquo ; vous devez les voir.

Créez un job nocturne qui rejoue un échantillon canari de 1 000 requêtes contre le snapshot actif et calcule le RSI. Alertez si le RSI passe sous votre SLO. Si vous êtes téméraires, publiez le RSI sur votre page de statut interne comme un budget d’erreurs.

Garde‑fous spécifiques aux outils (sans culte du fournisseur)

FAISS

  • Exact : IndexFlatIP/L2 est déterministe si votre backend BLAS et le nombre de threads sont figés.
  • IVF : Entraînez une fois par snapshot avec seed et jeu d’entraînement figés ; fixez nlist et nprobe.
  • HNSW : Fixez M, efConstruction, efSearch. Construisez l’index en single‑thread pour une reproductibilité maximale si vous pouvez vous le permettre.

Qdrant

  • Réglages HNSW : Définissez m, ef_construct et ef explicitement. Désactivez l’auto‑tuning. Envisagez le mode exact pour les petites collections.
  • Quantification : Si vous activez la quantification scalaire, appliquez la même chose aux vecteurs de requête et figez la configuration du codec par snapshot.

Milvus

  • Types d’index : IVF_FLAT, HNSW, IVF_PQ — dans tous les cas, fixez les paramètres d’index et la version du schéma de collection.
  • Compaction de segments : Planifiez la compaction en heures creuses et traitez‑la comme un swap de snapshot. Ne la laissez pas ad hoc.

pgvector

  • Entraînement ivfflat : Entraînez les listes avec une seed figée et un échantillon d’entraînement figé. Définissez ivfflat.probes explicitement. Réentraînez uniquement lors d’une bascule de snapshot.
  • Recherche exacte : Pour ≤ 1 M de lignes ou des workloads peu sensibles à la latence, utilisez l’exact pour éliminer la variance ANN.

Weaviate

  • Déterminisme hybride : Figez alpha pour l’hybride BM25/vecteur ; évitez autoCut si vous tenez à un top‑k stable.
  • Versions de modules : Figez les versions du re‑ranker et du module text2vec. Les mises à niveau équivalent à de nouveaux snapshots.

Arbitrages à assumer dès le départ

  • Latence vs stabilité : La recherche exacte peut être 4–10× plus lente que l’ANN. Un compromis courant est ANN jusqu’à 50 candidats + re‑ranking par cross‑encoder déterministe jusqu’à 10.
  • Fraîcheur vs déterminisme : Les snapshots append‑only signifient que la nouvelle connaissance arrive selon un calendrier (horaire/quotidien), pas instantanément. Si « à la seconde près » compte, routez explicitement un petit shard de fraîcheur et documentez que ces réponses peuvent varier.
  • Coût de la discipline : Figer modèles, tokenizers et paramètres d’index ralentit les features. Le retour : un debug plus rapide et des évaluations crédibles. Si vous ne pouvez pas reproduire une mauvaise réponse, vous ne pouvez pas la corriger.

Un modèle de gouvernance simple qui fonctionne

Traitez votre index comme un artefact de build.

  • Manifeste de snapshot : Fichier JSON avec des champs pour l’empreinte du corpus, la version du tokenizer, l’ID de l’embedder, les règles de prétraitement, le type d’index + paramètres, l’ID du re‑ranker et la politique temporelle. Versionnez‑le dans Git.
  • Revue de changement : Tout changement d’un champ du manifeste passe par une PR avec un diff de RSI et un diff d’éval.
  • Gates de promotion : RSI canari ≥ 0,9 et aucune régression sur un jeu de QA spécifique au domaine. Si ça échoue, ça ne part pas en prod.

À quoi ressemble le « bon » en chiffres

Sur des corpora de support SaaS typiques (50k–500k chunks) :

  • RSI : 0,93–0,97 sur trois exécutions avec HNSW ef_search fixé à 128.
  • Latence : 35–70 ms de recherche ANN p95 sur CPU pour top‑50 candidats ; 90–140 ms en recherche exacte sur 500k vecteurs ; ajoutez 30–60 ms pour le re‑ranking par cross‑encoder p95 sur une T4/A10.
  • Cadence de mise à jour : Snapshots horaires pour les changelogs ; quotidiens pour la doc ; hebdomadaires pour la politique/juridique.

Vos chiffres varieront, mais si votre RSI reste bloqué à 0,7, vous n’avez pas « pas de chance » — c’est votre pipeline.

Pourquoi cela compte pour vous en tant que CTO

Une recherche déterministe vous permet de faire de l’ingénierie, de la vraie :

  • Évals fiables : Vous pouvez attribuer les gains aux changements plutôt qu’au bruit. Un gain de +3 points signifie quelque chose.
  • Moins de risque support : Quand les réponses varient, les tickets explosent. La stabilité du top‑k réduit la variance de qualité de réponse, même si le LLM est stochastique.
  • Posture réglementaire : Vous pouvez montrer aux auditeurs exactement quel corpus et quelle version d’index ont produit une réponse. C’est la différence entre « on pense » et « on sait ».
  • Levier nearshore : Un pipeline discipliné est portable. Vous pouvez répliquer toute la pile avec un pod nearshore au Brazil et attendre le même comportement — même manifeste, même RSI, mêmes SLO.

Démarrer ce mois‑ci

  1. Semaine 1 : Ajoutez des IDs de snapshot à votre service de recherche et logguez les IDs + scores du top‑k. Construisez le job RSI sur le trafic de la semaine passée.
  2. Semaine 2 : Figez tokenizer, embedder et découpage. Coupez votre premier snapshot immuable. Basculez les requêtes pour qu’elles s’y réfèrent explicitement.
  3. Semaine 3 : Figez les paramètres ANN et les critères de départage. Désactivez tout tuning « auto ». Re‑coupez un snapshot et mesurez le RSI.
  4. Semaine 4 : Introduisez un re‑ranker cross‑encoder avec des réglages déterministes. Fixez votre SLO de RSI et branchez les alertes.

Après cela, déplacez les builds de snapshot hors du chemin critique, ajoutez des gates de promotion, et cessez de prendre pour le « drift du modèle » un problème de recherche que vous pouvez contrôler.

Points clés à retenir

  • La flakiness du RAG vient généralement du non‑déterminisme de la recherche, pas des humeurs du LLM.
  • Définissez et suivez une métrique RSI ; visez ≥ 0,9 de stabilité pour le Q&A en production.
  • Versionnez tout : corpus, tokenizer, embedder, type/paramètres d’index, re‑ranker et politique temporelle.
  • Utilisez un découpage déterministe et des départages de tri stables ; traitez les mises à niveau de tokenizer comme des migrations.
  • Fixez les paramètres et seeds ANN ; envisagez la recherche exacte pour les flux à haut risque.
  • Rendez le temps explicite avec des requêtes as_of ; arrêtez d’appeler now() dans le ranking.
  • Adoptez une architecture single‑writer, snapshot‑reader et mettez en cache des IDs canoniques, pas du texte.
  • Pour les corpora portugais/espagnol, figez les règles de normalisation et de segmentation pour éviter la dérive silencieuse.
  • Gouvernez avec un manifeste et des gates de promotion ; aucun snapshot ne part sans diff RSI/éval.

Ready to scale your engineering team?

Tell us about your project and we'll get back to you within 24 hours.

Start a conversation