Semantic Caching for LLM Applications
Reduce LLM API costs and latency by caching responses based on semantic similarity rather than exact string matching.
What you'll learn
- ✓Why exact-match caching fails for LLM applications
- ✓How semantic caching uses embeddings to match similar queries
- ✓How to build a semantic cache with Redis and FAISS
- ✓How to set similarity thresholds and handle cache invalidation
Prerequisites
- •Basic Python knowledge
- •Familiarity with LLM APIs and embeddings
Why Traditional Caching Falls Short
Traditional caching uses exact key matching. If a user asks “What is Docker?” and you cache the response, a second user asking “What’s Docker?” gets a cache miss. “Explain Docker to me” is another miss. “Can you tell me about Docker?” is yet another. The semantic meaning is identical, but the strings are different.
In LLM applications, this pattern is common. Users phrase the same question in many ways. Without semantic caching, you pay for a new API call every time, even though you already have a perfectly good answer.
Semantic caching solves this by comparing the meaning of queries rather than their text. If a new query is semantically similar enough to a cached query, return the cached response.
How Semantic Caching Works
The flow is straightforward:
- Convert the incoming query into an embedding vector.
- Search the cache for vectors within a similarity threshold.
- If a match is found, return the cached response.
- If no match, call the LLM, cache the query embedding and response, and return the response.
from sentence_transformers import SentenceTransformer
import numpy as np
class SemanticCache:
def __init__(self, similarity_threshold: float = 0.92):
self.encoder = SentenceTransformer("all-MiniLM-L6-v2")
self.threshold = similarity_threshold
self.cache: list[dict] = [] # Simple list-based cache for illustration
def _embed(self, text: str) -> np.ndarray:
return self.encoder.encode(text, normalize_embeddings=True)
def _cosine_similarity(self, a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b))
def get(self, query: str) -> str | None:
query_embedding = self._embed(query)
best_score = 0.0
best_response = None
for entry in self.cache:
score = self._cosine_similarity(query_embedding, entry["embedding"])
if score > best_score:
best_score = score
best_response = entry["response"]
if best_score >= self.threshold:
return best_response
return None
def put(self, query: str, response: str):
embedding = self._embed(query)
self.cache.append({
"query": query,
"embedding": embedding,
"response": response,
})
# Usage
cache = SemanticCache(similarity_threshold=0.92)
cache.put("What is Docker?", "Docker is a containerization platform that...")
# These will all hit the cache
print(cache.get("What's Docker?")) # Hit
print(cache.get("Explain Docker to me")) # Hit
print(cache.get("How does Kubernetes work?")) # Miss (different topic)
Scaling with FAISS
The list-based approach above scans every cached entry linearly. With thousands of cached queries, this becomes slow. FAISS (Facebook AI Similarity Search) provides fast approximate nearest neighbor search.
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
class FAISSSemanticCache:
def __init__(self, similarity_threshold: float = 0.92, dimension: int = 384):
self.encoder = SentenceTransformer("all-MiniLM-L6-v2")
self.threshold = similarity_threshold
self.dimension = dimension
# FAISS index for cosine similarity (using inner product on normalized vectors)
self.index = faiss.IndexFlatIP(dimension)
self.responses: list[str] = []
self.queries: list[str] = []
def _embed(self, text: str) -> np.ndarray:
vec = self.encoder.encode(text, normalize_embeddings=True)
return vec.reshape(1, -1).astype("float32")
def get(self, query: str) -> str | None:
if self.index.ntotal == 0:
return None
query_vec = self._embed(query)
scores, indices = self.index.search(query_vec, k=1)
if scores[0][0] >= self.threshold:
return self.responses[indices[0][0]]
return None
def put(self, query: str, response: str):
vec = self._embed(query)
self.index.add(vec)
self.queries.append(query)
self.responses.append(response)
@property
def size(self) -> int:
return self.index.ntotal
FAISS handles millions of vectors efficiently. For even larger scales, use IndexIVFFlat with clustering for sublinear search time.
Production Cache with Redis
For production deployments, you need persistence, TTL, and multi-process access. Redis with its vector search module handles all of this.
import redis
import json
import hashlib
import numpy as np
from sentence_transformers import SentenceTransformer
class RedisSemanticCache:
def __init__(
self,
redis_url: str = "redis://localhost:6379",
similarity_threshold: float = 0.92,
ttl_seconds: int = 3600,
prefix: str = "sem_cache",
):
self.redis = redis.from_url(redis_url)
self.encoder = SentenceTransformer("all-MiniLM-L6-v2")
self.threshold = similarity_threshold
self.ttl = ttl_seconds
self.prefix = prefix
self._ensure_index()
def _ensure_index(self):
"""Create the RediSearch index for vector similarity."""
try:
self.redis.execute_command(
"FT.CREATE", f"{self.prefix}:idx",
"ON", "HASH",
"PREFIX", "1", f"{self.prefix}:entry:",
"SCHEMA",
"query", "TEXT",
"response", "TEXT",
"embedding", "VECTOR", "FLAT", "6",
"TYPE", "FLOAT32",
"DIM", "384",
"DISTANCE_METRIC", "COSINE",
)
except redis.ResponseError:
pass # Index already exists
def _embed(self, text: str) -> bytes:
vec = self.encoder.encode(text, normalize_embeddings=True)
return vec.astype(np.float32).tobytes()
def get(self, query: str) -> str | None:
query_vec = self._embed(query)
results = self.redis.execute_command(
"FT.SEARCH", f"{self.prefix}:idx",
f"*=>[KNN 1 @embedding $vec AS score]",
"PARAMS", "2", "vec", query_vec,
"RETURN", "2", "response", "score",
"SORTBY", "score",
"DIALECT", "2",
)
if results[0] > 0:
# Parse results - score is cosine distance, lower is better
score = float(results[2][3])
if score <= (1 - self.threshold): # Convert distance to similarity
response = results[2][1].decode() if isinstance(results[2][1], bytes) else results[2][1]
return response
return None
def put(self, query: str, response: str):
key = f"{self.prefix}:entry:{hashlib.md5(query.encode()).hexdigest()}"
embedding = self._embed(query)
self.redis.hset(key, mapping={
"query": query,
"response": response,
"embedding": embedding,
})
self.redis.expire(key, self.ttl)
Integrating with Your LLM Client
Wrap your LLM client with the cache layer.
from openai import OpenAI
class CachedLLMClient:
def __init__(self, cache: FAISSSemanticCache, model: str = "gpt-4o"):
self.cache = cache
self.client = OpenAI()
self.model = model
self.stats = {"hits": 0, "misses": 0}
def chat(self, query: str, system_prompt: str = "") -> str:
# Check cache
cache_key = f"{system_prompt}|||{query}" if system_prompt else query
cached = self.cache.get(cache_key)
if cached is not None:
self.stats["hits"] += 1
return cached
# Cache miss - call LLM
self.stats["misses"] += 1
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": query})
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
)
answer = response.choices[0].message.content
# Store in cache
self.cache.put(cache_key, answer)
return answer
@property
def hit_rate(self) -> float:
total = self.stats["hits"] + self.stats["misses"]
return self.stats["hits"] / total if total > 0 else 0.0
# Usage
cache = FAISSSemanticCache(similarity_threshold=0.92)
llm = CachedLLMClient(cache)
# First call - cache miss
answer1 = llm.chat("What is Docker?")
# Second call - cache hit (semantically similar)
answer2 = llm.chat("Explain what Docker is")
print(f"Hit rate: {llm.hit_rate:.1%}")
print(f"Cache size: {cache.size}")
Choosing the Right Similarity Threshold
The threshold controls the tradeoff between cache hits and answer quality.
- 0.98+: Very conservative. Only near-identical queries match. Low hit rate but virtually no wrong answers served.
- 0.92-0.97: Good default range. Catches common paraphrases while avoiding semantic drift.
- 0.85-0.91: Aggressive. Higher hit rate but risk of serving answers to meaningfully different questions.
Test with your actual query distribution. Collect query pairs that should and should not match, then find the threshold that correctly classifies most of them.
# Evaluate threshold with labeled pairs
test_pairs = [
("What is Docker?", "Explain Docker", True),
("What is Docker?", "How to install Docker", False),
("Python async await", "How does async/await work in Python", True),
("Python async await", "JavaScript async await", False),
]
for threshold in [0.85, 0.90, 0.92, 0.95, 0.98]:
correct = 0
for q1, q2, should_match in test_pairs:
cache = SemanticCache(similarity_threshold=threshold)
cache.put(q1, "cached response")
matched = cache.get(q2) is not None
if matched == should_match:
correct += 1
print(f"Threshold {threshold}: {correct}/{len(test_pairs)} correct")
Cache Invalidation
Cached LLM responses can become stale. A cached answer about “the latest Python version” ages quickly. Strategies for invalidation:
TTL-based: Set an expiration time on each entry. Good for general knowledge queries where freshness matters.
Version-based: Include a version key in the cache. When you update your system prompt or model, bump the version to invalidate all entries.
Topic-based: Tag cached entries by topic. When you know a topic’s information has changed, invalidate entries with that tag.
class VersionedCache(FAISSSemanticCache):
def __init__(self, version: str = "v1", **kwargs):
super().__init__(**kwargs)
self.version = version
def get(self, query: str) -> str | None:
return super().get(f"{self.version}:{query}")
def put(self, query: str, response: str):
super().put(f"{self.version}:{query}", response)
Cost Savings
The economics of semantic caching are compelling. A GPT-4o call costs roughly $0.01-0.05 depending on input and output length. An embedding computation with a local model costs effectively nothing. With a 40% cache hit rate on 100,000 daily queries, you save 40,000 API calls per day.
The embedding model itself runs locally and processes thousands of queries per second on a single CPU. The bottleneck is never the cache lookup.
Summary
Semantic caching matches queries by meaning rather than exact text, dramatically improving cache hit rates for LLM applications. Use embedding models to convert queries to vectors, FAISS for fast similarity search at scale, and Redis for production persistence with TTL. Set your similarity threshold between 0.92 and 0.97 as a starting point and tune based on your query distribution. Include the system prompt in your cache key to avoid cross-context contamination. Implement TTL or version-based invalidation for time-sensitive content.
Related articles
- AI Function Calling Patterns Across LLM Providers
Compare function calling implementations across OpenAI, Anthropic, and Google, with patterns for routing, chaining, and error handling.
- AI AI Inference Optimization Techniques
Reduce latency and cost of AI model inference with batching, quantization, caching, and request routing strategies.
- AI Structured Output Extraction with LLMs
Extract structured data from unstructured text using LLMs with JSON mode, Pydantic validation, and provider-specific structured output APIs.
- AI Synthetic Data Generation with LLMs
Use large language models to generate high-quality synthetic datasets for training, testing, and evaluating AI systems.