Skip to main content

Book 2 · The Applied AI Engineer · 7 min read

Semantic Caching in LLM Apps: How It Works and When to Use It

Exact-string cache hit rate: 3%. Semantic cache at 0.97 threshold: 31%. Semantic cache without invalidation: stale availability answers for 24 hours after catalog update. The working architecture combines three cache layers with invalidation tied to the ingestion pipeline.

Semantic Caching in LLM Apps: How It Works and When to Use It

An LLM call costs money and takes time. When a customer asks "What fabrics does the cotton kurta come in?" and 400 other customers ask functionally the same question that day, paying for 401 LLM calls is wasteful. Semantic caching returns the stored answer when a new query is close enough to a previous one.

The Valid Failure

ShopBot's first caching attempt used exact string matching. Cache key: the full query string. Cache hit rate: 3%. Users phrase the same question differently. "Does the kurta have cotton?" and "Is this kurta made of cotton?" are different cache keys. Same answer. Two LLM calls.

The second attempt: vector similarity cache. Store every query as an embedding. On new query, check cosine similarity against all cached queries. If above 0.95, return the cached answer. Cache hit rate: 31%. LLM calls down by a third on high-traffic days.

Then the catalog was updated. The voile kurta was marked out of stock. The cache held the old availability answer for 24 hours. Customers were told it was available when it was not. The semantic cache was returning stale answers without knowing they were stale.

Caching without invalidation is worse than no cache for time-sensitive data.

The Three-Layer Cache Architecture

Layer 1: Exact match (Redis string)
           ↓ miss
Layer 2: Semantic match (vector similarity ≥ 0.97)
           ↓ miss
Layer 3: Full pipeline (retrieve + generate)
           → store result in both Layer 1 and Layer 2

Each layer has a different purpose:

Layer What it catches Threshold TTL
Exact match Identical query strings 1.0 1 hour
Semantic match Same question, different phrasing ≥ 0.97 1 hour
Full pipeline Everything else

Implementing the Two Cache Layers

import redis
import json
import hashlib
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import uuid

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
embed_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
cache_client = QdrantClient(url="http://localhost:6333")

CACHE_COLLECTION = "query_cache"
SEMANTIC_THRESHOLD = 0.97
TTL_SECONDS = 3600

# Ensure cache collection exists
cache_client.recreate_collection(
    collection_name=CACHE_COLLECTION,
    vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)

def cache_key(query: str) -> str:
    return f"exact:{hashlib.sha256(query.encode()).hexdigest()}"

def check_exact_cache(query: str) -> str | None:
    return r.get(cache_key(query))

def check_semantic_cache(query: str) -> str | None:
    vector = embed_model.encode(query).tolist()
    results = cache_client.search(
        collection_name=CACHE_COLLECTION,
        query_vector=vector,
        limit=1,
        score_threshold=SEMANTIC_THRESHOLD,
    )
    if results:
        return results[0].payload.get("answer")
    return None

def store_in_cache(query: str, answer: str):
    # Exact cache
    r.setex(cache_key(query), TTL_SECONDS, answer)
    # Semantic cache
    vector = embed_model.encode(query).tolist()
    cache_client.upsert(
        collection_name=CACHE_COLLECTION,
        points=[PointStruct(
            id=str(uuid.uuid4()),
            vector=vector,
            payload={"query": query, "answer": answer},
        )],
    )

def cached_chat(query: str) -> tuple[str, str]:
    # Layer 1
    answer = check_exact_cache(query)
    if answer:
        return answer, "exact_cache"

    # Layer 2
    answer = check_semantic_cache(query)
    if answer:
        return answer, "semantic_cache"

    # Layer 3
    chunks = retrieve(query)
    answer = generate(query, chunks)
    store_in_cache(query, answer)
    return answer, "pipeline"

Cache Invalidation: The Hard Part

The semantic cache makes the invalidation problem harder. When a product's availability changes, you cannot invalidate by key — there is no single key for "all queries about this product's availability."

Two invalidation strategies:

Strategy 1 — TTL only (simple, imperfect) Set a short TTL. A 1-hour TTL means stale answers persist for at most 1 hour. Acceptable for slow-changing data (fabric, care instructions). Not acceptable for fast-changing data (availability, price).

Strategy 2 — Invalidation on ingestion event (correct) When a new product batch is ingested or a catalog update runs, flush the semantic cache:

def invalidate_cache_on_ingestion():
    # Clear exact cache keys
    for key in r.scan_iter("exact:*"):
        r.delete(key)
    # Clear semantic cache collection
    cache_client.delete_collection(CACHE_COLLECTION)
    cache_client.recreate_collection(
        collection_name=CACHE_COLLECTION,
        vectors_config=VectorParams(size=384, distance=Distance.COSINE),
    )

This ties cache lifetime to data freshness — the cache is valid exactly as long as the underlying data is valid.

When Semantic Caching Pays Off

Traffic pattern Cache value Risk
High repeat queries, slow-changing data High — 30–50% hit rates common Low
High repeat queries, fast-changing data Medium — short TTL needed Stale answers if TTL too long
Low repeat queries, highly personalised Low — semantic similarity misses Wasted infrastructure
Product availability / price queries Dangerous without ingestion-linked invalidation Wrong answers to buy decisions

ShopBot's measurement after adding the three-layer cache: 31% cache hit rate, average latency on cached requests 12ms vs 380ms for full pipeline. At 7,000 queries on campaign Friday, 2,170 were served from cache. LLM API cost: reduced by 31%.

The Valid Knowledge

  • Exact string match hit rate is ~3%: users never ask exactly the same question twice. A string-match cache is nearly useless for conversational RAG.
  • 0.97 is the semantic threshold, not 0.90: at 0.90, different questions about the same product get the same answer. "Is this kurta available in blue?" and "Is this kurta available in red?" score 0.92 similarity — same answer, wrong color. Threshold must be high enough to prevent cross-query contamination.
  • Cache invalidation must be linked to data updates: a semantic cache with only TTL-based invalidation will serve stale availability and price data to customers. Tie invalidation to your ingestion pipeline.
  • Cache the answer, not the chunks: chunk retrieval changes as the catalog updates. The answer at the time of the original query is what was semantically verified. Cache the final answer string.

This is the concept. The book is the system.

Book 2: The Applied AI Engineer

Articles give you understanding. The book gives you a working system — full production code, RAGAS evaluation scores, and the patterns that hold up at 11 PM when something breaks.