Reuse stable policy answers across paraphrased questions without crossing release, access, or freshness boundaries; then prove the cache is both safe and worth serving.
A cached answer is behavior produced by one particular release, prompt policy, corpus, and access scope. Semantic caching only works when that contract is explicit.
Suppose the deployed developer-docs assistant repeatedly answers the public question, "How long are revoked API keys retained?" Developers phrase that question many ways. Reusing a verified answer could save generation work and respond faster. Reusing the answer after an API-key retention policy update, across a tenant boundary, or for a live incident-status question could be plainly wrong.
The safe version starts with one semantic cache for public policy answers. It will serve nothing until a shadow replay shows safe hits and worthwhile savings.
A response cache isn't a memory of generally true sentences. It's a store of outputs generated under a particular contract. The release bundle from the previous chapter gives us most of that contract already.
| Request | Can an answer be reused? | Why |
|---|---|---|
| "How long are revoked API keys retained?" | Candidate | Public docs-policy answer can remain stable within one policy release. |
| "How long do revoked keys stay in audit logs?" | Candidate | Paraphrase of the same public docs-policy question, after evaluation. |
| "What is incident INC-48192's current status?" | No | Answer depends on live incident state. |
| "Revoke API key KEY-48192." | No | The request asks for a side effect, not a reusable answer. |
For this system, a reusable answer must match all of these fields:
| Field | Why it matters |
|---|---|
release_id | Pins model, prompt, policy logic, and serving behavior. |
corpus_version | Prevents old policy evidence from surviving a document update. |
tenant_id and access_scope | Prevents one tenant's restricted information from leaking into another response. |
locale and response_schema | Prevents the right content from appearing in the wrong language or output contract. |
Semantic lookup has a second version boundary. The embedding model, vector preprocessing, and index build determine the score distribution. Record them as embedding_model_id and embedding_index_version in the cache policy. If either changes, rebuild the vectors and recalibrate the threshold in shadow mode before serving semantic hits. The admitted answer may still be valid, but an old score threshold no longer has evidence behind it.
Start the lab with the exact release scope and one response generated by the promoted release.
1from dataclasses import asdict, dataclass, replace
2from hashlib import sha256
3import json
4import math
5
6@dataclass(frozen=True)
7class ReleaseScope:
8 release_id: str
9 corpus_version: str
10 tenant_id: str
11 access_scope: str
12 locale: str
13 response_schema: str
14
15@dataclass(frozen=True)
16class Request:
17 text: str
18 tenant_id: str = "docs-public"
19 access_scope: str = "public-policy"
20 locale: str = "en-US"
21 response_schema: str = "cited-answer-v2"
22 requires_live_data: bool = False
23 writes_state: bool = False
24
25@dataclass(frozen=True)
26class CachedAnswer:
27 answer_id: str
28 source_query: str
29 response: str
30 scope: ReleaseScope
31 response_class: str
32 admission_evidence_id: str
33
34stable_scope = ReleaseScope(
35 release_id="docs-evidence-answerer@sha256:df2d4fe7b0c5",
36 corpus_version="api-key-policy-2026-04",
37 tenant_id="docs-public",
38 access_scope="public-policy",
39 locale="en-US",
40 response_schema="cited-answer-v2",
41)
42
43seed_answer = CachedAnswer(
44 answer_id="ans_api_key_retention_30d",
45 source_query="How long are revoked API keys retained?",
46 response="Revoked API keys remain visible in audit logs for 30 days.",
47 scope=stable_scope,
48 response_class="public-policy",
49 admission_evidence_id="eval-public-policy-api-keys-v4",
50)
51
52approved_admission_evidence_ids = {
53 "eval-public-policy-api-keys-v4",
54 "eval-public-policy-rate-limits-v1",
55}
56
57print(f"release_id={stable_scope.release_id}")
58print(f"corpus_version={stable_scope.corpus_version}")
59print(f"seed_answer={seed_answer.answer_id}")1release_id=docs-evidence-answerer@sha256:df2d4fe7b0c5
2corpus_version=api-key-policy-2026-04
3seed_answer=ans_api_key_retention_30dFor requests already eligible for answer reuse, an ordinary key-value cache can safely reuse normalized exact repeats as long as its key contains the full scope. It can't see through paraphrasing.
The scope fields must come from application-owned route policy and authenticated context, not from raw user text or a model's guess. Unknown response classes should bypass answer reuse. The lab also checks that the caller-selected scope agrees with the request before deriving a key.
1def normalized_text(text: str) -> str:
2 return " ".join(text.lower().split())
3
4def request_matches_scope(request: Request, scope: ReleaseScope) -> bool:
5 return (
6 request.tenant_id == scope.tenant_id
7 and request.access_scope == scope.access_scope
8 and request.locale == scope.locale
9 and request.response_schema == scope.response_schema
10 )
11
12def exact_key(scope: ReleaseScope, request: Request) -> str:
13 if not request_matches_scope(request, scope):
14 raise ValueError("request does not match cache scope")
15 payload = {
16 "scope": asdict(scope),
17 "text": normalized_text(request.text),
18 }
19 encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
20 return sha256(encoded).hexdigest()
21
22same_words = Request("How long are revoked API keys retained?")
23paraphrase = Request("How long do revoked keys stay in audit logs?")
24cross_tenant = replace(same_words, tenant_id="internal-admin")
25updated_scope = replace(
26 stable_scope,
27 release_id="docs-evidence-answerer@sha256:new-policy",
28 corpus_version="api-key-policy-2026-05",
29)
30
31seed_key = exact_key(stable_scope, Request(seed_answer.source_query))
32print(f"exact_repeat_hit={exact_key(stable_scope, same_words) == seed_key}")
33print(f"paraphrase_exact_hit={exact_key(stable_scope, paraphrase) == seed_key}")
34print(f"updated_policy_hit={exact_key(updated_scope, same_words) == seed_key}")
35try:
36 exact_key(stable_scope, cross_tenant)
37except ValueError as error:
38 print(f"cross_tenant_rejected={error}")1exact_repeat_hit=True
2paraphrase_exact_hit=False
3updated_policy_hit=False
4cross_tenant_rejected=request does not match cache scopeThe exact cache does the correct thing: it refuses a paraphrase and refuses an old answer under a new policy release. Semantic caching adds only the first capability. It must not weaken the second.
A semantic cache embeds a new question, searches stored question embeddings, and proposes a nearby saved answer. In a larger store, that retrieval step is commonly an approximate nearest-neighbor (ANN) search. Systems such as GPTCache apply candidate retrieval before deciding whether to call the LLM at all.[1] Sentence-BERT showed why this shape works: sentence embeddings can be compared efficiently with cosine similarity for semantic matching tasks.[2]
For two vectors and , cosine similarity is:
The numerator measures their aligned components. Dividing by both lengths makes the result compare direction rather than vector magnitude. A high score says two encoded questions are close under this embedding model. It does not say their answers are interchangeable.
The tiny vectors below are an instructional fixture, not scores from a commercial embedding model. They let us see the failure mode without downloading a model: a restore-key exception can sit near a general retention question while still needing a different answer.
1fixture_vectors = {
2 seed_answer.source_query: (1.00, 0.00, 0.00),
3 "How long do revoked keys stay in audit logs?": (0.99, 0.04, 0.00),
4 "Can I restore a revoked API key?": (0.94, 0.10, 0.00),
5 "What is incident INC-48192's current status?": (0.00, 0.05, 1.00),
6}
7
8def cosine(left: tuple[float, ...], right: tuple[float, ...]) -> float:
9 dot = sum(a * b for a, b in zip(left, right))
10 left_norm = math.sqrt(sum(value * value for value in left))
11 right_norm = math.sqrt(sum(value * value for value in right))
12 return dot / (left_norm * right_norm)
13
14seed_vector = fixture_vectors[seed_answer.source_query]
15for question in [
16 "How long do revoked keys stay in audit logs?",
17 "Can I restore a revoked API key?",
18 "What is incident INC-48192's current status?",
19]:
20 score = cosine(seed_vector, fixture_vectors[question])
21 print(f"{question} | score={score:.3f}")1How long do revoked keys stay in audit logs? | score=0.999
2Can I restore a revoked API key? | score=0.994
3What is incident INC-48192's current status? | score=0.000
The assistant shouldn't response-cache live incident state or actions at any threshold. Even for a public-policy question, a cached answer must be from the same release scope.
This decision procedure checks the non-negotiable rules first. Only an eligible, same-scope request reaches the similarity threshold.
1def same_scope(request: Request, record: CachedAnswer, active: ReleaseScope) -> bool:
2 return (
3 record.scope == active
4 and request_matches_scope(request, active)
5 )
6
7def record_is_admitted(record: CachedAnswer) -> bool:
8 return (
9 record.response_class == "public-policy"
10 and record.admission_evidence_id in approved_admission_evidence_ids
11 )
12
13def decide_candidate(
14 request: Request,
15 record: CachedAnswer,
16 active: ReleaseScope,
17 score: float,
18 threshold: float,
19) -> str:
20 if request.requires_live_data or request.writes_state:
21 return "BYPASS_DYNAMIC_OR_WRITE"
22 if not same_scope(request, record, active):
23 return "MISS_SCOPE_CHANGED"
24 if not record_is_admitted(record):
25 return "BYPASS_UNVALIDATED_RECORD"
26 if score < threshold:
27 return "MISS_BELOW_THRESHOLD"
28 return "SEMANTIC_HIT"
29
30policy_paraphrase = Request("How long do revoked keys stay in audit logs?")
31live_incident = Request(
32 "What is incident INC-48192's current status?",
33 access_scope="incident-state",
34 requires_live_data=True,
35)
36revoke_action = Request(
37 "Revoke API key KEY-48192.",
38 access_scope="key-admin",
39 writes_state=True,
40)
41
42policy_score = cosine(seed_vector, fixture_vectors[policy_paraphrase.text])
43print(decide_candidate(policy_paraphrase, seed_answer, stable_scope, policy_score, 0.98))
44print(decide_candidate(live_incident, seed_answer, stable_scope, 1.00, 0.98))
45print(decide_candidate(revoke_action, seed_answer, stable_scope, 1.00, 0.98))1SEMANTIC_HIT
2BYPASS_DYNAMIC_OR_WRITE
3BYPASS_DYNAMIC_OR_WRITEA time-to-live (TTL) can expire old entries after a period. It can't know that an API-key retention policy changed five minutes after an answer was stored. The release bundle provides a stronger invalidation hook: if policy evidence or answer behavior changes, the release or corpus version changes and old entries are no longer in scope.
1policy_update = replace(
2 stable_scope,
3 release_id="docs-evidence-answerer@sha256:7a12policy",
4 corpus_version="api-key-policy-2026-05",
5)
6
7same_question = Request(seed_answer.source_query)
8old_release_decision = decide_candidate(
9 same_question, seed_answer, stable_scope, 1.00, 0.98
10)
11new_release_decision = decide_candidate(
12 same_question, seed_answer, policy_update, 1.00, 0.98
13)
14
15print(f"old_release={old_release_decision}")
16print(f"new_policy_release={new_release_decision}")
17print(f"new_release_must_generate={new_release_decision != 'SEMANTIC_HIT'}")1old_release=SEMANTIC_HIT
2new_policy_release=MISS_SCOPE_CHANGED
3new_release_must_generate=TrueCache identity should inherit the release identity from deployment. Eviction can clean up storage later; correctness shouldn't depend on eviction finishing first. Keep a bounded TTL as a cleanup policy and backstop, but don't treat it as the authoritative invalidation signal.
Serving a semantic hit immediately turns a retrieval mistake into a user-visible wrong answer. Shadow mode runs the lookup decision but still serves the normal fresh path. Reviewers then label whether each proposed reuse would have been acceptable.
A good cache metric separates two questions:
High proposal rate without high precision is a cheaper system that's wrong more often. A raw cache hit rate can't distinguish those outcomes.
The labeled fixture below contains public-policy paraphrases, a subtle opened-item exception, and ineligible requests. Thresholds are specific to this fixture and embedding setup; don't copy them into production. Seven probes are enough to explain the tradeoff, but not enough to authorize a 99% production precision claim.
1@dataclass(frozen=True)
2class ShadowProbe:
3 name: str
4 score: float
5 eligible: bool
6 acceptable_reuse: bool
7
8shadow_probes = [
9 ShadowProbe("key retention paraphrase", 0.995, True, True),
10 ShadowProbe("audit log retention wording", 0.989, True, True),
11 ShadowProbe("revoked key wording", 0.982, True, True),
12 ShadowProbe("policy FAQ reworded", 0.981, True, True),
13 ShadowProbe("restore-key exception", 0.965, True, False),
14 ShadowProbe("live incident state", 0.999, False, False),
15 ShadowProbe("revoke key action", 0.997, False, False),
16]
17
18def replay_at(threshold: float) -> dict[str, float | int]:
19 proposed = [
20 probe for probe in shadow_probes
21 if probe.eligible and probe.score >= threshold
22 ]
23 accepted = [probe for probe in proposed if probe.acceptable_reuse]
24 precision = len(accepted) / len(proposed) if proposed else 1.0
25 return {
26 "proposed": len(proposed),
27 "accepted": len(accepted),
28 "precision": precision,
29 "proposal_rate": len(proposed) / len(shadow_probes),
30 }
31
32for threshold in [0.960, 0.980, 0.990]:
33 metrics = replay_at(threshold)
34 print(
35 f"threshold={threshold:.3f} "
36 f"proposed={metrics['proposed']} "
37 f"precision={metrics['precision']:.1%} "
38 f"proposal_rate={metrics['proposal_rate']:.1%}"
39 )
40
41selected_threshold = 0.980
42
43def wilson_lower_bound(successes: int, trials: int, z: float = 1.96) -> float:
44 if trials == 0:
45 return 0.0
46 observed = successes / trials
47 denominator = 1 + z**2 / trials
48 center = observed + z**2 / (2 * trials)
49 margin = z * math.sqrt(
50 observed * (1 - observed) / trials + z**2 / (4 * trials**2)
51 )
52 return (center - margin) / denominator
53
54# Synthetic full-window fixture at the selected threshold. In production,
55# populate these counts from representative labeled shadow traffic.
56full_shadow_requests = 10_000
57full_shadow_proposed = 5_714
58full_shadow_accepted = 5_714
59full_shadow_precision = full_shadow_accepted / full_shadow_proposed
60precision_lower_bound = wilson_lower_bound(
61 full_shadow_accepted,
62 full_shadow_proposed,
63)
64safe_hit_fraction = full_shadow_accepted / full_shadow_requests
65
66print(
67 f"full_shadow proposed={full_shadow_proposed} "
68 f"precision={full_shadow_precision:.1%} "
69 f"precision_lower_bound={precision_lower_bound:.2%}"
70)1threshold=0.960 proposed=5 precision=80.0% proposal_rate=71.4%
2threshold=0.980 proposed=4 precision=100.0% proposal_rate=57.1%
3threshold=0.990 proposed=1 precision=100.0% proposal_rate=14.3%
4full_shadow proposed=5714 precision=100.0% precision_lower_bound=99.93%
The larger fixture keeps the same 0.980 policy but evaluates 5,714 proposed hits from 10,000 requests. All are accepted, so observed precision is 100%. More importantly, the 95% Wilson lower bound is 99.93%, above the 99% gate. A confidence bound prevents a tiny perfect sample from looking stronger than it's. The traffic still needs to represent the production mix; statistical confidence can't repair a biased replay.
Every semantic lookup incurs work, even on a miss: embedding the request, searching an index, and recording metrics. Evaluate cost only after the precision gate passes.
Let:
If a hit skips fresh generation, expected period savings are:
These quantities must come from the workload and model you plan to operate. The next example uses labeled measurement fixtures, not provider prices.
1requests_per_day = full_shadow_requests
2fresh_generation_usd = 0.0040 # measured fixture: average full answer cost
3semantic_lookup_usd = 0.00008 # measured fixture: embed + index lookup
4
5without_cache = requests_per_day * fresh_generation_usd
6with_cache = requests_per_day * (
7 semantic_lookup_usd + (1 - safe_hit_fraction) * fresh_generation_usd
8)
9savings = without_cache - with_cache
10break_even_hit_fraction = semantic_lookup_usd / fresh_generation_usd
11
12print(f"safe_hit_fraction={safe_hit_fraction:.1%}")
13print(f"break_even_hit_fraction={break_even_hit_fraction:.1%}")
14print(f"daily_savings_fixture_usd={savings:.2f}")
15print(f"savings_positive={savings > 0}")1safe_hit_fraction=57.1%
2break_even_hit_fraction=2.0%
3daily_savings_fixture_usd=22.06
4savings_positive=TrueDon't guess from list price. Measure generation and lookup cost for the real release and traffic mix, then rerun the gate when either changes.
The read path now rejects records without approved admission evidence. The write path must enforce the same rule before adding a record to the servable index. A freshly generated answer isn't automatically safe to repeat across paraphrases.
Treat admission as write authorization. Keep unreviewed answers in a quarantine store, attach the evaluation artifact that approved a response class, and admit only records inside the tested release scope.
1def admission_decision(answer: CachedAnswer) -> str:
2 if answer.response_class != "public-policy":
3 return "QUARANTINE_RESPONSE_CLASS"
4 if answer.scope != stable_scope:
5 return "QUARANTINE_SCOPE_CHANGED"
6 if answer.admission_evidence_id not in approved_admission_evidence_ids:
7 return "QUARANTINE_MISSING_EVIDENCE"
8 return "ADMIT_SERVABLE"
9
10unreviewed_answer = replace(
11 seed_answer,
12 answer_id="ans_restore_key_review",
13 admission_evidence_id="",
14)
15validated_policy_answer = replace(
16 seed_answer,
17 answer_id="ans_rate_limit_public",
18 source_query="What is the default API rate limit?",
19 response="Default API keys allow 600 requests per minute unless the account policy says otherwise.",
20 admission_evidence_id="eval-public-policy-rate-limits-v1",
21)
22dynamic_incident_answer = replace(
23 seed_answer,
24 answer_id="ans_live_incident_status",
25 response_class="live-incident-status",
26)
27
28for label, answer in [
29 ("unreviewed", unreviewed_answer),
30 ("validated_policy", validated_policy_answer),
31 ("dynamic_incident", dynamic_incident_answer),
32]:
33 print(f"{label}={admission_decision(answer)}")1unreviewed=QUARANTINE_MISSING_EVIDENCE
2validated_policy=ADMIT_SERVABLE
3dynamic_incident=QUARANTINE_RESPONSE_CLASSThe admitted record still isn't a universal truth. Reads must match its release and access scope, then pass the calibrated semantic threshold. Admission prevents one bad fresh generation from silently becoming a high-fanout cached answer.
Don't turn on semantic caching for all support. Promote only the public-policy scope that passed shadow evidence. Account state and write actions still bypass.
1@dataclass(frozen=True)
2class CachePromotionGate:
3 minimum_precision_lower_bound: float
4 minimum_daily_savings_usd: float
5 required_scope: ReleaseScope
6
7gate = CachePromotionGate(
8 minimum_precision_lower_bound=0.99,
9 minimum_daily_savings_usd=5.00,
10 required_scope=stable_scope,
11)
12
13passes_quality = (
14 precision_lower_bound >= gate.minimum_precision_lower_bound
15)
16passes_economics = savings >= gate.minimum_daily_savings_usd
17passes_scope = seed_answer.scope == gate.required_scope
18decision = (
19 "PROMOTE_PUBLIC_POLICY_SEMANTIC_CACHE"
20 if passes_quality and passes_economics and passes_scope
21 else "KEEP_SHADOW_ONLY"
22)
23
24print(f"quality_gate={passes_quality}")
25print(f"economics_gate={passes_economics}")
26print(f"scope_gate={passes_scope}")
27print(f"cache_decision={decision}")1quality_gate=True
2economics_gate=True
3scope_gate=True
4cache_decision=PROMOTE_PUBLIC_POLICY_SEMANTIC_CACHE
Once the cache is serving, traces must answer: which release generated the cached response, which cache policy reused it, and why a request bypassed reuse? Without those fields, wrong-hit incidents become difficult to reconstruct.
1def trace_decision(request: Request, score: float) -> dict[str, str | float]:
2 cache_decision = decide_candidate(
3 request, seed_answer, stable_scope, score, selected_threshold
4 )
5 return {
6 "request": request.text,
7 "release_id": stable_scope.release_id,
8 "corpus_version": stable_scope.corpus_version,
9 "tenant_id": request.tenant_id,
10 "access_scope": request.access_scope,
11 "cache_policy": "public-policy-semantic-v1",
12 "embedding_model_id": "docs-query-embedder-v3",
13 "embedding_index_version": "api-key-policy-index-2026-04-v2",
14 "answer_id": seed_answer.answer_id if cache_decision == "SEMANTIC_HIT" else "",
15 "admission_evidence_id": seed_answer.admission_evidence_id,
16 "decision": cache_decision,
17 "score": score,
18 }
19
20hit_trace = trace_decision(policy_paraphrase, policy_score)
21bypass_trace = trace_decision(live_incident, 1.00)
22
23print(f"hit_decision={hit_trace['decision']} answer_id={hit_trace['answer_id']}")
24print(f"bypass_decision={bypass_trace['decision']}")
25print(f"traced_release={hit_trace['release_id'] == stable_scope.release_id}")
26print(f"traced_scope={hit_trace['corpus_version'] == stable_scope.corpus_version and hit_trace['access_scope'] == stable_scope.access_scope}")
27print(f"traced_index={hit_trace['embedding_index_version'] == 'api-key-policy-index-2026-04-v2'}")
28print(f"traced_admission={hit_trace['admission_evidence_id'] == seed_answer.admission_evidence_id}")1hit_decision=SEMANTIC_HIT answer_id=ans_api_key_retention_30d
2bypass_decision=BYPASS_DYNAMIC_OR_WRITE
3traced_release=True
4traced_scope=True
5traced_index=True
6traced_admission=TrueWatch production for accepted-hit review failures, user retries after cache hits, scope bypass volume, p95 latency, and realized saved generation. An incident should be able to disable this cache policy pointer without changing the production release that generates fresh responses.
The cache in this lab can return a stored answer for a paraphrase and skip generation. Provider prompt caching operates at a different layer. OpenAI's documented prompt caching applies automatically to prompts of at least 1,024 tokens, requires an exact prefix match, and reports how many input tokens were cached. The model still computes a new output.[3]
| Layer | Matches | Result on hit | Main correctness risk |
|---|---|---|---|
| Exact response cache | Same scoped request key | Return stored answer, skip generation | Stale or incomplete scope key |
| Semantic response cache | Similar eligible question under same contract | Return stored answer, skip generation | False semantic reuse |
| Provider prompt cache | Matching input prefix under provider rules | Compute a new answer with cheaper/faster repeated input work | Missed cost opportunity, not stored-answer substitution |
The distinction determines the evaluation: semantic answer caching needs labeled reuse precision; prompt-prefix caching needs token and latency accounting. The next chapter expands that economics.
1@dataclass(frozen=True)
2class ReuseCase:
3 name: str
4 semantic_answer_hit: bool
5 repeated_prefix_hit: bool
6
7cases = [
8 ReuseCase(
9 name="paraphrased public API-key question",
10 semantic_answer_hit=True,
11 repeated_prefix_hit=False,
12 ),
13 ReuseCase(
14 name="new live incident question after same long instructions",
15 semantic_answer_hit=False,
16 repeated_prefix_hit=True,
17 ),
18]
19
20for case in cases:
21 print(
22 f"{case.name}: "
23 f"skip_generation={case.semantic_answer_hit}, "
24 f"reuse_input_work={case.repeated_prefix_hit}"
25 )
26
27print("next_measure_token_economics=True")1paraphrased public API-key question: skip_generation=True, reuse_input_work=False
2new live incident question after same long instructions: skip_generation=False, reuse_input_work=True
3next_measure_token_economics=Trueresponse_schema="json-citations-v3" to a new scope and prove an old natural-language answer can't hit it.embedding_model_id, rebuild the index under a new version, and keep it shadow-only until its threshold passes calibration.Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Questions and insights from fellow learners.