Standardní in-memory nebo Redis cache postavená na exact-match klíči (např. SHA-256 hash promptu) selže pokaždé, když uživatel přeformuluje dotaz. Ukázkový případ:
"Jak resetuji heslo?"
"Postup pro reset hesla"
"Zapomněl jsem heslo, co dál?"
Z pohledu uživatele i odpovědi jsou ty tři dotazy v podstatě identické. Z pohledu hash funkce jsou to ovšem tři různé klíče — cache miss, tři volání LLM, tři účty. Studie GPT Semantic Cache ukazuje, že u FAQ workloadů je až 31 % požadavků sémanticky duplicitních, ale liší se ve formulaci. Exact-match cache je při téhle variabilitě v podstatě bezcenná.
Sémantická cache místo textové shody měří blízkost ve vektorovém prostoru. Prompt se převede na embedding, hledá se nejpodobnější uložený embedding a pokud je kosinová podobnost nad nastaveným prahem, vrátí se cachovaná odpověď. Tím rozeznáme parafráze a snížíme počet skutečných volání LLM API.
Jak sémantická cache funguje (architektura)
Produkční sémantická cache se skládá ze tří vrstev a vždy se kontroluje shora dolů:
- Exact-match vrstva — rychlý hash lookup (Redis
GET) na normalizovaný prompt. Latence ~1 ms, žádné náklady na embedding.
- Sémantická vrstva — pokud miss, spočítá se embedding a v Redis vector indexu se vyhledá nejbližší dotaz. Latence 20–30 ms (z toho ~22 ms padne na generování embeddingu).
- LLM volání — pokud nic neprošlo, jde se na model a výsledek se uloží do obou vrstev pro příště.
Při benchmarcích na GPT-4o (5 USD / 1M vstupních tokenů, 15 USD / 1M výstupních tokenů, ceník Q1 2026) trvá běžné API volání kolem 7 sekund. Cachovaná odpověď se vrací za 25 ms. To je 280× rychlejší a u 60% hit-rate workloadu s 1M požadavků/den úspora kolem 850 USD měsíčně. Rozdíl, který si CFO všimne.
Instalace a nastavení prostředí
Použijeme FastAPI jako API vrstvu, Redis Stack (obsahuje vektorový engine RediSearch), Sentence Transformers pro lokální embeddings (žádné dodatečné API náklady) a OpenAI SDK pro samotný LLM. Pokud potřebujete běžet vše v Dockeru, Redis Stack je nejjednodušší volba — žádné dodatečné moduly, žádné kompilování.
pip install fastapi uvicorn redis sentence-transformers openai numpy python-dotenv
# Spuštění Redis Stack lokálně
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest
V .env souboru:
OPENAI_API_KEY=sk-...
REDIS_URL=redis://localhost:6379
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
SIMILARITY_THRESHOLD=0.92
CACHE_TTL_SECONDS=86400
Volba embedding modelu
all-MiniLM-L6-v2 má 384 dimenzí, běží na CPU a vygeneruje embedding za 5–10 ms. Pro lepší přesnost použijte BAAI/bge-large-en-v1.5 (1024 dim) nebo text-embedding-3-small od OpenAI (1536 dim) — placený, ale v multilingvních scénářích výrazně lepší. Pro češtinu osobně doporučuji multilingual modely jako intfloat/multilingual-e5-large, které řeší diakritiku i ohýbání slov. Měl jsem zkušenost, kdy MiniLM házel 0,72 podobnost na očividné parafráze typu „kolik stojí" vs. „jaká je cena" — multilingual e5 to samé dotáhl na 0,93. Stojí to za ten větší model.
Kompletní implementace v Pythonu
Následující třída SemanticCache implementuje obě vrstvy, normalizaci embeddingů (kritické pro správnou kosinovou podobnost) a TTL. Index v Redis používá HNSW algoritmus pro rychlý ANN lookup.
import hashlib
import json
import time
from typing import Optional
import numpy as np
import redis
from redis.commands.search.field import TagField, TextField, VectorField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
from redis.commands.search.query import Query
from sentence_transformers import SentenceTransformer
class SemanticCache:
def __init__(
self,
redis_url: str = "redis://localhost:6379",
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
threshold: float = 0.92,
ttl: int = 86400,
index_name: str = "llm_cache_idx",
):
self.client = redis.from_url(redis_url, decode_responses=False)
self.model = SentenceTransformer(model_name)
self.dim = self.model.get_sentence_embedding_dimension()
self.threshold = threshold
self.ttl = ttl
self.index_name = index_name
self._ensure_index()
def _ensure_index(self):
try:
self.client.ft(self.index_name).info()
except redis.ResponseError:
schema = (
TextField("prompt"),
TextField("response"),
TagField("model"),
VectorField(
"embedding",
"HNSW",
{
"TYPE": "FLOAT32",
"DIM": self.dim,
"DISTANCE_METRIC": "COSINE",
},
),
)
self.client.ft(self.index_name).create_index(
fields=schema,
definition=IndexDefinition(
prefix=["cache:"], index_type=IndexType.HASH
),
)
def _embed(self, text: str) -> np.ndarray:
# normalize_embeddings=True je KLÍČOVÉ — bez něj kosinové
# vzdálenosti nedávají smysl a podobnosti unikají z [0, 1].
vec = self.model.encode(text, normalize_embeddings=True)
return vec.astype(np.float32)
def _exact_key(self, prompt: str, model: str) -> str:
h = hashlib.sha256(f"{model}:{prompt}".encode()).hexdigest()
return f"exact:{h}"
def get(self, prompt: str, model: str) -> Optional[dict]:
# 1) Exact-match vrstva
exact = self.client.get(self._exact_key(prompt, model))
if exact:
return {"response": exact.decode(), "hit": "exact", "latency_ms": 1}
# 2) Sémantická vrstva
t0 = time.perf_counter()
emb = self._embed(prompt).tobytes()
query = (
Query(f"(@model:{{{model}}})=>[KNN 1 @embedding $vec AS score]")
.sort_by("score")
.return_fields("response", "score", "prompt")
.dialect(2)
)
results = self.client.ft(self.index_name).search(
query, query_params={"vec": emb}
)
latency = int((time.perf_counter() - t0) * 1000)
if results.docs:
doc = results.docs[0]
# Redis vrací COSINE DISTANCE (0 = identické). Podobnost = 1 - distance.
similarity = 1.0 - float(doc.score)
if similarity >= self.threshold:
return {
"response": doc.response,
"hit": "semantic",
"similarity": round(similarity, 4),
"latency_ms": latency,
"matched_prompt": doc.prompt,
}
return None
def set(self, prompt: str, response: str, model: str):
emb = self._embed(prompt).tobytes()
# Exact-match vrstva
self.client.setex(self._exact_key(prompt, model), self.ttl, response)
# Sémantická vrstva
key = f"cache:{hashlib.sha256(f'{model}:{prompt}:{time.time()}'.encode()).hexdigest()[:16]}"
self.client.hset(
key,
mapping={
"prompt": prompt,
"response": response,
"model": model,
"embedding": emb,
},
)
self.client.expire(key, self.ttl)
Integrace s OpenAI klientem ve FastAPI
import os
from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel
from semantic_cache import SemanticCache
app = FastAPI()
cache = SemanticCache(
redis_url=os.getenv("REDIS_URL", "redis://localhost:6379"),
threshold=float(os.getenv("SIMILARITY_THRESHOLD", "0.92")),
)
llm = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
class ChatRequest(BaseModel):
prompt: str
model: str = "gpt-4o-mini"
@app.post("/chat")
async def chat(req: ChatRequest):
cached = cache.get(req.prompt, req.model)
if cached:
return {**cached, "cached": True}
completion = llm.chat.completions.create(
model=req.model,
messages=[{"role": "user", "content": req.prompt}],
)
answer = completion.choices[0].message.content
cache.set(req.prompt, answer, req.model)
return {"response": answer, "cached": False}
Po nasazení uvidíte v /chat odezvě informaci, jestli šlo o exact hit, semantic hit (s konkrétní similarity) nebo plné LLM volání. Tahle metadata jsou základem pro monitoring, který si ukážeme níže — bez nich ladíte naslepo.
Ladění similarity threshold — nejkritičtější parametr
Threshold je v sémantické cache stejně důležitý jako temperatura u LLM. Špatné nastavení neviditelně degraduje produkt: při příliš nízkém prahu vrátíte špatnou cachovanou odpověď s plnou důvěrou a HTTP 200, aniž by si toho někdo všiml. Při příliš vysokém prahu prakticky nic netrefíte a cache je zbytečná.
Distribuce cosine similarity v reálném traffic má bimodální tvar — shluk nad 0,95 (skutečné parafráze) a shluk pod 0,85 (nesouvisející dotazy). Nebezpečná zóna je 0,88–0,94, kde dotazy sice spadají do stejného tématu, ale odpovědi sdílet nemůžou. Tady leží většina produkčních nehod.
Doporučené prahy podle typu workloadu
| Práh | Chování | Vhodné pro |
| 0,85 | Agresivní — vysoký hit rate, vyšší false positive | Marketingový copywriting, brainstorming |
| 0,92 | Vyvážené — sweet spot pro produkci | FAQ boty, helpdesk, dokumentace |
| 0,98 | Konzervativní — téměř exact match | Lékařské/právní AI, finanční nástroje |
Empirické testy v GPT Semantic Cache publikaci (arXiv 2411.05276) ukázaly, že práh 0,8 dosáhl 68,8% hit-rate při 97% positive hit rate na obecných FAQ. Pro většinu produkčních systémů ale začněte na 0,92, monitorujte 48 hodin a posunujte v krocích po 0,01. Žádné velké skoky — distribuce je strmá a 0,90 → 0,85 dokáže zdvojnásobit false positive rate.
Per-kategorie thresholds
Jeden globální práh je téměř vždy špatně. Lepší je rozdělit dotazy do skupin (FAQ, vyhledávání, transakční, personalizované) a nasadit různý threshold:
THRESHOLDS = {
"faq": 0.94, # Přesnost > recall, špatná odpověď bolí
"search": 0.88, # Recall > přesnost, miss stojí peníze
"summarization": 0.95, # Mírný drift v parafrázi je OK
"code_gen": 0.97, # Kód musí sedět přesně
"personalized": None, # Necachovat vůbec
"transactional": None, # Ceny, sazby, real-time data — necachovat
}
def get_threshold(category: str) -> Optional[float]:
return THRESHOLDS.get(category)
Co (NE)cachovat
Některé dotazy do cache prostě nepatří, i když by to technicky šlo. Cachování těchhle kategorií je nejrychlejší cesta k incidentu:
- Personalizované odpovědi — cokoli, co obsahuje user ID, jméno, historii uživatele.
- Časově citlivá data — ceny, kurzovní lístky, počasí, stav skladu.
- Transakční potvrzení — „Vaše objednávka byla odeslána" pro jiného zákazníka by způsobila reklamaci (a nepříjemný hovor s podporou).
- Generativní úkoly s vysokou variabilitou — psaní příběhů, brainstorm 10 jmen pro produkt.
Praktické pravidlo, které si držte v hlavě: pokud by dva uživatele překvapilo, že dostali identickou odpověď, necachujte to.
Monitoring a metriky v produkci
Bez měření je sémantická cache slepá. Tři metriky, které musíte logovat od první sekundy:
- Hit ratio — kolik procent požadavků obsloužila cache. Cíl: 30–60 % po týdnu provozu.
- False positive rate — procento cache hitů, kde uživatel signalizoval špatnou odpověď (thumbs-down, follow-up „to není ono"). Cíl: pod 1 %.
- P50/P99 latence — sledujte odděleně cache hit a cache miss, jinak nuanci ztratíte v průměru.
Pokud už používáte Langfuse (viz předchozí článek o observabilitě), přidejte do tracingu vlastní atributy:
from langfuse import Langfuse
langfuse = Langfuse()
@app.post("/chat")
async def chat(req: ChatRequest):
trace = langfuse.trace(name="chat", input=req.prompt)
cached = cache.get(req.prompt, req.model)
if cached:
trace.update(
metadata={
"cache_hit": cached["hit"],
"similarity": cached.get("similarity"),
"matched_prompt": cached.get("matched_prompt"),
}
)
return {**cached, "cached": True}
# ...zbytek beze změny
V Langfuse pak postavíte dashboard, který koreluje similarity skóre s uživatelským feedbackem — to je jediný spolehlivý způsob, jak najít optimální threshold pro vaši doménu. Nic jiného to neodladí.
Cache invalidace a TTL
Jediný způsob, jak si bez monitoringu zničit důvěru produktu, je nechat starou odpověď v cache moc dlouho. Strategie pro řízení čerstvosti:
- Krátké TTL pro volatilní obsah — produktové popisky 24 h, dokumentace 7 dní, statické FAQ 30 dní.
- Event-based invalidation — při deployi nové verze dokumentace nebo změně promptu invalidujte celou cache (
FLUSHDB v Redis pro čistý reset, nebo prefix-based SCAN + DEL).
- Versioning v klíči — zahrňte
prompt_version a model_version do hash klíče. Při bumpnutí promptu se cache automaticky obnoví bez explicitního flushe.
# Versioning v klíči — bezpečnější než ruční invalidace
PROMPT_VERSION = "v3"
def _exact_key(self, prompt: str, model: str) -> str:
payload = f"{PROMPT_VERSION}:{model}:{prompt}"
return f"exact:{hashlib.sha256(payload.encode()).hexdigest()}"
Alternativy: GPTCache, LiteLLM a managed gateways
Pokud nechcete spravovat vlastní implementaci, existují vyzrálá open-source i komerční řešení:
- GPTCache (Zilliz, MIT licence) — wrapper kolem OpenAI klienta, podporuje SQLite/Redis/PostgreSQL backend a Milvus/FAISS vector store. Integrace ve dvou řádcích, ale méně kontroly nad threshold strategií.
- LangChain RedisSemanticCache — pokud už LangChain používáte, je to nejrychlejší cesta. Trade-off: práh nelze jednoduše měnit per dotaz.
- LiteLLM — proxy pro 100+ LLM providerů, přidává ~8 ms overhead. Postrádá streaming-aware cache a dual-layer strategii.
- Bifrost — open-source gateway v Go, 11 µs overhead, 5000 req/s na jednu instanci. Vhodné pro vysoké zatížení.
- Kong AI Gateway 3.8+ — managed plugin se semantickou cache postavenou nad Redis vector. Dobrá volba, pokud už Kong provozujete.
Vlastní implementace dává smysl, když potřebujete per-kategorie thresholds, custom invalidaci, multi-tenant izolaci nebo specifický embedding model (např. multilingvní pro češtinu). Pro 80 % případů GPTCache nebo LangChain RedisSemanticCache stačí — a ušetří vám týden ladění.
Reálné výsledky a ROI
Provozní data z různých produkčních nasazení (Percona, Portkey, VentureBeat case studies za Q1 2026):
- FAQ chatbot pro e-commerce: 62% hit rate, redukce nákladů 73 %, P99 latence z 4,1 s na 380 ms.
- Helpdesk asistent: 41% hit rate, redukce nákladů 38 %, ROI prvního měsíce ~ 6×.
- Code-completion sidecar: 18% hit rate (vyšší variabilita), úspora ~12 % — i tak se vyplatí.
Při GPT-4o cenách (5 USD vstup / 15 USD výstup za 1M tokenů, Q1 2026) a 1000 dotazů denně po 500 vstupních + 800 výstupních tokenech vychází měsíční účet okolo 435 USD. Při 50% hit rate ze sémantické cache ušetříte zhruba 217 USD měsíčně na jednom workloadu — a to bez jakékoli změny modelu nebo promptu. Nasobte si to počtem workloadů a najednou je to nezanedbatelná položka.
Často kladené otázky (FAQ)
Jaký similarity threshold mám použít pro češtinu?
Pro multilingvní embeddings (multilingual-e5-large, text-embedding-3-large) začněte na 0,90 a snižujte po 0,01, dokud false positive rate nepřekročí 1 %. Čeština má bohatou morfologii a starší modely jako all-MiniLM-L6-v2 mívají na čistě českém textu nižší a méně stabilní skóre — pak je rozumnější práh kolem 0,85, ovšem s pečlivějším monitoringem.
Kdy použít sémantickou cache vs. prompt cache od OpenAI/Anthropic?
Jsou komplementární. Prompt cache (Anthropic, OpenAI) cachuje prefix konverzace a snižuje cenu vstupních tokenů u dlouhých systém promptů — funguje na úrovni LLM provideru. Sémantická cache zachycuje celé dotazy a šetří 100 % nákladů na hit. Nasazení obojího současně je optimální: prompt cache snižuje cenu těch volání, která semantic cache nezachytila.
Jak velký Redis instance potřebuji?
Záleží na embedding dimenzi a TTL. Konkrétně: jeden záznam = ~1,5 KB metadata + 4 bajty × dimenze (např. 1536 dim = 6 KB embedding). Při 100 000 unikátních promptech a 1536-dim embeddings potřebujete zhruba 750 MB. Redis Stack zdarma zvládne miliony záznamů na jedné node při 4 GB RAM. HNSW index má ~10–15% paměťový overhead.
Co když uživatelé pošlou citlivá data v promptu?
Cachované prompty jsou v Redis čitelné — to je závažné z pohledu GDPR i ISO 27001. Tři protiopatření: (1) PII redakce před cachováním (např. knihovna presidio), (2) per-tenant Redis databáze pro multi-tenant systémy, (3) šifrování embeddings a odpovědí na úrovni aplikace pomocí KMS klíče. Necachujte nikdy autorizační tokeny, čísla karet ani healthcare data bez explicitního souhlasu.
Funguje sémantická cache i pro streaming odpovědi?
Ano, ale je třeba si uvědomit specifika. Při cache hit pošlete celou odpověď naráz — uživatel ztratí „typewriter" efekt. Řešení: simulujte streaming klientovi pomocí umělých chunků (např. po 5 tokenech) s krátkou pauzou. Při cache miss streamujte z LLM normálně a kompletní text uložte na konci streamu. LiteLLM tohle neumí, vlastní implementace ano.
Závěr
Sémantická cache je nejvýkonnější optimalizace pro produkční LLM aplikace v roce 2026. Žádná jiná technika neredukuje účet o desítky procent při zachování kvality výstupu. Klíčové body, které si odneste:
- Vždy nasazujte dvouvrstvou architekturu (exact + semantic), ne jen jednu.
- Threshold 0,92 je rozumný start, ale jeden globální práh je téměř vždy špatně — kategorizujte dotazy.
- Bez monitoringu false positive rate jste slepí. Zalogujte similarity skóre u každého hitu.
- Pro češtinu volte multilingvní embedding model.
- Necachujte personalizovaný, transakční nebo časově citlivý obsah.
V dalším díle se podíváme na streaming LLM odpovědí pomocí Server-Sent Events a jak cache zkombinovat se streamingem, aby uživatelské UX bylo plynulé i v cache hit cestě.