Module 9 — Mise en cache et maîtrise des coûts
Un système RAG en production traite des dizaines de milliers de questions par mois. Sans cache et sans mesure, la facture explose et la latence se dégrade sans qu'on sache pourquoi. Ce module donne la décomposition du coût par question, les caches à mettre en place, et la journalisation minimale pour diagnostiquer une régression après coup.
Décomposer le coût d'une question
Une question passe par cinq étapes facturables. Sur le fil rouge, avec un modèle de génération à environ 3 centimes pour 1 000 jetons de sortie et 1 centime pour 1 000 jetons d'entrée :
| Étape | Coût typique | Poids |
|---|---|---|
| Plongement de la question | 0,001 € | 1 % |
| Recherche vectorielle + BM25 | ≈ 0 (auto-hébergée) | 0 % |
| Reclassement (20 candidats) | 0,002 € (CPU) | 1 % |
| Génération de la réponse | 0,03 à 0,10 € | 90 % |
| Journalisation | ≈ 0 | 0 % |
Neuf pour cent du coût sont l'étape la moins visible, quatre-vingt-dix pour cent viennent d'une seule ligne de code. Toute optimisation qui ignore la génération se trompe de combat.
Cache des plongements
Les plongements sont déterministes : un texte identique donne toujours le même vecteur avec le même modèle. Deux caches sont utiles :
Cache des plongements de passages : indispensable au moment de la réindexation. Sans cache, chaque redéploiement recalcule tous les vecteurs — pour 300 documents, 20 minutes de calcul. Avec cache par empreinte du contenu, seuls les documents modifiés sont recalculés.
import hashlib
import sqlite3
class CachePlongements:
def __init__(self, chemin="./cache_plongements.sqlite"):
self.co = sqlite3.connect(chemin)
self.co.execute(
"CREATE TABLE IF NOT EXISTS c (h TEXT PRIMARY KEY, v BLOB)"
)
def empreinte(self, texte, modele):
return hashlib.sha256((modele + "|" + texte).encode()).hexdigest()
def obtenir(self, texte, modele):
h = self.empreinte(texte, modele)
r = self.co.execute("SELECT v FROM c WHERE h=?", (h,)).fetchone()
return None if r is None else np.frombuffer(r[0], dtype=np.float32)
def poser(self, texte, modele, vecteur):
h = self.empreinte(texte, modele)
self.co.execute(
"INSERT OR REPLACE INTO c (h, v) VALUES (?, ?)",
(h, vecteur.astype(np.float32).tobytes()),
)
self.co.commit()
L'empreinte inclut le nom du modèle, faute de quoi un changement de modèle silencieux servirait de vieux vecteurs qui ne comparent plus avec les nouveaux.
Cache des plongements de questions : utile si les utilisateurs posent souvent les mêmes questions, par exemple sur un intranet où « télétravail » revient trente fois par jour. Un simple dictionnaire en mémoire, plafonné à 10 000 entrées avec éviction LRU, suffit.
Cache des réponses complètes
Cacher une réponse est plus délicat : la clef doit refléter la question et l'état du corpus. Si un document a été mis à jour, la réponse mise en cache n'est plus valide.
def clef_reponse(question, version_index):
normalisee = " ".join(question.lower().split())
return hashlib.sha256(
f"{version_index}|{normalisee}".encode()
).hexdigest()
version_index est un identifiant qu'on incrémente à chaque réindexation. Toute réponse cachée sous une version antérieure est ignorée. Cette précaution évite le pire scénario : une note de service change, mais un utilisateur reçoit la réponse d'avant.
Le taux de succès du cache dépend de l'entreprise : entre 15 et 35 % sur un intranet typique, dominé par une longue traîne de reformulations qu'un cache par mots exact rate. Un cache sémantique — indexer les questions dans l'espace vectoriel et servir la réponse si une question précédente est à moins de 0,95 de similarité — grimpe à 40-50 % au prix d'un risque : deux questions très proches en apparence peuvent différer sur un détail crucial. À utiliser prudemment, avec un seuil élevé.
Une entreprise change sa politique de télétravail, la met à jour dans l'intranet, et pendant deux jours l'assistant continue de répondre l'ancienne version parce que le cache n'a pas été invalidé. C'est un incident de communication interne difficile à repérer, car aucun utilisateur ne signale que « la réponse a l'air correcte mais elle est périmée ». Le rattachement du cache à la version d'index est la seule protection ; ne jamais cacher sans elle.
Réindexation incrémentale
Sur un corpus qui bouge peu — quelques documents modifiés par semaine — recréer l'index intégralement à chaque changement gaspille des ressources. Une réindexation incrémentale suit ce schéma :
- Comparer les empreintes des fichiers du corpus à celles du dernier index.
- Pour chaque fichier modifié : supprimer ses passages de l'index par
document_id, ré-extraire, redécouper, replonger (avec cache), réinsérer. - Pour chaque fichier supprimé : supprimer ses passages.
- Pour chaque fichier ajouté : traiter comme une insertion.
- Incrémenter
version_index.
def reindexer_incremental(dossier, index, cache_plongements):
connu = index.empreintes_actuelles()
present = empreintes_dossier(dossier)
a_supprimer = set(connu) - set(present)
a_traiter = [f for f, h in present.items() if connu.get(f) != h]
for fichier in a_supprimer:
index.supprimer_par_document(fichier)
for fichier in a_traiter:
index.supprimer_par_document(fichier)
passages = decouper_hierarchique(extraire(fichier))
vecteurs = plonger_avec_cache(passages, cache_plongements)
index.inserer(passages, vecteurs)
index.incrementer_version()
Sur 300 documents dont 5 modifiés, le passage de 20 minutes à 30 secondes rend la réindexation acceptable à chaque commit sur le dépôt de documents.
Contrôler la longueur de la réponse
La sortie du modèle est de loin la partie la plus chère. Deux leviers concrets :
Fixer une longueur cible dans la consigne : « Réponds en trois à cinq phrases, jamais plus. » Un modèle bien instruit tient cette contrainte à 90 % près.
Couper la génération avec max_tokens=400. Non pas par défiance envers la consigne, mais comme filet de sécurité : une réponse qui déborde au-delà est presque toujours une réponse qui déraille (répétition, invention).
Sur le fil rouge, le passage de « pas de contrainte » à « max 400 jetons » a divisé le coût moyen par 2,3 sans mesure de qualité qui régresse — les réponses trop longues étaient rarement de meilleures réponses.
Journalisation minimale
Chaque question traitée doit laisser une trace exploitable hors ligne :
def journaliser(question, passages, reponse, temps_ms, cout):
ligne = {
"date": datetime.now(timezone.utc).isoformat(),
"question_hash": hashlib.sha256(question.encode()).hexdigest()[:16],
"passages_ids": [p["id"] for p in passages],
"reponse_len": len(reponse),
"temps_ms": temps_ms,
"cout_eur": cout,
"abstention": "Je ne trouve pas" in reponse,
}
with open("./journal.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(ligne) + "\n")
Deux règles : on ne journalise jamais la question ni la réponse en clair sans autorisation (RGPD, secret professionnel), et l'on garde l'empreinte du corpus et de l'index qui ont servi. Le jour où un utilisateur signale une régression, on retrouve la version qui a produit la réponse et l'on peut la reproduire.
Le décompte à partir des jetons envoyés et reçus est fiable : les API renvoient les compteurs input_tokens et output_tokens, il suffit de les multiplier par le tarif de l'API. Comparer ce coût mesuré à un objectif — 0,05 € par question, par exemple — permet de repérer immédiatement une dérive : un modèle qui passe de 300 à 1 200 jetons de sortie en moyenne signale un dérèglement de la consigne. C'est l'un des sept thèmes explicites de la banque d'examen.
En résumé
- Le coût d'une question RAG est dominé à 90 % par la génération ; toute optimisation qui l'ignore rate l'essentiel.
- Les plongements sont déterministes : un cache par empreinte du texte évite de recalculer à chaque réindexation.
- Un cache de réponses doit être rattaché à une version d'index pour ne pas servir une information périmée après mise à jour du corpus.
- Une réindexation incrémentale transforme la mise à jour du corpus d'un chantier de vingt minutes à une opération de trente secondes ; une journalisation frugale mais complète permet de diagnostiquer les régressions après coup.
Module suivant : assembler tous les modules en un projet d'assistant documentaire complet, avec permissions par document et interface minimale.