← AI + Backend
LLM & GenAI Systems

Semantic Caching for LLMs

Watch: "Semantic Caching with Valkey and Redis: Reducing LLM Cost and Latency - Martin Visser" — Percona

A semantic cache stores LLM responses and returns a stored response when a new prompt is close enough in meaning to an earlier one, rather than only when the strings are identical. "How do I reset my password?" and "how can I reset my password" should cost one model call, not two. Interviewers probe it because it looks like an easy cost win and hides a correctness problem: unlike a normal cache, a semantic cache can return a confidently wrong answer to a question that merely looks similar. For a Backend or AI engineer the questions are where the similarity threshold comes from, what goes into the cache key so tenants and prompt versions never leak into each other, what must never be cached, and whether the hit rate actually pays for the extra lookup on every request. Classic cache mechanics (TTL, invalidation, stampedes, eviction) are covered in Redis & Caching and Caching Strategies; this page is about what changes when the key is a meaning instead of a string.

What Is Semantic Caching?

A semantic cache is a shortcut that lets an AI system skip asking the expensive model a question it has essentially answered before, even if this time the question is worded differently. Instead of matching the exact same text, it checks whether a new question means roughly the same thing as one already answered, and if so, hands back the old answer instead of generating a brand new one.

Where This Is Used

A Day-to-Day Example

Think of a librarian who keeps a small stack of "questions I've already answered today" next to the reference desk. If someone asks "where's your cookbook section?" and a little later another visitor asks "do you have any recipe books?", the librarian doesn't walk the shelves again — they recognize the two questions mean the same thing and give the same answer from memory. The risk is if a slightly different question just sounds similar: mistaking "where's your cookbook section?" for "where's your comic book section?" would send the wrong visitor to the wrong shelf.

Deep Dive: Exact-Match vs Semantic Cache, and How They Layer

Exact cache then semantic cache then the LLM1. normalise → exact keyexact cache (Redis GET)2. embed the prompt3. nearest neighbourinside the same namespace4. call the LLM, store, returnHIT → returnsim ≥ threshold → returnhitmissbelowrisk: a FALSE hit servesa wrong answer confidently
The cheap exact-match lookup goes first. Only its misses pay for an embedding and a similarity search, and only if nothing is similar enough does the request go to the LLM, whose answer is then cached for next time.
exact hitsemantic hit
Show text version
request
   |
   v
[1] normalise (trim, collapse whitespace, optional lowercase)  --> exact key = hash(namespace + normalised prompt)
   |
   +--> exact cache (Redis GET)            HIT --> return           cost ~ microseconds to sub-ms
   |            MISS
   v
[2] embed the prompt (small embedding model)                        cost ~ one embedding call, paid on every miss of [1]
   |
   v
[3] nearest-neighbour search WITHIN the same namespace              cost ~ vector index lookup
   |        best similarity s
   +--> s >= threshold                  HIT --> return cached answer  (risk: FALSE HIT)
   |            s < threshold
   v
[4] call the LLM (seconds, dollars)  --> store (embedding, answer) under the namespace with a TTL --> return

An exact-match cache is cheap, predictable and never wrong: the same key means the same input, so, if the model is deterministic enough for your purpose, the same output. It hits rarely for free-text prompts because humans rephrase, but often for programmatic ones such as fixed templates, repeated system-generated queries or retried requests. A semantic cache raises the hit rate by generalising over phrasing, and pays for it with an embedding call and a vector search on every request that reaches it, plus the risk of returning an answer meant for a different question. That is why the two are layered with the exact cache first: it absorbs the free hits, the semantic layer catches paraphrases, and the model handles the rest. The vector lookup itself is an ordinary similarity-search problem (see Vector Databases and Approximate Nearest Neighbor Search), and at small cache sizes a brute-force scan is fine.

Deep Dive: The Similarity Threshold Is a Precision/Recall Trade-off

Similarity scores against three thresholdst=0.5t=0.7t=0.9how can I reset my password1.00reset my account password0.82…reset my password manager0.82refund policy for monthly plans0.75I forgot my login credentials0.00red = a different question: any threshold low enough to catch it serves a wrong answer
Each bar is the query's similarity to a cached question (toy bag-of-words model). "Password manager" scores as high as a true paraphrase, so no threshold separates them cleanly, while a real paraphrase with different words scores 0. That's the trade-off: lower thresholds save more calls and serve more wrong answers.
same questiondifferent question
Toy demo (bag-of-words cosine; see Try It Yourself). Two cached questions:
    Q1 "how do I reset my password"       Q2 "what is the refund policy for annual plans"

