Semantisches Caching für LLM-Apps unter Ubuntu 24.04 mit Qdrant
Senken Sie die Kosten für LLM-APIs, indem Sie Antworten nach Bedeutung statt nach Text cachen: Qdrant unter Ubuntu 24.04, Mandantentrennung, Schwellen pro Route und IOPS-Kosten.
Sobald eine generative KI-App live ist, fällt die Rechnung schnell auf, denn jeder Token wird berechnet. Wer seine Prompt-Logs durchgeht, stellt wahrscheinlich fest, dass bis zu 40% der Nutzeranfragen dieselbe Absicht in anderen Worten ausdrücken. "Wie setze ich mein Passwort zurück?" und "Aus dem Konto ausgesperrt, brauche wieder Zugang" meinen dasselbe. Ein klassischer Key-Value-Store sieht zwei unabhängige Anfragen, ruft die OpenAI-API zweimal auf und berechnet beide Male den vollen Preis.
Ein semantischer Cache löst das. Hier sehen Sie, wie man ihn unter Ubuntu 24.04 LTS aus Betriebssicht aufbaut, und welche drei Fallen Tutorials meist auslassen.
Exakter Abgleich gegen Bedeutung
Zwei Arten der Suche verhalten sich sehr unterschiedlich:
- Lexikalisches Caching, etwa ein gewöhnlicher Key-Value-Store in Redis, braucht identische Strings. "Wo ist die Bestellung?" ist gecacht, aber "Paket verfolgen" geht daneben, weil die Zeichen andere sind.
- Semantisches Caching wandelt Prompts mit einem Embedding-Modell in hochdimensionale Vektoren um. "Wo ist die Bestellung?" und "Paket verfolgen" liegen im Vektorraum nah beieinander, also kommt die gespeicherte Antwort sofort zurück. Die Latenz der Suche fällt von etwa 3000 ms auf rund 15 ms.
Den Vektor-Cache mit Qdrant einrichten
Als Speicher läuft Qdrant in Docker mit einem persistenten Volume, danach wird eine Python-Umgebung vorbereitet:
# 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
Mandanten sauber trennen
Ein einziger globaler semantischer Cache in einem mandantenfähigen SaaS-Produkt lädt zu ernsten Datenlecks zwischen Kunden ein. Stellen Sie sich vor, Nutzer A bittet um eine Übersicht der letzten Transaktionen auf seinem Konto. Das Modell schreibt eine Antwort mit den privaten Finanzdaten von A, und sie wird gecacht. Später bittet Nutzer B um eine Zusammenfassung seiner eigenen Transaktionen. Der Cache sieht eine Übereinstimmung der Absicht von 98% und gibt B die Antwort, die für A geschrieben wurde.
Die Abhilfe ist ein strikter Namensraum, tenant_id, im Qdrant-Payload, und jede Ähnlichkeitssuche läuft unter einem harten Metadatenfilter. Ohne diesen Filter darf eine Suche gar nicht erst ausgeführt werden. Der folgende Code legt die Collection mit der richtigen Vektorgröße an (384 für den lokalen MiniLM-Encoder) und führt eine nach Mandant gefilterte Suche aus:
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
Die Schwellenwert-Falle
Die Wahl der Ähnlichkeitsschwelle ist eine Abwägung zwischen Präzision und Recall. Viele Anleitungen empfehlen pauschal den Wert 0,80, und das führt zu unangenehmen falschen Antworten. Nähe im Embedding-Raum heißt nicht gleiche Bedeutung. "Nenne die Hauptstadt von Frankreich" und "Nenne die Hauptstadt von Deutschland" haben denselben Satzbau, liegen also sehr dicht beieinander, mit einer Kosinus-Ähnlichkeit über 0,85. Bei einer Schwelle von 0,80 bekäme jemand, der nach Deutschland fragt, die gecachte Antwort zu Frankreich.
Stimmen Sie die Schwelle stattdessen pro Route ab:
- 0,95 und höher für strikt sachliche, technische oder finanzielle Fragen
- 0,88 für allgemeine FAQs im Gesprächston
Wenn die Plattengeschwindigkeit zum Engpass wird
Vektordatenbanken auf Basis von HNSW-Indizes beanspruchen Speicher- und Plattendurchsatz stark. Schwere Ähnlichkeitssuche in einer Public Cloud bedeutet meist Block-Storage mit bereitgestellten IOPS, und diese io2-Kosten können hoch ausfallen.
Ein Ausweg ist, den gesamten Cache-Stack auf dedizierten Bare-Metal-Servern zu betreiben. Direkt angebundene Enterprise-NVMe-Laufwerke über PCIe liefern Millionen IOPS ohne Aufpreis, und ein großer Pool an DDR5-RAM auf dedizierter Hardware kann Embedding-Abfragen selbst bei viel gleichzeitigem Verkehr unter 15 ms halten. Damit fällt die Speichersteuer weg, die Cloud-Anbieter auf die Rechenleistung aufschlagen.