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é quelque chose de 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 variaient de 1 564 à 8 676 octets. L'ID de la réponse mise 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 depuis 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 mise 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 entrées structurées
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 modifie à peine le résultat.
Résultat : la similitude 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 mise en cache.
En fouillant dans les logs, nous avons trouvé trois façons dont cela échoue :
Le plugin de traduction est le pire contrevenant. 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 ce problème.
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 5 Ko à 47 Ko) était à peine pris en compte 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 schéma é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 similitude qui se chevauchent fortement". Le seuil optimal varie de 0,71 à 1,0 selon les différentes entrées mises 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 cache sensible aux catégories a montré qu'un seuil de 0,80 produit 15 % de fausses correspondances 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 également 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 similitude 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 similitude 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 les entrées structurées (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 chaînes de caractères (feuilles)
- 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;
}
Ceci 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 2 Ko. 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 par empreinte numérique (Fingerprint)
Après un hit de cache sémantique, nous comparons un hash du texte extrait de la requête actuelle avec le hash stocké dans l'entrée mise 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 similitude
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 (après la Couche 2), pas l'entrée brute. 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 bits) 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à de 0,95. L'augmenter n'aide pas. Le problème est que des entrées structurellement similaires produisent des scores de similitude supérieurs à 0,95, quel que soit le contenu réel.
Les données de vCache confirment cela : les distributions de similitude des hits corrects et incorrects se chevauchent tellement qu'aucun seuil unique ne peut les séparer. 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'entrée, vérifiez la sortie. 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 tous les hits de cache | ~95% | <5% |
| Taux de hit de cache légitime | Inchangé | Inchangé |
| Latence ajoutée par requête | 0 | <1ms (analyse JSON + hash FNV) |
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 entrées structurées qui ne sont pas du JSON.
Points clés
Si vous utilisez 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 seule 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'entrée structurée 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 similitude artificiellement élevés. Prétraitez avant l'embedding.
Une couche de vérification n'est pas optionnelle. Chaque cache sémantique de 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'entrée pour normaliser la qualité de l'embedding entre les catégories.
Le cache sémantique peut réduire de 30 à 70 % les coûts d'API LLM. Mais sans prétraitement des entrées et vérification des hits, vous servez des réponses erronées et appelez cela une victoire de performance.
LemonData offre un accès unifié à plus de 300 modèles d'IA avec cache intégré, routage et optimisation des coûts. Essayez-le gratuitement avec 1 $ de crédit.