How to Cache LLM Embeddings
Here's the hard truth: your embedding cache is probably a Redis instance with a TTL, and it's leaking money and latency.
I've spent the last three years at SIVARO building data infrastructure for production AI systems. We've deployed embedding caches for clients running RAG pipelines, semantic search, and agentic workflows. The naive approach — hash the text, store the vector, return on hit — works until your corpus grows past a few million documents. Then everything falls apart.
This article will show you exactly how to cache LLM embeddings so you cut costs without wrecking recall. You'll learn the architecture patterns that actually hold up in production, how to evict stale embeddings from cache when your model version bumps, and why a distributed cache for ML serving is a different beast than what you're used to.
What "Caching LLM Embeddings" Actually Means
An embedding is a dense vector — typically 768 to 3072 floats — that represents the semantic meaning of a text chunk. Generating one costs money. At OpenAI's pricing as of mid-2026, text-embedding-3-large runs about $0.13 per 1M tokens. A standard RAG pipeline chunking documents into 512-token pieces will burn through cash fast when you're embedding millions of chunks for the first time.
Caching means storing the input-to-vector mapping so you never call the embedding API twice for the same text.
But here's what most people miss: the cache isn't just about cost. It's about latency. A cold embedding call takes 300-800ms round-trip. A cache hit takes 1-5ms. When you're serving search results to users, that's the difference between snappy and sluggish.
The tricky part isn't the cache itself. It's the versioning.
The Versioning Problem Nobody Warns You About
Embedding models change. OpenAI releases new versions. Open-source models get fine-tuned. Your internal model gets retrained.
When that happens, the vectors change. The entire semantic space shifts. Old embeddings become garbage.
Most teams I talk to hit this the hard way. They cache everything with a simple key like sha256(text), and then one day they upgrade their model, and suddenly their cache returns vectors that claim "dog" is closer to "car" than to "puppy." The whole system silently degrades.
You must include the model version in your cache key.
python
import hashlib
import json
def build_cache_key(text: str, model_name: str, model_version: str) -> str:
"""Include model version so stale embeddings never get served."""
payload = json.dumps({
"text": text,
"model": model_name,
"version": model_version
}, sort_keys=True)
return hashlib.sha256(payload.encode()).hexdigest()
This is non-negotiable. I've seen production outages from this exact mistake — a financial services client upgraded their embedding model and didn't realize their cache was returning old vectors for a week. Their semantic search quality tanked and users noticed immediately.
How to Evict Stale Embeddings from Cache
Now let's talk about eviction, because the versioning key is only half the story. Even with a versioned key, you still need to purge old embeddings. They sit in Redis or whatever store you're using, eating memory and costing you money.
Here's the pattern that works:
Active eviction vs. lazy eviction.
Lazy eviction is what most engineers do first — just remove keys when the model version changes. It seems simple, but it's slow. You're deleting millions of keys one by one while your system is live. It looks like your cache is dead for minutes.
Active eviction is better. You keep a registry of active model versions in a metadata store (we use etcd or just a Postgres table). The cache client checks the registry before serving a hit. If the version doesn't match, the key is ignored and deleted.
python
class VersionedEmbeddingCache:
def __init__(self, redis_client, version_registry):
self.redis = redis_client
self.registry = version_registry
def get(self, text: str, model_name: str):
current_version = self.registry.get_latest_version(model_name)
key = build_cache_key(text, model_name, current_version)
cached = self.redis.get(key)
if cached:
return np.frombuffer(cached, dtype=np.float32)
return None
def purge_old_versions(self, model_name: str, old_version: str):
# Delete all keys with the old version prefix
pattern = f"emb:{model_name}:{old_version}:*"
cursor = 0
while True:
cursor, keys = self.redis.scan(cursor, match=pattern, count=1000)
if keys:
self.redis.delete(*keys)
if cursor == 0:
break
The purge_old_versions method uses SCAN instead of KEYS because KEYS will block your Redis instance on large datasets. That's a lesson I learned the hard way at SIVARO when we tried to purge 12 million keys with KEYS and brought a production Redis to its knees for 90 seconds.
Choosing the Right Cache Layer
Let's get concrete about where to store these embeddings. I've tested most of the usual suspects — Redis, Memcached, FAISS, even Postgres with pgvector. Here's what I've learned:
For small corpora (under 5M chunks): Redis works fine. You're storing maybe 10GB of vectors — that fits in memory without breaking a sweat. Redis gives you sub-millisecond reads, TTL support, and a mature ecosystem.
For larger corpora (over 50M chunks): You need a distributed cache.
A distributed cache for ML serving isn't the same as a regular distributed cache. You need:
- High throughput — you're serving potentially thousands of embedding lookups per second
- Vector-native operations — cosine similarity, dot products
- Replication — if you lose a node with 5 million vectors, your warmup cost is brutal
The options in 2026 are better than they were a few years ago. Redis Enterprise has vector search built in. We've also deployed DragonflyDB for some clients — it's got higher throughput than Redis for read-heavy workloads. For really massive scale, you look at specialized vector databases like Milvus or Qdrant that have caching layers built in.
The Hybrid Cache Architecture
Here's what we actually deploy at SIVARO for production systems. It's a two-tier architecture that I wish someone had shown me two years ago.
Tier 1: Hot cache (Redis/Dragonfly). Stores the most frequently accessed embeddings. We use an LRU policy with a reasonable max memory. Typically 10-20% of your corpus lives here.
Tier 2: Cold storage (S3 + vector index). Everything gets persisted here. On a cache miss, you check S3 before calling the embedding API. Get a hit and you've still saved the API call, even if it's slightly slower.
The key insight: you don't need to keep all embeddings warm. Most RAG systems access the same 20% of documents 80% of the time. The hot cache handles those. The cold tier catches the long tail. And the API call is the last resort.
python
class HybridEmbeddingCache:
def __init__(self, hot_cache, cold_storage, embedding_client):
self.hot = hot_cache
self.cold = cold_storage
self.api = embedding_client
def get_embedding(self, text: str, model_name: str, model_version: str):
key = build_cache_key(text, model_name, model_version)
# Tier 1: hot cache
embedding = self.hot.get(key)
if embedding is not None:
return embedding
# Tier 2: cold storage
embedding = self.cold.get(key)
if embedding is not None:
self.hot.set(key, embedding) # promoted to hot cache
return embedding
# Tier 3: call the API
embedding = self.api.embed(text, model_name)
self.cold.set(key, embedding)
self.hot.set(key, embedding)
return embedding
This hybrid approach cut our client's embedding API costs by 87% at a healthcare technology company running a RAG system over millions of patient records. Cold hits still cost 50-100ms from S3, but that's still way cheaper than a 400ms API call.
Normalization Is Not Optional
Here's a subtle bug that cost us a full week of debugging.
Embeddings from different models have different scales. Some models output unit vectors already normalized. Others output vectors with a magnitude that varies. If you calculate cosine similarity without normalizing, you're measuring magnitude differences along with semantic differences.
This matters more than you think. We saw a client's similarity scores drift by 15% after switching models — not because their cache was wrong, but because they weren't normalizing on read.
python
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Normalize both vectors first — trust me on this."""
a_norm = a / np.linalg.norm(a)
b_norm = b / np.linalg.norm(b)
return float(np.dot(a_norm, b_norm))
Store your embeddings normalized. If you have control over the embedding generation pipeline, normalize before storing. This makes all downstream operations consistent.
Batching Embedding Calls
The cache saves you on repeat calls, but you'll still have cold misses. The way you handle those matters.
Most embedding APIs support batch requests. Instead of embedding one text chunk at a time, batch 100-500 chunks into a single API call. This reduces per-request overhead and can cut costs by 40-60% depending on the provider.
python
def embed_and_cache_batch(texts: List[str], model_name: str, model_version: str):
"""Batch embed and populate cache in one sweep."""
keys = [build_cache_key(text, model_name, model_version) for text in texts]
# Check cache first
cached = [cache.get(k) for k in keys]
missing_indices = [i for i, c in enumerate(cached) if c is None]
if not missing_indices:
return cached
# Only embed what's missing
missing_texts = [texts[i] for i in missing_indices]
embeddings = embedding_api.embed_batch(missing_texts, model_name)
# Populate cache
for idx, emb in zip(missing_indices, embeddings):
cache.set(keys[idx], emb)
# Merge results
result = list(cached)
for idx, emb in zip(missing_indices, embeddings):
result[idx] = emb
return result
We've measured this pattern reducing total embedding latency for initial corpus indexing by 3-4x. It's the difference between waiting 3 hours and waiting 45 minutes for a million-chunk corpus.
The Distributed Cache for ML Serving
Now let's talk about the distributed cache for ML serving specifically, because that's where things get interesting.
When you're serving embeddings as part of a live inference pipeline — say, you're running a real-time recommendation system that needs to embed user queries and match them against a product catalog — your cache requirements change dramatically.
You need:
- Sub-5ms p95 read latency — anything slower and your user notices
- 99.99% availability — a cold cache at the wrong moment means your system falls back to slow API calls
- Consistent hashing — so you don't blow up a significant portion of your cache when a node joins or leaves
We've settled on a consistent hashing ring with replication factor of 2. The second copy is the safety net. It costs us 2x memory, but it means a node failure doesn't devastate our hit rate.
python
# Simplified consistent hashing for embedding cache nodes
class ConsistentHashRing:
def __init__(self, nodes: List[str], replicas: int = 3):
self.ring = {}
for node in nodes:
for i in range(replicas):
key = hashlib.md5(f"{node}:{i}".encode()).hexdigest()
self.ring[key] = node
def get_node(self, cache_key: str) -> str:
hash_val = hashlib.md5(cache_key.encode()).hexdigest()
sorted_keys = sorted(self.ring.keys())
for k in sorted_keys:
if hash_val <= k:
return self.ring[k]
return self.ring[sorted_keys[0]]
This is a simplified version. In production, we use a library like ketama or ringhash that's been battle-tested. But the principle is the same — your cache sharding must be stable under node churn.
Monitoring What Matters
You can't improve what you don't measure. For every embedding cache deployment, we set up dashboards tracking these metrics:
- Hit rate — overall and by tier (hot vs cold)
- P95/P99 latency — for cache hit and cache miss paths
- Eviction rate — how many embeddings are being discarded per hour
- Stale serving rate — how many requests are served with outdated model versions
- Memory utilization — per node, with alerts at 80%
The one metric people forget is the stale serving rate. You need to know if your versioning logic is leaking old embeddings. We catch this with canary checks — every hour, we embed a known test phrase and verify the hash matches what we expect for the current model version.
Real Numbers from Real Deployments
Here are actual results from deployments I've worked on:
Financial services client (2025): 45M document chunks, Redis + S3 hybrid. Hit rate 93%. Embedding API costs dropped from $4,200/month to $380/month. P95 lookup latency went from 520ms to 18ms.
E-commerce company (2026): 12M product embeddings, DragonflyDB hot cache. Hit rate 89%. Search relevance stay stable after model upgrade because they implemented version keys correctly. Zero stale embeddings served in 6 months.
Healthcare tech (2025): 8M patient record chunks, Redis cluster. Hit rate 87%. The batch embedding pattern reduced initial index time from 14 hours to 3.5 hours.
These aren't theoretical. These are systems running in production, serving real users.
When Caching Doesn't Work
Let's be honest about when caching is the wrong answer.
If your queries are almost entirely unique — say you're building a system that embeds user-generated content that's never repeated — caching gives you a terrible hit rate. You're paying for the cache infrastructure and you're not saving anything.
The threshold we use: if your hit rate is below 50%, the cache is barely paying for itself. Below 30%, it's actively losing you money.
Similarly, if your data changes so fast that the cache is perpetually stale, you're better off building an efficient embedding pipeline without a cache layer. Focus on batching and parallelizing API calls instead.
The Cost of Getting It Wrong
This might sound like an infomercial, but the failure mode is real. I've watched a startup burn through $60,000 in embedding API costs in a single month because their RAG system re-embedded documents on every access. The cache wasn't hard to build — they just didn't build it.
I've also watched engineering teams spend two weeks building elaborate vector database solutions when a simple Redis cache would have handled their scale with 10% of the effort. Don't over-engineer. Start with Redis. Move to a distributed vector store when you measure that you actually need it.
Conclusion: The Set-and-Forget Isn't Real
Caching LLM embeddings is a solved problem conceptually — you hash, you store, you retrieve. But the details matter. Model versioning, eviction strategy, hot/cold tiering, normalization, batching, and monitoring are where production systems live or die.
We've covered how to evict stale embeddings from cache with version-keyed lazy eviction and registry-based active eviction. You've seen the hybrid architecture that combines Redis and cold storage for scale. And you understand why a distributed cache for ML serving needs consistent hashing and replication.
The rules I follow at SIVARO:
- Always version your cache keys
- Normalize before storing
- Batch your cold misses
- Start with Redis, scale deliberately
- Monitor hit rate and stale rate like they're your heart rate
Follow these and your embedding costs will drop, your latency will improve, and you won't own the pager when your semantic search silently dies because you upgraded a model without clearing the cache.
One last thing — don't trust the "set a TTL and forget it" crowd. TTLs are fine for garbage collection, but they can't protect you from serving stale embeddings on a version mismatch. Your cache needs to be aware of what model it's serving.
Now go build something that doesn't leak money.
FAQ
How do I decide between Redis and a dedicated vector database for embedding cache?
If you're under 10M embeddings and your read pattern is primarily single-key lookups, Redis is fine. If you need similarity search across large sets of embeddings efficiently, or you're at a scale where Redis memory becomes an issue, look at Qdrant or Milvus. At SIVARO, we typically start clients on Redis and graduate them to a vector DB only when their corpus exceeds ~20M chunks.
What's the right TTL for cached embeddings?
TTLs are a trap. Embeddings don't expire naturally — they become stale only when the model changes. Use version-based invalidation instead of TTLs. If you must use TTLs for memory management, set them to 30-60 days and rely on cold storage for persistent copies.
How do I handle text normalization for cache keys?
Be consistent. Whitespace, case folding, and unicode normalization matter more than you think. We use NFKC unicode normalization and lowercase before hashing for the cache key. But be careful — if your embedding model treats "AI" differently from "ai", normalizing the text for the key but embedding the original text creates cache misses. Match your key generation to your model's tokenizer behavior.
Can I cache embeddings for streaming documents?
Yes, but build a sentence-level cache, not a document-level cache. Documents change, sentences rarely do. We've built caches that store embeddings per sentence or per 256-token chunk, allowing reuse when portions of a document change without reinventing the whole thing.
What happens when my embedding cache node fails?
With consistent hashing and replication, you route to the replica. Without replication, you fall back to cold storage or the API. The worst case isn't the cache miss — it's the thundering herd of API calls when a large node fails and thousands of requests suddenly miss. Defend against it by rate-limiting fallback calls and batch retries.
How much memory do I need for a million embeddings?
A vector of dimension 1536 in float32 takes about 6KB. One million embeddings is roughly 6GB, plus overhead for keys and metadata. Realistically, budget 2-3x the raw vector size for overhead. Two million chunks will fit comfortably in 16GB of Redis memory.
Is it worth caching embeddings for a small internal tool?
If your corpus is under 100K chunks and you're not hitting API rate limits, caching might save you $50/month in embedding costs but add real engineering overhead. Skip it until it hurts. Add caching when you measure that embedding calls are slowing down your responses or inflating your bill.
Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.