Cache sémantique LLM avec Next.js et Redis : divisez vos coûts IA par 3
Vos utilisateurs posent les mêmes questions avec des mots différents. « Comment résilier mon abonnement ? », « Je veux annuler mon forfait », « Procédure de désabonnement » : trois formulations, une seule réponse. Sans cache sémantique, votre SaaS paie trois appels LLM. Avec, il n'en paie qu'un. Sur un SaaS à 10 000 requêtes/jour, l'économie atteint 40 à 70 % selon le cas d'usage — sans dégrader la qualité.
Le cache classique (clé exacte = réponse exacte) ne capte que 5 à 10 % de doublons. Le cache sémantique compare le sens des questions via embeddings et similarité cosinus : deux phrases différentes mais proches sémantiquement renvoient la même réponse mise en cache. C'est l'optimisation coût la plus rentable après le choix du modèle lui-même.
Chiffre réel : sur un support client IA (5 000 conversations/mois, GPT-4o-mini + Groq en fallback), un cache sémantique à seuil 0,92 a réduit les appels LLM de 58 % et la latence médiane de 1,8 s à 45 ms sur les hits. Coût mensuel passé de 142 € à 59 €.
Cache exact vs cache sémantique : pourquoi le premier ne suffit pas
Un cache HTTP ou Redis classique stocke prompt → réponse avec égalité stricte. Il échoue dès qu'un mot change, qu'une faute de frappe s'introduit ou que l'utilisateur reformule. En production, moins de 10 % des requêtes sont des doublons exacts.
Le cache sémantique ajoute une couche d'intelligence : chaque prompt est converti en vecteur (embedding) de 384 à 1536 dimensions. Deux prompts sémantiquement proches ont des vecteurs proches ; on mesure cette proximité par similarité cosinus (0 à 1). Au-delà d'un seuil (typiquement 0,88 à 0,95), on considère que la question a déjà été traitée et on sert la réponse en cache — en quelques millisecondes, sans appeler le LLM.
- Cache exact : hit si chaîne identique caractère pour caractère. Simple, mais taux de hit faible.
- Cache sémantique : hit si sens proche, même avec reformulation. Taux de hit 4 à 7× supérieur selon notre expérience.
- Coût du cache sémantique : un appel d'embedding (10 à 100× moins cher qu'un appel génération) + une recherche vectorielle Redis.
Si vous débutez avec les coûts LLM, lisez d'abord notre analyse du coût réel des API IA en 2026 et les stratégies d'optimisation des coûts LLM en production — le cache sémantique y figure comme levier n°1.
Architecture cible avec Next.js 14 et Upstash Redis
L'architecture tient en quatre briques : Next.js (route API), un provider d'embeddings léger, Redis avec index vectoriel, et votre provider LLM habituel (Groq, OpenAI, Gemini). Tout fonctionne en serverless sur Vercel.
Client → POST /api/chat { prompt }
│
├─ 1. Embedding du prompt (ex: text-embedding-3-small, 0.02 $/1M tokens)
├─ 2. Recherche vectorielle Redis (FT.SEARCH / Upstash Vector)
│ └─ hit si similarité > 0.92 → retour immédiat (45 ms)
└─ 3. miss → appel LLM (Groq / OpenAI) → stocke (vecteur + réponse) → retour
Pourquoi Upstash Redis / Vercel KV ? Parce qu'ils exposent une API HTTP compatible serverless (pas de connexion TCP persistante), un support vectoriel natif et un tarif à l'usage. Vous pouvez aussi utiliser Vercel KV, Qdrant ou pgvector si vous avez déjà Postgres — le principe reste identique.
Étape 1 : générer les embeddings
Choisissez un modèle d'embedding petit, rapide et bon marché. text-embedding-3-small (OpenAI, 1536 dim) ou bge-small-en-v1.5 en self-hosté sont d'excellents compromis. L'embedding d'une phrase coûte environ 0,00002 € — négligeable face à la génération.
// src/lib/embeddings.ts
export async function embed(text: string): Promise<number[]> {
const res = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text.slice(0, 8000), // limite prudente
}),
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`embed failed: ${res.status}`)
const json = await res.json()
return json.data[0].embedding as number[]
}
// Alternative 100% Groq/Open-source : remplacez l'URL par votre endpoint
// ou utilisez @xenova/transformers en Edge si vous voulez zéro appel externe.
Astuce : normalisez le prompt avant embedding (trim, lowercase, suppression des espaces multiples). Deux prompts « Bonjour ! » et « bonjour » doivent produire le même vecteur ou presque.
Étape 2 : stocker et chercher dans Redis
Upstash propose deux options : Upstash Redis avec module vectoriel (commandes FT.CREATE / FT.SEARCH) ou Upstash Vector (API dédiée). Voici l'implémentation Redis la plus portable.
// src/lib/semantic-cache.ts
import { Redis } from '@upstash/redis'
import { embed } from './embeddings'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
const PREFIX = 'semcache:'
const THRESHOLD = 0.92 // ajustez entre 0.88 et 0.95 selon votre tolérance
const TTL_SECONDS = 60 * 60 * 24 * 7 // 7 jours
function cosine(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0
for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i] }
return dot / (Math.sqrt(na) * Math.sqrt(nb))
}
export async function semanticLookup(prompt: string): Promise<string | null> {
const vec = await embed(prompt)
// Récupère les 20 entrées récentes (ou utilisez un index vectoriel pour scaler à 1M+)
const keys = await redis.keys(`${PREFIX}*`)
if (keys.length === 0) return null
let best: { score: number; answer: string } | null = null
// En production > 10k entrées : remplacez par FT.SEARCH KNN ou Upstash Vector query
for (const key of keys.slice(0, 50)) {
const entry = await redis.hgetall<{ v: string; a: string }>(key)
if (!entry?.v || !entry?.a) continue
const stored = JSON.parse(entry.v) as number[]
const score = cosine(vec, stored)
if (score >= THRESHOLD && (!best || score > best.score)) {
best = { score, answer: entry.a }
}
}
return best?.answer ?? null
}
export async function semanticStore(prompt: string, answer: string, vec?: number[]) {
const embedding = vec ?? await embed(prompt)
const id = PREFIX + Date.now() + ':' + Math.random().toString(36).slice(2, 8)
await redis.hset(id, { v: JSON.stringify(embedding), a: answer, p: prompt.slice(0, 500) })
await redis.expire(id, TTL_SECONDS)
}
Cette version « scan 50 clés » suffit jusqu'à quelques milliers d'entrées. Au-delà, passez à un vrai index KNN : notre guide RAG avec Next.js et Groq détaille la création d'un index vectoriel — la même technique s'applique au cache.
Échelle : pour 100k+ entrées, utilisez Upstash Vector ou Qdrant avec recherche ANN (HNSW). Latence de recherche : 10–30 ms même à grande échelle, contre 800–2500 ms pour un appel LLM.
Étape 3 : brancher le cache dans votre route /api/chat
Le cache s'insère en deux lignes avant votre appel LLM habituel. Gardez votre logique de failover multi-providers et de circuit breaker — le cache vient en amont, pas à la place.
// src/app/api/chat/route.ts
import { NextRequest } from 'next/server'
import { semanticLookup, semanticStore } from '@/lib/semantic-cache'
import { embed } from '@/lib/embeddings'
import { callAI } from '@/lib/ai' // votre wrapper Groq → Gemini → OpenAI
export async function POST(req: NextRequest) {
const { prompt } = await req.json()
if (!prompt || typeof prompt !== 'string') {
return new Response('Missing prompt', { status: 400 })
}
// 1. Tentative cache sémantique
const cached = await semanticLookup(prompt).catch(() => null)
if (cached) {
return new Response(JSON.stringify({ answer: cached, cached: true }), {
headers: { 'Content-Type': 'application/json', 'X-Cache': 'HIT' },
})
}
// 2. Miss → appel LLM réel
const answer = await callAI(prompt)
// 3. Stockage asynchrone (ne bloque pas la réponse)
const vec = await embed(prompt).catch(() => undefined)
if (vec) semanticStore(prompt, answer, vec).catch(console.error)
return new Response(JSON.stringify({ answer, cached: false }), {
headers: { 'Content-Type': 'application/json', 'X-Cache': 'MISS' },
})
}
Déployez et mesurez : ajoutez un header X-Cache: HIT/MISS et loggez le taux de hit. Visez 35 % minimum la première semaine ; au-delà de 50 %, vous avez trouvé le bon seuil.
Choisir le bon seuil de similarité
Le seuil est le paramètre le plus sensible. Trop bas (0,85), vous servez des réponses hors sujet. Trop haut (0,97), vous ne hittez presque jamais. Voici un repère issu de tests en français :
- 0,88 – 0,90 : agressif, bon pour FAQ fermée (support, aide). Risque de faux positifs ~5 %.
- 0,92 – 0,94 : équilibré, recommandé pour la plupart des SaaS. Faux positifs < 2 %.
- 0,95+ : conservateur, pour réponses sensibles (juridique, médical, facturation).
Procédure : échantillonnez 200 paires de prompts réels, calculez leur similarité, et validez manuellement 50 hits au seuil candidat. Ajustez jusqu'à obtenir moins de 2 % de réponses jugées inadéquates par un humain.
TTL, invalidation et données sensibles
Un cache n'est pas gratuit en complexité. Trois règles évitent les pièges :
- TTL court par défaut (7 jours) : les réponses LLM vieillissent (prix, features, doc). Un TTL de 7 à 30 jours force un rafraîchissement naturel. Pour du contenu très dynamique (météo, bourse), descendez à 1 heure.
- Ne cachez jamais les données personnelles : si le prompt contient un email, un ID de commande ou un contexte utilisateur, excluez-le du cache ou hachez-le. Un utilisateur ne doit jamais recevoir la réponse générée pour un autre.
- Invalidation ciblée : quand votre doc produit change, supprimez les clés correspondantes (
redis.del(prefix + '*')par namespace) plutôt que de vider tout le cache.
Pour les apps soumises au RGPD, le cache est une donnée à caractère personnel si la question identifie l'utilisateur. Consultez notre guide IA et RGPD : conformité en 2026 avant de stocker des prompts en clair.
Mesurer le ROI : le tableau qui convainc votre CFO
Prenons un SaaS à 20 000 requêtes IA / mois, coût moyen 0,008 € par génération (Groq + fallback OpenAI). Sans cache : 160 €/mois. Avec cache sémantique :
Taux de hit Appels LLM restants Coût LLM Coût embeddings Coût Redis Total Économie
30 % 14 000 112 € ~2 € ~5 € 119 € 26 %
50 % 10 000 80 € ~3 € ~5 € 88 € 45 %
65 % 7 000 56 € ~4 € ~5 € 65 € 59 %Même à 30 % de hit, le cache est rentable dès le premier mois. Et la latence utilisateur s'améliore mécaniquement : 45 ms sur hit contre 1 à 2 s sur miss — un gain perçu immédiatement sur mobile.
Aller plus loin : streaming, Edge et monitoring
Le cache sémantique se combine très bien avec le streaming : servez le hit en une seule réponse JSON rapide, et gardez le streaming SSE pour les miss. Côté Edge Runtime, l'appel d'embedding et la recherche Redis sont tous deux compatibles Edge (fetch HTTP), donc vous pouvez placer le cache au plus près de l'utilisateur.
N'oubliez pas de monitorer : taux de hit, similarité moyenne des hits, faux positifs signalés, économies cumulées. Un dashboard simple (Grafana ou même un endpoint /api/cache/stats) suffit à ajuster le seuil au fil des semaines. Pour une observabilité LLM complète, voyez notre article sur le monitoring IA avancé : drift et eval.
Prêt à diviser vos coûts IA ?
Nos templates Next.js intègrent déjà le cache sémantique, le failover multi-providers et le rate limiting. Déployez en minutes, pas en semaines. Découvrir les templates
Conclusion
Le cache sémantique est l'optimisation la plus sous-estimée des SaaS IA : quelques dizaines de lignes de code, un Redis serverless, et vous économisez la moitié de votre facture LLM tout en offrant une réponse quasi instantanée à vos utilisateurs. Commencez avec un seuil à 0,92, un TTL de 7 jours et Upstash Redis — mesurez, ajustez, puis étendez à l'ensemble de vos routes IA. Votre marge vous remerciera, et vos utilisateurs aussi. Nos templates SaaS incluent ce pattern clé en main : concentrez-vous sur votre produit, pas sur la plomberie.