incoming query                                  cosine   t=0.5        t=0.7        t=0.9
how can I reset my password                      1.00    HIT          HIT          HIT
reset my account password                        0.82    HIT          HIT          miss (lost saving)
how do I reset my password manager               0.82    FALSE-HIT    FALSE-HIT    miss
what is the refund policy for monthly plans      0.75    FALSE-HIT    FALSE-HIT    miss
I forgot my login credentials                    0.00    miss (lost)  miss (lost)  miss (lost)

Lower threshold  -> more hits, more FALSE hits (wrong answer served)
Higher threshold -> fewer false hits, more misses (money and latency not saved)

Every threshold is a point on a precision/recall curve. A false hit (a different intent above the threshold) is a correctness failure the user may never notice; a miss (a same-intent query below the threshold) only costs one model call. Those costs are asymmetric, so you generally bias the threshold high and accept a lower hit rate, and you choose it empirically: label a sample of real query pairs as same or different intent, compute the similarity for each pair, and pick the threshold that meets a target false-hit rate. Two things make this harder than the toy suggests. Similarity scores depend on the embedding model, so the threshold is not portable across models, and changing the embedder invalidates the whole cache. And embeddings often score near-identical sentences that differ in one decisive token (monthly vs annual, cannot vs can, a number, a name) as very similar, so a global threshold cannot fully separate them. Mitigations are per-category thresholds, a stricter threshold for high-stakes intents, verifying the hit with a cheap check (matching entities or numbers extracted from both prompts), or restricting the semantic layer to intents where wrong answers are cheap, such as FAQ-style content.

Deep Dive: What Belongs in the Cache Key

namespace = ( tenant_id,           # data isolation: tenant A must never receive tenant B's answer
              model_id + version,  # a different model gives a different answer
              prompt_template_version,   # system prompt / instructions changed -> old answers stale
              generation params,   # temperature, top_p, max_tokens, response_format, tools enabled
              retrieval corpus version / index version,   # for RAG: answers depend on the documents
              locale / language )  # if the answer text depends on it

exact key    = hash(namespace, normalised prompt)
semantic key = the namespace PLUS the vector; similarity search is done only INSIDE one namespace.

What is NOT safe to cache (or must be scoped to a user):
  - answers using personal data (account balance, order status, "my" anything) -> key by user, or skip
  - prompts containing PII or secrets                                            -> do not store the prompt/answer at all
  - anything time-sensitive ("today", "latest", stock price, weather)             -> skip or very short TTL
  - answers from tool calls / agent runs with side effects                        -> cache reads, never the action
  - high temperature / creative requests where variety is the point               -> skip
  - safety-sensitive outputs whose policy may change                              -> short TTL, invalidate on policy update

The namespace rule is the one that prevents the worst incidents. If the key omits the tenant, one customer's cached answer (possibly containing their data) can be served to another; if it omits the prompt version, a fix to the system prompt appears not to work because old answers keep being returned; if it omits the model, a model upgrade silently changes nothing until entries expire. Put the version fields in the key so a change invalidates naturally instead of requiring a purge, and treat the semantic search as filtered by namespace before ranking, not after. The question "is this response the same for everyone who asks it?" decides cacheability: a public FAQ answer is, a personalised one is not. Anything that includes user-specific context must either include the user in the key (which usually destroys the hit rate, so it is often better not to cache it) or be excluded. Storing prompts and answers in a shared cache also creates a data-retention and privacy surface, so apply the same PII handling and access controls you would to a database; see Guardrails, Content Moderation & Safety.

Deep Dive: TTL, Invalidation and Hit-Rate Economics

# ILLUSTRATIVE economics of a semantic cache. Every number below is an ASSUMPTION, not a measurement.
req_per_day   = 1_000_000
llm_cost      = 0.0020     # assumed $ per uncached LLM call
llm_latency   = 2.0        # assumed seconds per uncached LLM call
lookup_cost   = 0.00002    # assumed $ per request for embedding + vector lookup (paid on EVERY request)
lookup_latency= 0.05       # assumed seconds for embed + lookup (paid on EVERY request)
print("break-even hit rate (cost only) =", round(lookup_cost / llm_cost, 3))
print(f"{'hit rate':>8s} {'$/day no cache':>15s} {'$/day cached':>13s} {'saved $/day':>12s} {'avg latency s':>14s}")
for h in (0.05, 0.20, 0.40):
    base   = req_per_day * llm_cost
    cached = req_per_day * lookup_cost + req_per_day * (1 - h) * llm_cost
    lat    = lookup_latency + (1 - h) * llm_latency
    print(f"{h:8.2f} {base:15,.0f} {cached:13,.0f} {base-cached:12,.0f} {lat:14.2f}")
