Semantic Caching for LLM APIs

LLMCachingPerformanceInfrastructure

Exact-match caching does almost nothing for LLM traffic. Users never phrase the same question the same way twice — "how do I cancel my subscription," "cancel subscription," and "I want to stop paying" are three cache misses that all deserve one answer. Semantic caching fixes this by keying on meaning instead of the literal string, and when it works it turns a 2-second, few-cents LLM call into a 15-millisecond lookup that costs effectively nothing.

I've built this into a couple of production RAG and support-assistant systems, and it's one of the highest-leverage optimizations available — but it has a sharp edge. A too-eager cache confidently returns the wrong answer, which is far worse than being slow. Here's how it actually works and how to keep it honest.

The core idea

A semantic cache sits in front of your LLM call. For each incoming prompt you:

  1. Embed the prompt into a vector.
  2. Search a vector store for the nearest previously-seen prompt.
  3. If the nearest neighbor's similarity is above a threshold, return its stored response — a cache hit.
  4. Otherwise call the LLM, then store the new prompt embedding + response for next time.
def semantic_cache_lookup(prompt: str, threshold: float = 0.92):
    vec = embed(prompt)                      # e.g. a small embeddings model
    hit = vector_store.query(vec, top_k=1)
    if hit and hit.score >= threshold:
        return hit.metadata["response"], True   # cache hit
    response = call_llm(prompt)
    vector_store.upsert(vec, metadata={
        "prompt": prompt,
        "response": response,
        "ts": time.time(),
    })
    return response, False

That's the whole mechanism. The engineering is entirely in the details — the threshold, what you scope the cache to, and when you invalidate.

The threshold is everything

The single number that decides whether this helps or hurts is the similarity threshold. Set it too high (say 0.99 with cosine similarity) and you get almost no hits — you've reinvented exact matching. Set it too low (0.80) and you start serving the answer for "how do I upgrade my plan" to someone who asked "how do I downgrade my plan," which are semantically close but operationally opposite.

There's no universal right value; it depends on your embeddings model and domain. What worked for me was to log candidate hits without serving them for a week — record the prompt, the matched prompt, and the score — then eyeball where false matches start creeping in. In one support system the safe line sat around 0.93 cosine; below that, antonym pairs ("enable"/"disable") started colliding. Measure it against your own traffic; don't copy someone's number.

Scope the cache, don't make it global

The biggest real-world mistake I see is a single global cache. If your responses depend on the user (their plan, their locale, their permissions) or on time (prices, availability), a global cache leaks one user's answer to another and serves stale data.

The fix is to include those variables in the cache key namespace:

namespace = f"{tenant_id}:{locale}:{plan_tier}"
hit = vector_store.query(vec, top_k=1, namespace=namespace)

This is the same instinct as good HTTP cache design: cache keys must include everything that changes the answer. For anything personalized, either partition by user or don't semantically cache it at all. Static, factual, non-personalized queries — product docs, how-tos, definitions — are the ideal candidates.

Invalidation, the hard part

Caches are only as good as their eviction. LLM answers grounded in a knowledge base go stale the moment that base changes. Strategies I've used, roughly in order of effort:

Whatever you pick, treat a prompt-template change as a full cache flush. I once spent an afternoon debugging "why is the assistant ignoring the new tone guidelines" — the answer was that 60% of responses were cached from before the change.

What it actually buys you

On a support assistant with heavily repeated questions, a semantic cache hit rate of 30-40% is realistic, and each hit removes a full generation call. The effect compounds: lower p50 latency (cached responses return in tens of milliseconds), lower cost, and less load on your rate-limited LLM provider during traffic spikes. It stacks cleanly with the other levers in cutting LLM costs — routing and batching handle the misses, semantic caching handles the hits.

The infrastructure is modest: any vector database in production can back it, and your choice of embeddings model mostly affects lookup latency and match quality. Use a small, fast embeddings model here — you're comparing short prompts, and shaving embedding latency matters because it's now on your critical path for every request.

Failure modes to watch

The short version

Semantic caching is worth building the moment you have repeated, non-personalized queries and care about latency or cost. Start conservative on the threshold, scope keys to everything that changes the answer, treat prompt changes as flushes, and instrument hits so a bad match surfaces before your users find it. Done carefully it's nearly free performance; done carelessly it's a machine for serving wrong answers quickly.

Resources

Frequently asked questions

What is semantic caching for LLM APIs?

Semantic caching stores past prompt-response pairs and returns a cached answer when a new prompt is semantically similar — not just byte-identical — to a stored one. It uses embeddings and a similarity threshold instead of exact-string matching, so paraphrased questions can still hit the cache.

How is semantic caching different from normal caching?

A normal cache keys on the exact request string, so 'reset my password' and 'how do I reset my password' miss each other. A semantic cache keys on meaning via embeddings, so both can return the same stored answer if their vectors are close enough.

What is the main risk of semantic caching?

False hits. If your similarity threshold is too loose, the cache returns an answer to a subtly different question, which is worse than a slow correct answer. Tuning the threshold and scoping the cache carefully is essential.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →