Sémantické cachování LLM v Pythonu: Snižte náklady o 40-70 % pomocí Redis a embeddings

Praktický průvodce produkční sémantickou cache pro LLM v Pythonu. Dvouvrstvá architektura s Redis a embeddings, ladění similarity threshold, monitoring false positive rate a reálné case studies s úsporou 40-70 %.

Sémantická cache LLM Python: Setup 2026

Účty za LLM API rostou tempem, které začíná být upřímně řečeno trochu děsivé — globální výdaje za LLM API se mezi koncem roku 2024 a polovinou roku 2025 zvedly z 3,5 na 8,4 miliardy dolarů a 72 % organizací plánuje pro rok 2026 další navýšení rozpočtů. A přitom: 30–60 % dotazů v typickém produkčním systému jsou jen přeformulované varianty stejných sta otázek. Sémantické cachování je jedna z mála optimalizací, která dokáže náklady seškrtat o 40–70 % bez zásahu do kvality odpovědí — a v Pythonu ji nasadíte za odpoledne práce.

V tomhle průvodci si ukážeme, jak postavit produkční sémantickou cache v Pythonu pomocí Redis a embeddings, jak správně vyladit similarity threshold (kritický krok, který odlišuje fungující systém od tichého selhání) a jak měřit reálný dopad. Tutoriál navazuje na předchozí díly o function calling, strukturovaných výstupech a observabilitě LLM aplikací — semantic cache je další stavební kámen produkčně vyzrálé AI architektury.

Proč klasická cache pro LLM nefunguje

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ů:

  1. Exact-match vrstva — rychlý hash lookup (Redis GET) na normalizovaný prompt. Latence ~1 ms, žádné náklady na embedding.
  2. 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).
  3. 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áhChováníVhodné pro
0,85Agresivní — vysoký hit rate, vyšší false positiveMarketingový copywriting, brainstorming
0,92Vyvážené — sweet spot pro produkciFAQ boty, helpdesk, dokumentace
0,98Konzervativní — téměř exact matchLé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:

  1. Krátké TTL pro volatilní obsah — produktové popisky 24 h, dokumentace 7 dní, statické FAQ 30 dní.
  2. 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).
  3. 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ě.

Article changelog (1)
  • — SEO meta refreshed (title and description updated)
Editorial Team
O Autorovi Editorial Team

Our team of expert writers and editors.