f = 0.01
print(f"\nif 1% of the 20% hits are false hits: {req_per_day*0.20*f:,.0f} wrong answers/day served confidently")
# break-even hit rate (cost only) = 0.01
# hit rate  $/day no cache  $/day cached  saved $/day  avg latency s
#     0.05           2,000         1,920           80           1.95
#     0.20           2,000         1,620          380           1.65
#     0.40           2,000         1,220          780           1.25
#
# if 1% of the 20% hits are false hits: 2,000 wrong answers/day served confidently

The economics are simple, which is why they are worth stating as arithmetic. The lookup (embedding plus search) is paid on every request that reaches the semantic layer, while the saving is only earned on hits, so the break-even hit rate is roughly lookup cost divided by model cost. With the assumed numbers that is about 1%, so a semantic cache usually pays for itself on cost when the embedding step is much cheaper than the generation it replaces; the bigger benefit is latency, since a hit replaces a multi-second generation with a sub-second lookup. The figures are assumptions: real token prices, model latencies and achievable hit rates vary widely by workload and provider, so measure your own hit rate on production-like traffic before promising savings. The line at the bottom is the part that is easy to forget: a 1% false-hit rate on 200,000 daily hits is 2,000 confidently wrong answers a day, and that cost does not appear on the cloud bill.

Freshness is handled with TTLs and versioned keys, and the same trade-off as any cache applies. A short TTL bounds staleness but lowers the hit rate; a long TTL raises the hit rate but serves out-of-date answers after the underlying documents, policies or prices change. For RAG systems, include the index or corpus version in the key so a re-index invalidates related answers, or attach a tag to each entry (the source document ids) so you can delete every entry that depended on a changed document. Eviction is usually LRU or LFU with a size cap. Cache the negative case sparingly: caching a refusal or an error response can lock in a transient failure, so do not cache errors, timeouts or truncated outputs.

Key Pointers

How It Works

  1. The gateway builds the namespace from tenant, model, prompt version, generation parameters and corpus version, then normalises the incoming prompt.
  2. It checks the exact-match cache (a hash of namespace plus normalised prompt) and returns immediately on a hit.
  3. On a miss it embeds the prompt with the same embedding model used when entries were stored.
  4. It searches for the nearest stored prompt embedding inside the same namespace and compares the top similarity score to the threshold.
  5. If the score meets the threshold (and any extra checks such as matching entities pass) it returns the cached response and records a hit; otherwise it calls the LLM.
  6. On an LLM call it stores the new embedding and response under the namespace with a TTL (skipping uncacheable or failed responses) and emits metrics: hit rate, similarity distribution, and sampled hits for human review.

Try It Yourself

# Toy semantic cache: bag-of-words cosine similarity, namespaced keys, threshold sweep.
import math, re
from collections import Counter

STOP = {"how", "do", "i", "can", "my", "the", "a", "an", "is", "for", "what", "to", "of"}

def embed(text):                       # "embedding" = term-frequency vector (no learned semantics)
    return Counter(w for w in re.findall(r"[a-z]+", text.lower()) if w not in STOP)

def cosine(a, b):
    dot = sum(a[t] * b[t] for t in a)
    na = math.sqrt(sum(v * v for v in a.values())); nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0

class SemanticCache:
    def __init__(self, threshold):
        self.threshold, self.entries = threshold, []        # (ns, vector, query, answer)
    def key_ns(self, tenant, model, prompt_v, temperature):
        return (tenant, model, prompt_v, temperature)      # anything that changes the answer
    def put(self, ns, query, answer):
        self.entries.append((ns, embed(query), query, answer))
    def get(self, ns, query):
        q, best, best_sim = embed(query), None, 0.0
        for ens, vec, cq, ans in self.entries:
            if ens != ns: continue                          # never cross namespaces
            s = cosine(q, vec)
            if s > best_sim: best, best_sim = (cq, ans), s
        return (best, best_sim) if best and best_sim >= self.threshold else (None, best_sim)

NS = ("acme", "model-x", "prompt-v3", 0.0)
SEED = [("how do I reset my password", "ANSWER: use Settings > Security > Reset"),
        ("what is the refund policy for annual plans", "ANSWER: 30-day refund on annual plans")]
