Cache sémantique pour applications LLM sous Ubuntu 24.04 avec Qdrant
Réduisez la facture des API LLM en mettant les réponses en cache selon le sens et non le texte : Qdrant sous Ubuntu 24.04, isolation des clients, seuils par route et coût des IOPS.
Dès qu'une application d'IA générative passe en production, la facture attire vite l'attention, car chaque token est payant. En parcourant vos logs de prompts, vous découvrirez sans doute que jusqu'à 40% des requêtes des utilisateurs expriment la même intention avec d'autres mots. "Comment réinitialiser mon mot de passe ?" et "Bloqué hors de mon compte, besoin de retrouver l'accès" veulent dire la même chose. Un magasin clé-valeur classique y voit deux requêtes sans rapport, appelle l'API OpenAI deux fois et facture le prix plein les deux fois.
Un cache sémantique règle ce problème. Voici comment en construire un sous Ubuntu 24.04 LTS, du point de vue de l'exploitation, avec les trois pièges que les tutoriels passent généralement sous silence.
Correspondance exacte ou sens
Deux façons de chercher se comportent très différemment :
- Le cache lexical, comme un simple magasin clé-valeur Redis, exige des chaînes identiques. "Où est la commande ?" est en cache, mais "Suivre le colis" ne le trouve pas, parce que les caractères diffèrent.
- Le cache sémantique transforme les prompts en vecteurs de grande dimension grâce à un modèle d'embeddings. "Où est la commande ?" et "Suivre le colis" se retrouvent proches dans l'espace vectoriel, donc la réponse stockée revient tout de suite. La latence de recherche passe d'environ 3000 ms à près de 15 ms.
Mettre en place le cache vectoriel avec Qdrant
Pour le stockage, lancez Qdrant dans Docker avec un volume persistant, puis préparez un environnement Python :
# 1. Install Qdrant via Docker on Ubuntu 24.04 (Persistent Storage)
sudo apt update && sudo apt install docker.io -y
sudo docker run -d --restart unless-stopped -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage:z \
qdrant/qdrant
# 2. Setup Python environment
python3 -m venv llm_cache_env
source llm_cache_env/bin/activate
pip install qdrant-client sentence-transformers openai
Séparer les clients
Un cache sémantique global unique dans un produit SaaS multi-locataire ouvre la porte à de graves fuites entre clients. Imaginez que l'utilisateur A demande un aperçu des dernières transactions de son compte. Le modèle rédige une réponse qui contient les données financières privées de A, et elle est mise en cache. Plus tard, l'utilisateur B demande un résumé de ses propres transactions. Le cache voit une correspondance d'intention de 98% et donne à B la réponse écrite pour A.
La solution consiste à placer un espace de noms strict, tenant_id, dans le payload Qdrant et à exécuter chaque recherche de similarité sous un filtre de métadonnées obligatoire. Sans ce filtre, une recherche ne doit jamais s'exécuter. Le code ci-dessous crée la collection avec la bonne taille de vecteur (384 pour l'encodeur MiniLM local) et effectue une recherche filtrée par client :
from qdrant_client import QdrantClient
from qdrant_client.http import models
from sentence_transformers import SentenceTransformer
# Initialize Local Embedding Model (Free & Fast)
encoder = SentenceTransformer("all-MiniLM-L6-v2")
client = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "semantic_cache_prod"
# Explicitly create collection with correct Vector Dimensions (384)
if not client.collection_exists(collection_name=COLLECTION_NAME):
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=models.VectorParams(
size=384, # Matches 'all-MiniLM-L6-v2' output dimension
distance=models.Distance.COSINE
),
)
def check_secure_cache(user_query: str, tenant_id: str, threshold: float = 0.90):
query_vector = encoder.encode(user_query).tolist()
# SRE Fix: Hard Metadata Filtering by tenant_id
hits = client.search(
collection_name=COLLECTION_NAME,
query_vector=query_vector,
query_filter=models.Filter(
must=[models.FieldCondition(
key="tenant_id",
match=models.MatchValue(value=tenant_id)
)]
),
limit=1,
score_threshold=threshold
)
if hits:
print(f" Secure Cache HIT! (Score: {hits[0].score:.3f})")
return hits[0].payload["llm_response"]
return None
Le piège du seuil
Choisir le seuil de similarité, c'est arbitrer entre précision et rappel. Beaucoup de guides conseillent une valeur unique de 0,80, ce qui produit de vilaines réponses fausses. La proximité dans l'espace des embeddings ne signifie pas un sens identique. "Donne la capitale de la France" et "Donne la capitale de l'Allemagne" ont la même structure de phrase, donc elles sont très proches l'une de l'autre, avec une similarité cosinus supérieure à 0,85. Avec un seuil de 0,80, la personne qui pose la question sur l'Allemagne recevrait la réponse en cache sur la France.
Réglez plutôt le seuil par route :
- 0,95 et plus pour les questions strictement factuelles, techniques ou financières
- 0,88 pour les FAQ conversationnelles génériques
Quand la vitesse du disque devient le goulot
Les bases vectorielles construites sur des index HNSW sollicitent fortement la mémoire et le débit disque. Une recherche de similarité intensive dans un cloud public implique en général du stockage bloc avec des IOPS provisionnées, et ces frais io2 peuvent être élevés.
Une façon de contourner le problème est d'héberger toute la pile de cache sur des serveurs bare-metal dédiés. Des disques NVMe d'entreprise reliés directement en PCIe offrent des millions d'IOPS sans supplément, et une grande réserve de RAM DDR5 sur du matériel dédié peut maintenir les recherches d'embeddings sous 15 ms même avec beaucoup de trafic simultané. On supprime ainsi la taxe sur le stockage que les fournisseurs cloud ajoutent au calcul.