DEV Community

Jordan Huang
Jordan Huang

Posted on

Semantic Caching Cut My Free Model Calls by 60%. Here's the 30-Minute Setup.

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)