Don't pay twice for the same question — but one wrong hit costs more than a hundred misses.
// WHY THIS MATTERS
Your LLM bill is stuffed with "semantic duplicates": the same question phrased differently, FAQs, templated queries. Exact-match caching is useless here — change one character and it misses. Prefix caching (Day 15/16's cache_control) only saves input tokens; the answer is still fully re-inferred every time. To actually pay once per question, you need semantic caching: embed the request, and on a vector-neighbor hit, reuse the old answer — saving an entire inference. But semantic caching is one of the few optimizations in this series that gets more dangerous the more you use it: loosen the threshold a notch and the system starts returning the "return policy" answer to an "exchange policy" question — and it doesn't error; the user gets a confidently wrong answer. This issue is not about installing GPTCache — it's about walking the tightrope between saving money and false hits.
// 01
The Three-Tier Cache Stack: exact → prefix → semantic
Claim: these three tiers stack, they don't substitute; hit rate, savings, and risk all rise together — so you must query from the safest tier first.
Background & Principle
Treating "cache" as one thing is a rookie mistake. An LLM request has three cacheable points with wildly different cost and risk:
L1 exact cache: hash the request text → answer. Lowest hit rate (one punctuation change and it misses), but zero semantic risk — a hit is guaranteed to be the same sentence.
L2 prefix cache: Anthropic cache_control caches the stable prefix (system prompt, few-shot, long docs) server-side. It saves input tokens but still runs a full inference every time — the answer is recomputed, so it too is zero semantic risk. Note: Anthropic defaults to a 5-minute TTL (optional 1 hour), up to 4 breakpoints, and any byte change before a breakpoint invalidates the cache from that point onward — so place the breakpoint exactly on the boundary between the stable prefix and the volatile tail.
L3 semantic cache: embed the request → nearest neighbor (ANN) → if distance is below threshold, return the old answer. Saves an entire inference (largest savings), but may hit the wrong old answer.
The key engineering decision: query the three tiers in order, with the semantic tier last, handling only what exact/prefix let through. An identical query should be caught by the zero-risk L1 and never reach L3 to gamble on a similarity check.
def answer(q, scope):
qn = normalize(q) # normalize: collapse surface variants firstif (a := L1.get(hash(qn, scope))): return a # zero-risk hit
hit = L3.search(embed(qn), scope, k=1) # semantic neighborif hit and hit.dist < TAU: return hit.answer # ⚠️ threshold gate
a = llm(qn, scope) # L2 prefix cache applies inside this call
L1.put(hash(qn, scope), a); L3.put(embed(qn), a, scope, ttl=...)
return a
Failure mode: skipping L1/L2 and going "all semantic." That means throwing even identical queries — which could hit L1 at zero risk — into a similarity gamble, manufacturing false hits out of nothing. The semantic tier plugs leaks; it doesn't lead.
Claim: the similarity threshold is a risk dial, not a precision dial — a false hit costs far more than a miss, so the threshold should lean strict, preferring to miss.
Background & Principle
The two errors have grossly asymmetric costs. A miss costs one extra API call (a fraction of a cent plus a little latency) — harmless. A false hit costs a user a confidently wrong answer — broken trust, possibly legal fallout. Given the asymmetry, you don't pick the threshold at "max F1"; you pick it at "false hits pushed below tolerance" — which usually means a stricter threshold and more misses.
Redis uses cosine distance over [0,2], where 0 = identical and 2 = opposite; a smaller threshold = stricter = fewer false hits. The embedding choice matters even more than the threshold: a generic sentence encoder can't separate "return vs exchange" (near-synonyms, different answers), nor "can I return" vs "can't I return" (one word apart, opposite meaning, near-identical vectors). So don't guess the threshold — sweep it: take a batch of human-labeled (q1, q2, should-hit?) pairs, plot false-hit and false-miss curves, and pick the highest-hit-rate point subject to "false hits under the red line."
Hands-on
# Threshold sweep: pick tau under a false-hit cap, not at max F1def pick_tau(pairs, embed, max_false_hit=0.01):
best = Nonefor tau in arange(0.05, 0.40, 0.01):
fh = mean([dist(embed(a),embed(b)) < tau and not same
for a,b,same in pairs]) # false-hit rate
hit = mean([dist(embed(a),embed(b)) < tau and same
for a,b,same in pairs])
if fh <= max_false_hit: best = (tau, hit, fh) # constraint firstreturn best # highest hit rate under the strict cap
Failure mode: shipping the default threshold (many libs give 0.8 similarity) plus a generic embedding straight to prod. Near-synonyms, negations, and number-bearing queries ("order 1234" vs "order 1235") have near-identical embeddings and false-hit reliably. The MeanCache paper (arXiv 2403.02694) names it outright: existing cache methods can't find true semantic similarity, yielding unacceptable false hit/miss rates — not a tuning nuisance, but the core hard problem of semantic caching.
Cache Poisoning: Correctness Isn't Just "Do the Questions Look Alike"
Claim: two literally identical questions can have different answers — what decides whether a cache entry can be shared is the scope key, not query similarity.
Background & Principle
Even with a perfect similarity judgment, a semantic cache has a subtler error class: the answer itself no longer holds. Three poison sources:
Personalization: "Where is my order?" has a different answer per user — a cross-user hit is a disaster (and leaks others' privacy).
Freshness: "latest price" or "today's stock" cached yesterday is poison today.
Context dependence: the same "what about the second one?" points at entirely different things given different conversation histories.
The fix is a scope key: the cache key can't be just the query embedding — bolt on user_id / tenant / data_version / lang / context summary, and only allow hits within the same scope. Tier TTLs by freshness: static knowledge might get a day, prices/stock maybe 60 seconds or no cache at all. MeanCache's approach encodes a context chain per cached query to distinguish context-dependent queries from self-contained ones — only the latter are safe to reuse across sessions.
Hands-on
# scope key: similarity is only the necessary condition; scope match is the sufficient onedef scope_key(req):
return {
"tenant": req.tenant_id, # isolate multi-tenant"user": req.user_id if req.personalized else"*",
"lang": req.lang,
"dv": req.data_version, # data changes → old cache auto-mismatches
}
# hang invalidation hooks on the write path: source changes → clear cache by scopedef on_price_update(sku):
cache.invalidate(scope={"dv": bump_version()}) # bump version → all old cache mismatches
Failure mode: a global shared cache with no invalidation hook. The database updates but nobody tells the cache, and the agent confidently answers from stale data — wrong on freshness and permissions alike. Invalidation must be triggered by the write path — using a data version number as part of the scope is the cheapest implementation: bump the version on any source change and all old entries mismatch instantly, no per-row deletion needed.
Claim: watching hit rate alone tempts you to loosen the threshold until the system breaks — you must monitor hit rate and false-hit rate together.
Background & Principle
First, the safe ways to raise hit rate (no threshold change, no added risk):
Canonicalization: lowercase, strip punctuation and extra whitespace, drop politeness noise ("pls / could you / would you mind"), so the L1 exact tier absorbs surface variants — less load on the semantic tier, a narrower false-hit surface.
Negative cache: cache "no results / I don't know" too, so the same empty query doesn't keep hitting the LLM.
Warmup: preload the store offline with high-frequency historical queries.
But hit rate is a dangerous single metric: loosen the threshold → hit rate rises → bill looks great → but false hits quietly rise too. So you need a shadow eval: sample some already-cache-hit requests, actually call the LLM, and compare whether old and new answers are equivalent (via LLM-as-judge or embedding similarity), estimating the live false-hit rate. This is the same idea as Day 42's shadow testing and Day 56's "stabilize eval with a distribution, not a single point" — use a cheap side channel to surface silent errors.
Hands-on
# shadow sampling: quietly recompute 1% of hits to quantify false-hit ratedef answer_monitored(q, scope):
hit = semantic_lookup(q, scope)
if hit and sample(0.01): # 1% of hits go to shadow reconciliation
truth = llm(q, scope)
if not equivalent(hit.answer, truth): # judge / similarity
metrics.incr("cache.false_hit") # alert on THIS, not hit ratereturn hit.answer if hit else llm(q, scope)
Failure mode: reporting hit rate as a KPI to the team. People optimize what's measured — engineers loosen the threshold to make hit rate look good, false hits rise in the unmonitored dark, until customer complaints erupt one day. Hit rate is the benefit metric, false-hit rate is the guardrail metric, and both must be watched together; when the guardrail breaks, give back hit rate first.
// Capstone · Add a "Safe" Semantic Cache to Your Own RAG/Agent
String the four points into a weekend project: add semantic caching to your existing RAG or support agent, aiming not for max hit rate but for zero perceptible false hits.
Build three tiers: normalize → L1 exact (Redis hash) → L3 semantic (GPTCache / RedisVL) → LLM. Hand L2 prefix caching to Anthropic cache_control, parking system prompt + retrieved docs before one breakpoint.
Label 50 query pairs: pick 50 semantically-close question pairs from real logs, hand-label "should share the answer," run the §2 threshold sweep, and pick the threshold under a false-hit ≤ 1% cap.
Add the scope key: bolt on tenant / user (only when personalized) / lang / data_version; hang a hook on the source data's write path that bumps data_version.
Ship shadow reconciliation: recompute 1% of hits, emit both hit_rate and false_hit_rate to metrics, and alert only on the latter.
Do the math: a week later, count inferences saved and the false-hit rate. You'll find that tightening the threshold — hit rate dropping from 40% to 25% but false hits at zero — is the point production actually wants, not the reverse.
Once you've built this, whenever you see someone brag "we added semantic caching and saved X%," you'll instinctively ask: what's your false-hit rate and how did you measure it? — savings numbers with no answer to that are hand-waving.
// KEY TERMS
Semantic Cache
A cache that embeds the request and reuses the old answer on a vector-neighbor hit, saving a whole inference. This issue's protagonist.
Exact / Prefix Cache
Exact caching (text hash) and prefix caching (e.g. Anthropic cache_control saving input tokens); both are zero semantic risk.
False Hit
Similarity says hit, but the returned old answer is actually wrong — the most expensive error in semantic caching.
Similarity Threshold (τ)
The distance cutoff for "close enough to reuse." A risk dial, not a precision dial; keep it strict.
Cosine Distance
The distance metric Redis uses, over [0,2]: 0 = identical, 2 = opposite.
Scope Key
Isolation dimensions beyond the embedding: tenant / user / data_version / lang. Decides whether an entry can be shared across requests.
Canonicalization
Request normalization (lowercase, strip punctuation and politeness noise) so the exact tier collapses surface variants first.
Negative Cache
Caching "no results / don't know" to stop empty queries from repeatedly hitting the LLM.
Cache Poisoning
An entry is still hit even though personalization / freshness / context dependence has made its answer no longer valid.
Shadow Eval
Sampling hit requests to recompute and compare answers, quantifying the live false-hit rate.
// DEEPER THINKING
Semantic caching uses an embedding to judge "close enough to reuse," and RAG uses one to judge "close enough to retrieve." Can they share a threshold?
No — opposite directions. RAG recall should be wide: over-retrieve candidates and let a reranker/LLM filter; a false positive is harmless (downstream drops it). A cache hit should be strict: once hit, it's returned directly with no downstream gate, so a false positive becomes a wrong answer. The same query pair may deserve retrieval but not a cache hit. So the cache threshold must be markedly stricter than the retrieval threshold on the same embedding, and be labeled/tuned on "answer equivalence," not "topical relevance."
Reasoning models (o1/R1, Day 49) are pricier, so caching saves more. But their thinking path differs every run for the same prompt — what does that mean for semantic caching?
The reward is largest and so is the risk. Being expensive, one hit saves a lot; but the output is a long reasoning chain, and while two near-synonymous questions may share a correct final answer, their intermediate reasoning may not be reusable — caching the whole trace forces A's reasoning onto B. Pragmatic move: cache only the final answer, never the reasoning trace, and tighten the threshold beyond what you'd use for a plain model (longer answers diverge more easily on details). For high-value cases, prefer to allow only exact-tier hits and forgo semantic hits.
Why is "hit rate" — the most natural-looking KPI — actually the most dangerous metric in semantic caching?
Because it and false-hit rate are inversely coupled by the same dial (the threshold): loosening raises both. Once hit rate is a KPI, people optimize it — loosening the threshold, whose hit-rate gain is visible and reportable while the false-hit rise is silent and only visible via dedicated shadow sampling. The system gets steadily pushed toward "pretty savings, more wrong answers." The fix is to make false-hit rate an un-crossable guardrail and optimize hit rate only within it — the benefit metric obeys the guardrail. Same engineering philosophy as Day 34/56's "constrain optimization with a guardrail."
If the embedding model is upgraded (a stronger sentence encoder), what happens to an already-built semantic cache?
The whole vector store is void — old vectors and new-query vectors live in different spaces, distances are incomparable, and the threshold loses meaning. Same as Day 40's "embedding migration means rebuilding the index." So the embedding version must go into the scope key (or become a separate namespace), and on upgrade you keep old and new stores side by side and cut over gradually — never mix in place. It's a reminder: semantic caching ties "savings" to "embedding choice" — the cost of switching embeddings includes rebuilding the cache, so choose right the first time.
// FURTHER READING
GPTCache · Zilliz — reference semantic-cache impl, with embedding + vector store + eval module