Un utilisateur a signalé que notre plugin de traduction renvoyait le même résultat mis en cache pour chaque requête, quel que soit l'input. Nous avons enquêté et trouvé pire : 95 % de tous les hits de cache sémantique sur notre plateforme étaient des faux positifs. 199 requêtes de traduction différentes, 198 corps de requête uniques, une seule réponse en cache servie à toutes.
Le rapport de bug
Le rapport était simple : "J'ai désactivé le cache sémantique, mais chaque traduction renvoie le même résultat."
Trois IDs de requête, trois segments de traduction différents, des réponses en cache identiques. Les corps de requête allaient de 1 564 à 8 676 bytes. L'ID de la réponse en cache était le même pour tous : chatcmpl-DG6J03nhdvcF7Ek0C8rJkjh7lN9pF.
Première suspicion : les paramètres de cache de l'utilisateur n'étaient pas appliqués. Il s'est avéré qu'il s'agissait d'un bug distinct de synchronisation de la source de données (le panneau d'administration écrivait dans une table, la passerelle API lisait dans une autre). Mais corriger cela n'a résolu que la moitié du problème. Même avec le cache activé et fonctionnant correctement, le cache sémantique faisait correspondre des requêtes qui ne devraient jamais correspondre.
Les données de production
Nous avons extrait 24 heures de données de hits de cache de ClickHouse. Les chiffres étaient mauvais.
| Modèle | Total des requêtes | Hits de cache | Requêtes uniques | Réponses uniques | Taux de hit |
|---|---|---|---|---|---|
| gpt-4.1-nano | 200 | 199 | 198 | 1 | 99.5% |
| glm-4.6-thinking | 100 | 38 | 13 | 1 | 38% |
| gpt-5-nano | 31 | 29 | 28 | 2 | 93.5% |
| gpt-oss-120b | 18 | 17 | 17 | 1 | 94.4% |
| qwen3-vl-flash | 17 | 16 | 16 | 1 | 94.1% |
198 requêtes de traduction uniques, toutes renvoyant la même réponse unique en cache. Ce n'est pas un cache. C'est une fonction cassée qui renvoie une constante.
Chaque modèle affecté partageait deux traits : toutes les requêtes provenaient d'un seul utilisateur, et toutes utilisaient un template de prompt système fixe avec un contenu utilisateur variable.
Pourquoi les embeddings échouent sur les inputs structurés
Le plugin de traduction envoie des requêtes comme celle-ci :
System: "Act as a translation API. Output a single raw JSON object only.
Input: {"targetLanguage":"<lang>","title":"...","segments":[...]}"
User: {"targetLanguage":"zh","title":"Product Page",
"description":"Translate product descriptions",
"tone":"formal",
"segments":[{"text":"actual varying content here"}]}
Le prompt système est identique pour toutes les requêtes. Le message utilisateur est un objet JSON où targetLanguage, title, description, et tone sont fixes. Seul segments[].text change.
Lorsque notre cache sémantique extrait le texte pour l'embedding, il concatène le prompt système et le message utilisateur. Le template fixe représente environ 80 % du texte. Le modèle d'embedding (all-mpnet-base-v2, 768 dimensions) compresse cela en un vecteur où la structure du template domine. Le contenu réel de la traduction fait à peine bouger l'aiguille.
Résultat : la similarité cosinus entre "traduire 'Hello world'" et "traduire 'Le rapport financier trimestriel montre une augmentation de 15 % des revenus'" dépasse 0,95. Notre seuil est de 0,95. Chaque requête de traduction correspond à la première entrée en cache.
En fouillant dans les logs, nous avons trouvé trois façons dont cela casse :
Le plugin de traduction est le pire coupable. Les clés et valeurs JSON fixes noient les segments de traduction réels. gpt-4.1-nano et gpt-5-nano ont tous deux été touchés par cela.
Un assistant de résumé de contexte présentait une variante du même problème. Son prompt système était si long que le contenu utilisateur (allant de 5KB à 47KB) était à peine enregistré dans l'embedding. C'est ainsi que glm-4.6-thinking a fini par renvoyer le même résumé pour chaque conversation.
Le troisième pattern était plus subtil. Pour gpt-oss-120b et qwen3-vl-flash, les 500 premiers caractères de chaque requête étaient identiques au bit près. Le contenu variable venait après, mais l'embedding était déjà dominé par le préfixe partagé.
Ce que dit la recherche
Ce n'est pas un problème nouveau. Des articles récents l'ont quantifié.
Le projet vCache de l'UC Berkeley a révélé que les hits de cache corrects et incorrects ont des "distributions de similarité qui se chevauchent fortement". Le seuil optimal varie de 0,71 à 1,0 selon les entrées en cache. Aucun chiffre unique ne fonctionne. Leur solution : apprendre un seuil séparé par entrée de cache, ce qui a réduit les taux d'erreur par 6 tout en doublant les taux de hit. (vCache, 2025)
Cela s'aggrave lorsque vous mélangez les types de requêtes. Une étude sur le caching sensible aux catégories a montré qu'un seuil de 0,80 produit 15 % de faux matchs sur les requêtes de code (sort_ascending vs sort_descending), tandis que le même seuil manque des paraphrases valides dans les requêtes conversationnelles. Un seul seuil, deux modes d'échec. (Category-Aware Semantic Caching, 2025)
Les banques y sont aussi confrontées. Une étude de cas InfoQ a documenté un système RAG où "Puis-je sauter mon paiement de prêt ce mois-ci" correspondait à "Que se passe-t-il si je manque un paiement de prêt" avec une similarité de 88,7 %. Intention différente, même réponse en cache. Ils ont commencé avec un taux de faux positifs de 99 % et ont eu besoin de quatre cycles d'optimisation pour descendre à 3,8 %. (InfoQ Banking Case Study, 2025)
Le problème de fond : les embeddings mesurent si deux prompts sont sémantiquement similaires, et non si la même réponse peut répondre aux deux. Cet écart est l'endroit où vivent les faux hits de cache. (Efficient Prompt Caching via Embedding Similarity, 2024)
Tous les articles que nous avons trouvés s'accordent sur une chose : la similarité d'embedding seule ne suffit pas. Vous avez besoin d'une couche de vérification.
La solution à deux couches
Nous avons construit deux défenses. La première élimine le bruit du template avant l'embedding. La seconde vérifie les hits après la mise en correspondance.
Couche 2 : Extraction de contenu pour les embeddings
Avant de générer un embedding, nous détectons désormais l'input structuré (JSON) et n'extrayons que le contenu significatif et variable.
La logique :
- Vérifier si le contenu du message commence par
{ou[ - S'il s'analyse comme du JSON, collecter récursivement toutes les valeurs de feuilles de type chaîne
- Filtrer les valeurs courtes (20 caractères ou moins) car il s'agit généralement de champs de configuration comme
"zh","formal", ou"Product Page" - Si le texte extrait est trop court ou vide, revenir au texte original
function extractContentForEmbedding(text: string): string {
const extracted = tryExtractJsonContent(text);
return extracted && extracted.length > 20 ? extracted : text;
}
Cela s'applique à la fois au prompt système et au message utilisateur. Pour le plugin de traduction, l'embedding représente désormais "Hello world" au lieu d'un blob JSON de 2KB. Pour l'assistant de résumé, il extrait la conversation réelle de l'enveloppe du template.
Le seuil de 20 caractères a été choisi de manière empirique :
"zh"(2 chars) : filtré. Valeur de config."formal"(6 chars) : filtré. Valeur de config."Product Page"(12 chars) : filtré. Champ de template."Translate product descriptions"(31 chars) : conservé. Contenu significatif."The quarterly financial report..."(40+ chars) : conservé. Contenu de traduction réel.
Couche 3 : Vérification de l'empreinte (Fingerprint)
Après un hit de cache sémantique, nous comparons un hash du texte extrait de la requête actuelle au hash stocké dans l'entrée en cache. S'ils ne correspondent pas, le hit est rejeté.
// Lors de l'écriture en cache
entry.metadata.textHash = fnv1aHash(extractedText);
// Lors de la lecture du cache, après avoir trouvé une correspondance de similarité
if (entry.metadata.textHash !== undefined) {
if (entry.metadata.textHash !== fnv1aHash(currentExtractedText)) {
// Faux positif : sémantiquement similaire mais contenu différent
metrics.recordFingerprintRejection();
return null;
}
}
Le hash utilise le texte extrait (post-Couche 2), pas l'input brut. Deux requêtes avec des enveloppes de template différentes mais un contenu réel identique correspondent toujours. Contenu différent, hash différent, rejeté.
Les anciennes entrées de cache sans textHash ignorent la vérification (rétrocompatibilité). Elles expirent naturellement via le TTL.
Nous utilisons FNV-1a (32-bit) pour le hash. Rapide, déterministe, et un taux de collision d'environ 1 sur 4 milliards est acceptable pour vérifier un seul hit de cache.
Pourquoi ne pas simplement augmenter le seuil ?
Notre seuil est déjà à 0,95. L'augmenter n'aide pas. Le problème est que des inputs structurellement similaires produisent des scores de similarité supérieurs à 0,95, peu importe ce que dit le contenu réel.
Les données de vCache confirment cela : les distributions de similarité des hits corrects et incorrects se chevauchent tellement qu'aucune coupure unique ne les sépare. Poussez le seuil à 0,99 et vous tuerez les hits de cache légitimes pour les paraphrases sans éliminer les faux positifs des requêtes lourdes en templates.
Corrigez l'input, vérifiez l'output. Ne jouez pas avec le seuil.
Résultats
Avec les deux couches déployées :
| Métrique | Avant | Après |
|---|---|---|
| Faux positifs gpt-4.1-nano | 198/199 | 0 |
| Part des faux positifs sur l'ensemble des hits | ~95% | <5% |
| Taux de hit de cache légitime | Inchangé | Inchangé |
| Latence ajoutée par requête | 0 | <1ms (JSON parse + FNV hash) |
La couche 2 seule aurait corrigé le plugin de traduction. La couche 3 est le filet de sécurité pour les cas où l'extraction JSON ne sépare pas complètement le contenu, ou pour les inputs structurés qui ne sont pas du JSON.
Points à retenir
Si vous gérez un cache sémantique en production :
Surveillez la diversité des réponses. Si un modèle a un taux de hit de cache de 100 % et 1 réponse unique, vous avez un problème de faux positifs. Requête :
SELECT model, uniqExact(substring(response_body, 1, 200)) as unique_responses, count() as total FROM request_logs WHERE cache_hit = true GROUP BY model.L'input structuré tue l'embedding naïf. Toute requête avec un template fixe (APIs JSON, enveloppes de prompt système, tâches de remplissage de formulaires) produira des scores de similarité artificiellement élevés. Prétraitez avant l'embedding.
Une couche de vérification n'est pas optionnelle. Chaque cache sémantique en production dans la littérature de recherche en possède une. La question est de savoir si vous utilisez une vérification par hash légère, un reranker cross-encoder, ou un appel de vérification LLM complet. Choisissez en fonction de votre budget de latence.
Les seuils globaux sont un compromis, pas une solution. Différents types de requêtes nécessitent différents seuils. Si vous ne pouvez pas faire de seuils par catégorie ou par entrée, ajoutez au moins un prétraitement d'input pour normaliser la qualité de l'embedding entre les catégories.
Le caching sémantique peut réduire de 30 à 70 % les coûts d'API LLM. Mais sans prétraitement de l'input et vérification des hits, vous servez des réponses périmées et appelez cela une victoire de performance.
LemonData fournit un accès unifié à plus de 300 modèles d'IA avec mise en cache, routage et optimisation des coûts intégrés. Essayez-le gratuitement avec 1 $ de crédit.