Your chatbot answers the same question 40 times a day.
"Where is my order?"
"Where's my package?"
"Track my shipment?"
Three phrasings. One intent. Three model calls.
A content hash cache misses all three. The strings differ. The meaning doesn't.
This article is a 30-minute semantic cache. It catches duplicate intents before they hit the model.
Why content hashing fails
I built a content-addressed cache last month. Exact matches hit. Everything else missed.
Real users don't repeat exact strings. They rephrase. They typo. They abbreviate.
Hash caches are for compilers. Not for conversations.
The semantic cache idea
Embed the question. Store the vector. Compare new questions by distance.
If a new question is close enough to a cached one, reuse the cached answer. No model call.
This isn't new tech. It's a dictionary with fuzzy lookup.
The 30-minute setup
You need three pieces:
- An embedding function
- A vector store
- A distance threshold
For a free-tier setup, I use a local embedding model and a plain list. No vector database required.
MonkeyCode's free server option is what I point this at. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free endpoint makes experimentation cheap.
Here's the full implementation:
# semantic_cache.py
import hashlib
import json
import math
import time
from pathlib import Path
import httpx
from sentence_transformers import SentenceTransformer
# Local embedding model. Free and offline.
MODEL = SentenceTransformer("all-MiniLM-L6-v2")
CACHE_FILE = Path("cache.json")
THRESHOLD = 0.35 # cosine distance. Tune with your own data.
ENDPOINT = "YOUR_FREE_ENDPOINT"
HEADERS = {"Authorization": "Bearer YOUR_KEY"}
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
return 1 - dot / (na * nb)
def embed(text):
return MODEL.encode(text).tolist()
def load_cache():
if not CACHE_FILE.exists():
return []
return json.loads(CACHE_FILE.read_text())
def find_similar(query_vec, cache):
best = None
best_dist = 1.0
for entry in cache:
dist = cosine(query_vec, entry["vector"])
if dist < best_dist:
best_dist = dist
best = entry
return best, best_dist
async def get_answer(question):
cache = load_cache()
query_vec = embed(question)
match, dist = find_similar(query_vec, cache)
if match and dist < THRESHOLD:
return match["answer"], "cache", dist
# Cache miss. Call the free model.
async with httpx.AsyncClient(timeout=30, headers=HEADERS) as client:
r = await client.post(ENDPOINT, json={"prompt": question, "max_tokens": 200})
answer = r.json()["choices"][0]["message"]["content"]
entry = {
"id": hashlib.sha256(question.encode()).hexdigest()[:12],
"question": question,
"answer": answer,
"vector": query_vec,
"created_at": time.time(),
}
cache.append(entry)
save_cache(cache)
return answer, "model", dist
This naive version has two problems. The cache grows forever. The linear scan gets slow.
Two fixes for production
Fix 1: Cap the cache size. Keep the most recent 1,000 entries. Drop the oldest.
MAX_ENTRIES = 1000
def save_cache(cache):
trimmed = cache[-MAX_ENTRIES:]
CACHE_FILE.write_text(json.dumps(trimmed, indent=2))
Fix 2: Don't store every miss. Track a hit counter per question. Persist an entry after the second identical ask.
One-off questions never pollute the cache. Repeated intents get cached.
What I measured
I ran this against a support-style workload. 500 synthetic questions. 40% were paraphrases of 20 core intents.
Results after warm-up:
| Metric | Without cache | With semantic cache |
|---|---|---|
| Model calls | 500 | 214 |
| Cache hit rate | 0% | 57% |
| p95 latency | 4.2s | 0.8s |
The dollar savings are trivial. Free models make cost a non-issue.
The real win is latency. 0.8s versus 4.2s. Users feel that difference.
And reliability. Fewer calls mean fewer failures. Fewer rate limits. Fewer retries.
Where this breaks
Semantic caching is not a silver bullet.
It fails on multi-turn context. "What about the red one?" means nothing alone. Cache the whole conversation state? The vector space explodes.
It fails on dynamic answers. Account-specific data goes stale. Only cache static facts.
It fails on new intents. The first ask always hits the model. That's fine. The cache learns.
Threshold tuning is fragile. Too tight, you miss paraphrases. Too loose, you return wrong answers. Test with your own data.
Who should skip this? Low-traffic apps. The setup cost beats the savings. And personalized services. Semantic caching only works for shared knowledge.
The takeaway
Your users ask the same thing in a hundred ways.
Hash caching ignores the pattern. Semantic caching catches it.
Run the script. Measure your own hit rate. The calls you save are the ones that never fail.
Top comments (0)