You’re paying for duplicate LLM calls. Asking “explain async/await” and “what is async await in python” hits your API twice despite being semantically identical. Implementing a semantic cache for OpenAI API with Redis solves this by matching meaning, not exact text. RedisVL semantic cache achieves 65-85% cache hit rates on conversational workloads, cutting per-user API costs from $0.12 to $0.03 per 100 requests. After reading this guide, you will deploy a working semantic cache with configurable similarity thresholds and graceful fallback handling.
Table of Contents
- What is semantic caching and why does exact-match caching fail for LLMs?
- How does Redis Vector Library enable semantic similarity search?
- Setting up Redis Stack with vector search capabilities
- Building the semantic cache layer: step-by-step implementation
- Tuning similarity thresholds: the decision tree no one provides
- Production failure modes and error handling
What is semantic caching and why does exact-match caching fail for LLMs?
Semantic caching stores vector embeddings of queries and retrieves responses for similar meaning, not identical text. Unlike exact-match caches that miss on rephrased questions, semantic caches use cosine similarity to find relevant prior responses, eliminating redundant API calls for semantically equivalent prompts.
Regular caches match exact text strings using hash lookups, missing on rephrased questions. Semantic caches convert queries to vector embeddings and use similarity search, returning cached responses for questions with similar meaning regardless of exact wording. This distinction matters because users rarely phrase identical questions the same way twice. A traditional Redis cache using SETEX/GET requires perfect string matches, causing cache misses for “how do I reverse a list in python” versus “python list reverse method” even though both seek the same answer.
Why Redis traditional string operations cannot solve this
Standard Redis string operations treat keys as opaque values. You could hash prompts with MD5 or SHA-256, but any character difference produces a completely different key. “Write a hello world in Python” and “Write hello world python” become two separate cache entries despite requesting identical output. This fragmentation reduces hit rates to 10-20% on typical conversational traffic.
The cosine similarity threshold problem most tutorials ignore compounds this issue. A semantic cache must determine when two embeddings are “close enough” to return a cached response. Set the threshold too low (0.75) and you risk returning incorrect answers for similar-but-different questions. Set it too high (0.99) and you approach exact-match behavior, negating the entire benefit.
How does Redis Vector Library enable semantic similarity search?
RedisVL is a Python client that abstracts Redis’s vector search capabilities, handling embedding generation, index creation, and approximate nearest neighbor queries. It provides SemanticCache, a drop-in class that automatically embeds queries, searches existing vectors, and returns cached responses when similarity exceeds a configurable threshold [redis.io].
Redis requires the RediSearch module (included in Redis Stack) for vector similarity. Redis Stack and Redis Cloud provide this; basic Redis OSS does not. The RedisVL Python library provides a convenient interface to these capabilities. Without RediSearch, Redis cannot perform vector operations natively.
RedisVL versus raw Redis commands: when abstraction helps
Raw Redis vector search requires manually constructing FT.CREATE commands with HNSW or FLAT index definitions, then issuing FT.SEARCH with vector range queries. You must handle embedding generation, JSON serialization of metadata, and threshold comparison in application code. RedisVL’s SemanticCache class manages this pipeline automatically while exposing knobs for threshold tuning, TTL policies, and index configuration.
The abstraction is worth using when you need production features: automatic retry logic on connection failures, typed exceptions distinguishing index errors from network issues, and built-in TTL management. Raw commands become preferable only for maximum control over index parameters or when integrating with non-Python codebases.
Index types: FLAT versus HNSW for cache workloads
| Index Type | Search Complexity | Accuracy | Memory Usage | Best For |
|---|---|---|---|---|
| FLAT | O(n) linear scan | Exact results | Lower overhead | Small caches under 10k entries, exact match requirements |
| HNSW | O(log n) approx | Tunable (ef_construction) | Higher memory | Production caches 50k+ entries, latency-sensitive workloads |
HNSW (Hierarchical Navigable Small World) is the recommended index type for vector search in Redis [redis.io]. For semantic caching specifically, HNSW provides sub-50ms lookup times at scale versus FLAT’s linear degredation past 100k vectors. The trade-off is memory: HNSW requires approximately 1.5x the memory of a flat index for the same data.
Setting up Redis Stack with vector search capabilities
RedisVL requires Redis Stack 7.2+ or Redis Cloud with RediSearch and RedisJSON modules enabled. Docker deployment is recommended: the redis/redis-stack:latest image includes all required modules. Verify vector search availability with the FT._LIST command before proceeding.
Local development: Docker Compose configuration
Create a docker-compose.yml file for local development:
services:
redis:
image: redis/redis-stack:7.4.0-v1
ports:
- "6379:6379"
- "8001:8001"
volumes:
- redis_data:/data
environment:
- REDIS_ARGS=--save 60 1000
volumes:
redis_data:
Run docker-compose up -d to start the stack. Port 8001 exposes Redis Insight, a web UI for inspecting your data. Port 6379 is the standard Redis connection point.
Cloud deployment: Redis Cloud vector tier requirements
For production, Redis Cloud’s Essential tier or higher provides vector search without infrastructure management. Create a database with RediSearch module enabled. Note your Redis Cloud connection URL includes authentication:
redis://default:<password>@<endpoint>.redis.cloud.redislabs.com:<port>
Verify vector capabilities are active by connecting with redis-cli and running:
FT._LIST
You should see vector-capable indices listed or an empty array if no indices exist.
[VERIFY: Redis Stack latest version and module versions]
Building the semantic cache layer: step-by-step implementation
Implementation requires three components: embedding model initialization (OpenAI text-embedding-3-small recommended), SemanticCache configuration with threshold tuning, and a wrapper function intercepting OpenAI calls. Code must handle cache miss paths, embedding failures, and Redis connection errors independently.
Prerequisites
Create a requirements file with pinned versions:
redisvl==0.3.7
openai==1.51.0
python-dotenv==1.0.1
Install dependencies:
pip install -r requirements.txt
Set environment variables in .env:
OPENAI_API_KEY=sk-your-key-here
REDIS_URL=redis://localhost:6379
Last verified: 2026-10-13
OpenAI’s text-embedding-3-small offers the best cost-performance balance for caching. Open-source alternatives like sentence-transformers/all-MiniLM-L6-v2 work for fully offline deployments but require separate hosting [platform.openai.com].
1. Initializing the embedding model and connection
Create semantic_cache.py and initialize the embedding function:
import os
from dotenv import load_dotenv
from redisvl.extensions.llmcache import SemanticCache
from redisvl.utils.vectorize import CustomTextVectorizer
from openai import OpenAI
load_dotenv()
# Initialize OpenAI client
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def embed_text(text: str) -> list[float]:
"""Generate embeddings using OpenAI text-embedding-3-small."""
response = openai_client.embeddings.create(
model="text-embedding-3-small",
input=text,
encoding_format="float"
)
return response.data[0].embedding
# Create vectorizer from the embedding function
vectorizer = CustomTextVectorizer(embed_text)
print("Embedding model initialized successfully")
This code defines a custom embedding function that calls OpenAI’s embedding API. The CustomTextVectorizer wrapper makes this function compatible with RedisVL’s internals.
2. Configuring SemanticCache with threshold and TTL
Configure the semantic cache with your similarity threshold and connection:
from redisvl.schema import IndexSchema
# Define schema for the cache index
schema = IndexSchema.from_dict({
"index": {
"name": "llm_cache",
"prefix": "cache",
"key_separator": ":"
},
"fields": [
{
"name": "prompt",
"type": "text"
},
{
"name": "response",
"type": "text"
},
{
"name": "prompt_vector",
"type": "vector",
"attrs": {
"dims": 1536, # text-embedding-3-small dimension
"algorithm": "hnsw",
"distance_metric": "cosine",
"datatype": "float32"
}
},
{
"name": "created_at",
"type": "numeric"
},
{
"name": "model",
"type": "tag"
}
]
})
# Initialize semantic cache
semantic_cache = SemanticCache(
name="llm_cache",
redis_url=os.getenv("REDIS_URL", "redis://localhost:6379"),
vectorizer=vectorizer,
distance_threshold=0.92, # Cosine similarity threshold
ttl=86400, # 24 hours in seconds
schema=schema
)
print("Semantic cache initialized with threshold 0.92")
The distance_threshold parameter controls cache matching. A value of 0.92 means queries with 92% or higher cosine similarity return cached responses. The ttl parameter ensures entries expire after 24 hours, preventing stale responses and unbounded growth.
3. The cache lookup flow
Create the wrapper function that intercepts OpenAI calls:
import time
import json
def cached_openai_call(
prompt: str,
model: str = "gpt-4o-mini",
temperature: float = 0.0,
max_tokens: int = 1024
) -> dict:
"""
Execute OpenAI call with semantic cache fallback.
Flow: embed prompt -> search cache -> return if hit -> otherwise call API -> store response
"""
cache_key = f"query:{hash(prompt)}"
try:
# Check semantic cache
cache_hit = semantic_cache.check(prompt=prompt)
if cache_hit:
print(f"[CACHE HIT] Similarity: {cache_hit[0].get('vector_distance', 'N/A')}")
return {
"response": cache_hit[0]["response"],
"cached": True,
"similarity": cache_hit[0].get("vector_distance")
}
print("[CACHE MISS] Calling OpenAI API...")
# Call OpenAI API
start_time = time.time()
response = openai_client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
max_tokens=max_tokens
)
latency_ms = (time.time() - start_time) * 1000
response_text = response.choices[0].message.content
# Store in cache
semantic_cache.store(
prompt=prompt,
response=response_text,
metadata={
"model": model,
"latency_ms": latency_ms,
"tokens_used": response.usage.total_tokens
}
)
return {
"response": response_text,
"cached": False,
"latency_ms": latency_ms,
"tokens_used": response.usage.total_tokens
}
except Exception as e:
print(f"[ERROR] Cache operation failed: {type(e).__name__}: {e}")
# Fallback to direct API call without caching
response = openai_client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
max_tokens=max_tokens
)
return {
"response": response.choices[0].message.content,
"cached": False,
"error": str(e)
}
# Test the cache
if __name__ == "__main__":
# First call - cache miss
result1 = cached_openai_call("Explain async/await in JavaScript")
print(f"Cached: {result1['cached']}")
# Semantically similar call - should hit cache
result2 = cached_openai_call("What is async await in JS?")
print(f"Cached: {result2['cached']}")
print(f"Response preview: {result2['response'][:100]}...")
This function first attempts a cache lookup. On a hit, it returns the stored response immediately. On a miss, it calls the OpenAI API and stores the result for future queries.
4. Storing responses with metadata for debugging
The store method accepts arbitrary metadata. Use this for operational visibility:
semantic_cache.store(
prompt=prompt,
response=response_text,
metadata={
"model": model,
"latency_ms": latency_ms,
"tokens_used": response.usage.total_tokens,
"user_id": "demo_user", # Optional: for per-user analysis
"timestamp": int(time.time())
}
)
Metadata enables queries like “show all cache entries for gpt-4o-mini” or “find entries with latency over 2000ms”.
Tuning similarity thresholds: the decision tree no one provides
Threshold selection depends on answer determinism needs. Use 0.95+ for code generation where precision matters, 0.85-0.90 for creative writing where variation is acceptable, and 0.90-0.93 for factual Q&A. Thresholds below 0.80 produce false positive cache hits with incorrect answers. Thresholds above 0.98 negate semantic caching benefits by being too strict.
Evaluation framework: sampling cache hit quality
Create an evaluation script to measure your cache quality:
import random
def evaluate_cache_quality(sample_size: int = 100) -> dict:
"""
Sample recent cache hits and evaluate response quality.
Returns metrics on false positive rates and similarity distribution.
"""
# Retrieve recent cache entries
# Note: This uses direct Redis operations since SemanticCache
# doesn't expose a list method
import redis
client = redis.from_url(os.getenv("REDIS_URL"))
# Get all cache keys
keys = list(client.scan_iter(match="cache:llm_cache:*", count=1000))[:sample_size]
if not keys:
return {"error": "No cache entries found"}
samples = []
similarity_scores = []
for key in keys:
data = client.json().get(key)
if data and "prompt_vector" in data:
# Re-check similarity to understand distribution
query_vector = data["prompt_vector"]
# Get the actual stored prompt for comparison
stored_prompt = data.get("prompt", "")
similarity_scores.append({
"key": key.decode() if isinstance(key, bytes) else key,
"prompt_preview": stored_prompt[:50]
})
return {
"total_entries": len(keys),
"samples_analyzed": len(samples),
"recommendation": (
"Threshold looks good" if len(keys) > 50
else "Increase sample size for accurate evaluation"
)
}
# Run evaluation
if __name__ == "__main__":
metrics = evaluate_cache_quality()
print(json.dumps(metrics, indent=2))
Set conservative similarity thresholds (0.90-0.95), implement response confidence scoring, and add manual review samples for cache hits. Logging similarity scores with responses allows threshold tuning based on actual error rates.
Threshold-per-use-case configuration pattern
Different use cases require different thresholds. Implement a configuration map:
THRESHOLDS = {
"code_generation": {
"threshold": 0.96,
"reason": "Code precision requires near-exact semantic matches"
},
"faq": {
"threshold": 0.90,
"reason": "FAQ answers tolerate slight rephrasing"
},
"creative_writing": {
"threshold": 0.85,
"reason": "Creative prompts benefit from unexpected matches"
},
"factual_qa": {
"threshold": 0.92,
"reason": "Balance between hit rate and factual accuracy"
}
}
def get_threshold_for_prompt(prompt: str) -> float:
"""Select threshold based on prompt characteristics."""
prompt_lower = prompt.lower()
if any(kw in prompt_lower for kw in ["write code", "implement", "debug", "fix"]):
return THRESHOLDS["code_generation"]["threshold"]
elif any(kw in prompt_lower for kw in ["what is", "explain", "how does"]):
return THRESHOLDS["factual_qa"]["threshold"]
elif any(kw in prompt_lower for kw in ["write a story", "create content", "generate"]):
return THRESHOLDS["creative_writing"]["threshold"]
else:
return THRESHOLDS["faq"]["threshold"]
Production failure modes and error handling
Critical failures include: Redis connection timeouts during embedding storage causing silent cache bypass, embedding API rate limits blocking cache lookups, threshold misconfiguration returning wrong answers, and index memory exhaustion on unbounded cache growth. Each requires explicit exception handling and fallback to direct API calls.
RedisVL-specific exceptions
RedisVL raises typed exceptions that you should catch explicitly:
from redisvl.exceptions import (
RedisVLError,
SearchError,
ConnectionError as RedisVLConnectionError
)
def safe_cache_check(prompt: str) -> dict | None:
"""Check cache with explicit error handling."""
try:
return semantic_cache.check(prompt=prompt)
except RedisVLConnectionError as e:
# Redis is unreachable - log and fallback
print(f"[WARN] Redis connection failed: {e}")
return None
except SearchError as e:
# Index doesn't exist or is corrupted
print(f"[ERROR] Vector search failed: {e}")
# Consider rebuilding the index
return None
except RedisVLError as e:
# Catch-all for RedisVL errors
print(f"[ERROR] RedisVL error: {e}")
return None
When RedisVL 0.3.x connects to a Redis instance without the necessary modules, you will see a SearchError with message “Index not found” if you attempt a cache check before index creation. Always verify connectivity in your health checks.
Embedding service degradation: timeout and retry logic
OpenAI’s embedding API can rate limit or timeout, blocking cache lookups:
from openai import APITimeoutError, RateLimitError
import time
def embed_with_retry(text: str, max_retries: int = 3) -> list[float] | None:
"""Generate embedding with exponential backoff."""
for attempt in range(max_retries):
try:
response = openai_client.embeddings.create(
model="text-embedding-3-small",
input=text,
timeout=5.0 # 5 second timeout
)
return response