# (incoming query, index of seed with the SAME intent, or None if it needs a fresh answer)
TESTS = [("how do I reset my password",                        0),
         ("how can I reset my password",                       0),
         ("reset my account password",                         0),
         ("how do I reset my password manager",                None),   # different intent
         ("what is the refund policy for monthly plans",       None),   # different answer!
         ("I forgot my login credentials",                     0)]      # same intent, no shared words

print(f"{'query':46s} {'sim':>5s}   " + "   ".join(f"t={t}" for t in (0.5, 0.7, 0.9)))
sims = {}
for q, want in TESTS:
    row = []
    for t in (0.5, 0.7, 0.9):
        c = SemanticCache(t)
        for sq, sa in SEED: c.put(NS, sq, sa)
        hit, sim = c.get(NS, q)
        sims[q] = sim
        if hit is None:
            row.append("MISS" if want is None else "MISS(lost saving)")
        else:
            correct = want is not None and hit[0] == SEED[want][0]
            row.append("HIT" if correct else "FALSE-HIT")
    print(f"{q:46s} {sims[q]:5.2f}   " + "   ".join(f"{r:>17s}" for r in row))

print()
c = SemanticCache(0.7)
for sq, sa in SEED: c.put(NS, sq, sa)
other_tenant = ("globex", "model-x", "prompt-v3", 0.0)
new_prompt   = ("acme", "model-x", "prompt-v4", 0.0)
print("same query, same namespace     :", c.get(NS, "how do I reset my password")[0] is not None)
print("same query, other tenant       :", c.get(other_tenant, "how do I reset my password")[0] is not None)
print("same query, new prompt version :", c.get(new_prompt, "how do I reset my password")[0] is not None)

# Real output from python3:
# query                                            sim   t=0.5   t=0.7   t=0.9
# how do I reset my password                      1.00                 HIT                 HIT                 HIT
# how can I reset my password                     1.00                 HIT                 HIT                 HIT
# reset my account password                       0.82                 HIT                 HIT   MISS(lost saving)
# how do I reset my password manager              0.82           FALSE-HIT           FALSE-HIT                MISS
# what is the refund policy for monthly plans     0.75           FALSE-HIT           FALSE-HIT                MISS
# I forgot my login credentials                   0.00   MISS(lost saving)   MISS(lost saving)   MISS(lost saving)
#
# same query, same namespace     : True
# same query, other tenant       : False
# same query, new prompt version : False

# What to notice:
#  - "how do I reset my password manager" and "...refund policy for monthly plans" score as similar
#    to a cached question but need a DIFFERENT answer: a low threshold turns them into FALSE-HITs.
#  - at t=0.9 the false hits disappear, but the true paraphrase "reset my account password" is also lost.
#  - "I forgot my login credentials" has the same intent but shares no words, so word overlap
#    scores 0.00. A real embedding model would score it high; bag-of-words cannot. This toy shows
#    the THRESHOLD mechanics, not the quality of a learned embedding.
#  - the namespace check means another tenant, or a new prompt version, never reads the entry.

Interview Q&A

Q: What is a semantic cache and when does it beat an exact-match cache?

A: It stores prompt embeddings with their responses and returns a stored response when a new prompt's embedding is similar enough. It wins when many users ask the same thing in different words, such as FAQ and support traffic, where exact-match hit rates are low. It loses when prompts are unique, personalised or contain long variable context, because the hit rate is small while the embedding and search cost is paid on every request.

Q: How do you choose the similarity threshold?

A: Empirically, per embedding model. Sample real queries, label pairs as same or different intent, compute similarity, and pick the threshold that meets a target false-hit rate, then look at the hit rate you can afford at that point. Because a false hit is a wrong answer and a miss is only one extra model call, I bias high, and revisit it whenever the embedder, domain or traffic mix changes.

Q: What must be in the cache key besides the prompt?

A: Tenant, model and version, prompt-template version, generation parameters (temperature, max tokens, response format, tools), and for RAG the corpus or index version. Each of these changes the correct answer or the audience of the answer, so omitting one causes cross-tenant leaks or stale behaviour after a deploy. Similarity search is then restricted to that namespace.

Q: What should you never cache?

A: Responses that depend on a specific user's data, prompts with PII or secrets, time-sensitive answers, outputs of tool calls with side effects, errors and truncated generations, and high-temperature creative output. For personal data you either key by user, which usually kills the hit rate, or skip caching. The general test is whether the answer is the same for everyone who asks and stays correct for the TTL.

Q: Is a semantic cache always worth it? How would you decide?

A: Compare the break-even hit rate (lookup cost over model cost) with the measured hit rate, and also weigh the latency gain and the false-hit risk. If lookup is about 1% of a model call and you get a 20% hit rate, the saving is real; if hits are 2% and the intent is high-stakes, it is not worth the risk. Run it in shadow mode first: compute would-be hits, sample them and have reviewers judge whether the cached answer was acceptable.

Q: How do you invalidate a semantic cache when the source documents change?

A: Version the corpus or index in the namespace so a re-index invalidates everything at once, and for finer control tag each entry with the ids of the documents used so a document update can delete exactly those entries. A TTL is the backstop for anything missed. The trade-off is that broad invalidation is simple but throws away good entries, while tagging needs the RAG pipeline to report which documents contributed to each answer.

Q: How do exact-match and semantic layers interact with a cache stampede?

A: Semantic caches are exposed to the same problem: when a popular entry expires many concurrent requests miss and call the model. The same defences apply: single-flight per namespace and prompt so one request generates while others wait, TTL jitter, and serving a stale entry while refreshing. Model calls are slow and expensive, so a stampede here costs money and rate-limit budget as well as latency.

Follow-up: How would you detect that the cache is returning wrong answers in production?

A: Log every hit with the incoming prompt, matched prompt, similarity score and namespace, then sample hits for human or LLM-judge review and track a false-hit rate. Watch the similarity score distribution: a pile of hits just above the threshold is where errors live. Also expose user feedback (thumbs down) joined against whether the answer was cached, and keep a kill switch or per-intent disable so a bad category can be turned off without redeploying.

Follow-up: A single tenant complains they saw another tenant's data in an answer. What do you check?

A: First whether the tenant id is in the namespace and whether the vector search filter is applied before ranking, since a post-filter on top-k can also lose valid results but a missing filter leaks data. Then check whether prompts or contexts containing tenant data were stored in a shared namespace, whether a normalisation step dropped the tenant field, and whether the exact-cache key hashes the same fields. Purge the affected entries, add a test that asserts cross-tenant reads miss, and treat it as a security incident.

Follow-up: Could you cache at the provider level instead? How is that different?

A: Some providers offer prompt (prefix) caching, which reuses the computation for a repeated prompt prefix to cut cost and latency of the input side, but the model still generates a fresh response each time. A semantic or response cache skips generation entirely and is your responsibility. They are complementary, and the details and pricing vary by provider, so check current documentation.

System Design Tie-in

In an LLM gateway or a RAG support chatbot the semantic cache sits in the gateway, after authentication and rate limiting and before the provider call (see Design an LLM API Gateway and Cost Optimization for AI Systems). Stating assumptions, if the service handles 1M requests per day with an average of 12 requests per second and perhaps 10 times that at peak, and the FAQ-like share of traffic is what the cache can serve, a 20% hit rate removes 200,000 model calls a day and lowers the average latency roughly in proportion, as the arithmetic above shows, while the same 20% is a burst-absorbing buffer if the provider rate-limits you. The exact layer can live in Redis (Redis & Caching) with TTL and jitter, while the semantic layer needs a vector index scoped by namespace, either a vector database or Redis with a vector module, sized by entries times embedding dimensions times bytes (for example 1M entries of 768 float32 dimensions is about 3 GB before index overhead, which is an estimate to be checked against the store you choose). Design the namespace, invalidation on prompt or corpus change, per-tenant enable and disable, PII exclusion, and the metrics (hit rate, false-hit sample rate, lookup latency) explicitly, and remember that the cache is a derived optimisation: the system must remain correct, only slower and costlier, when the cache is empty or turned off. Related building blocks are Chunking & Embedding Strategies for the embedding side and Prompt Engineering & Prompt Management for versioning prompts that feed the key.

Trade-offs

The decision is to spend an embedding call and a vector search on every request in exchange for skipping some model calls. You gain lower cost, lower average latency and protection against provider rate limits, and you pay in added infrastructure, a threshold that must be tuned and re-tuned, a new privacy and tenancy surface, and a failure mode where a wrong answer is returned instantly and confidently. It is the wrong choice when queries are mostly unique or personalised, when answers depend on fresh or per-user data, when the cost of one wrong answer exceeds the savings (medical, legal, financial guidance), when the traffic is too small for the hit rate to cover the lookup, or when an exact-match cache plus a prompt-prefix cache already captures most of the benefit. Start with exact-match and provider prefix caching, add semantic caching only for the intents where measurement shows a good hit rate and reviewed false-hit rate is acceptable, and keep the ability to disable it per tenant or per intent.