◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-04-29Advanced

Production Semantic Cache for Claude API — Similarity Thresholds, Pollution Defense, and What to Track

A production playbook for adding a semantic cache in front of Claude API — threshold tuning, multi-tenant isolation, pollution prevention, fallbacks, how it stacks with Anthropic's prompt caching, and the metrics that actually prove it works.

claude-api83semantic-cacheembeddingproduction111cost-optimization31observability21multi-tenant

✦ Premium Article

"I want to add caching, but what if a similar question returns a wrong answer?" — that was the first concern I heard, every single time, on three different products where I added semantic caching in front of Claude.

An exact-match cache (the SHA-256-keyed Redis pattern) is easy to ship. The problem is that real users phrase the same question differently — punctuation, word order, formality. Hit rates rarely cross 5%. Claude API spend stays flat and latency does not move.

A semantic cache uses embeddings to detect "near-equivalent" prompts and returns a previously generated response. Done well, hits return in roughly 50 ms with zero Claude API charge. Done naively, it serves a medical-domain response to a finance-domain question. What follows is the design I have settled on for drawing that line, written down together with the mistakes that shaped it and the checklist I now refuse to ship without.

Why a semantic cache — when an exact-match cache falls short

As an indie developer maintaining several small products, API spend is not an abstract line item for me — it is the difference between shipping a feature or shelving it. That pressure is exactly why I approached caching so carefully. In a support bot I run, 70% of user questions had the same intent under different wording. "I can't log in" / "Sign-in is failing" / "Can't get into my account" — three distinct keys to an exact-match cache. Embedded into the same vector space, the three collapse into one cache entry. That single observation is what makes the embedding-based approach worth the additional complexity.

Semantic caching pays off most when "vocabulary is broad but the response space is narrow" — FAQ bots, product-search assistants, error-help flows, customer-service routing, internal knowledge-base lookups. It pays off poorly when "every input is a unique code review" or "every prompt is a fresh creative generation" — forcing it there causes accidents and frustrated users.

Decide up front which surfaces are cacheable and which must always be fresh. In my deployments I add an explicit cacheable: true flag per endpoint. The default is false, and only endpoints we have verified empirically get the flag turned on. That single rule, more than any technical detail in this article, has prevented "we put it on everything and broke prod" mistakes. If you take only one thing from this guide, take that.

There is also a cost-benefit framing worth being honest about. If your monthly Claude API bill is below a few hundred dollars, the engineering effort to build, monitor, and maintain a semantic cache may not pay back. The real wins start when you are spending thousands per month on inference, when latency-sensitive endpoints have repeated query patterns, or when you want to make the same answer available across many users without re-paying for it.

Architecture — three cache tiers and the data flow

A production-grade setup is rarely a single layer. I run three:

  • Tier 1: exact-match (Redis, short TTL) — fastest, zero false positives
  • Tier 2: semantic (vector DB with threshold filter) — fast, requires guards
  • Tier 3: Claude API (fallback) — slowest, always correct

Queries cascade. Tier 1 first; if a hit, return immediately. Otherwise Tier 2 with a similarity threshold (I use ~0.92 in production, more on this below). If neither, fall through to Tier 3 and call Claude. After a Tier 3 call, the response is written back into both Tier 1 and Tier 2 — but only after passing the safety guards from Step 3.

Why three tiers and not two? Because exact match is essentially free in latency and impossible to get wrong. Even if your semantic cache is perfect, the same user often re-issues the exact same query within a session, and you want that round-trip to be 1 ms, not 50 ms. Tier 1 handles the easy case; Tier 2 picks up the long tail.

Include tenant_id in the cache key. The single most common mistake I see (and one I have made myself) is filtering by tenant only at write time and forgetting it at read time, leaking responses across tenants. We will harden against this in Step 2.

One correction before the code. The first version of this article used qdrant.search(). When I bumped my environment to qdrant-client 1.19.1, the QdrantClient class no longer had a search attribute at all and the lookup died with an AttributeError. The method was folded into query_points() in 1.10, sat through a deprecation window, and has since been removed. The code below uses query_points() and reads .points off the response. If you are pinned to an older client, search() still works — but I would rather you not discover the removal the way I did, on a morning when a dependency bump was supposed to be routine.

# semantic_cache.py — three-tier lookup flow
import hashlib
import time
 
from anthropic import Anthropic
from redis import Redis
import qdrant_client
from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue
 
from cache_guard import is_safe_to_cache   # Step 3: the three pre-write guards
 
EXACT_TTL_SEC = 3600                # Tier 1: 1 hour
SEMANTIC_THRESHOLD = 0.92           # Tier 2: cosine similarity floor
SEMANTIC_TTL_SEC = 86400            # Tier 2: 24 hours
 
def get_cached_response(
    tenant_id: str,
    user_query: str,
    redis: Redis,
    qdrant: qdrant_client.QdrantClient,
    embed_fn,                       # external embedding function (Voyage, Cohere, etc.)
    anthropic: Anthropic,
    feature_id: str,                # the cacheable: true endpoint identifier
) -> dict:
    # Tier 1: exact match
    exact_key = f"cache:exact:{tenant_id}:{feature_id}:{hashlib.sha256(user_query.encode()).hexdigest()}"
    cached = redis.get(exact_key)
    if cached:
        return {"text": cached.decode(), "layer": "exact", "latency_ms": 1}
 
    # Tier 2: semantic (qdrant-client >= 1.10 uses query_points; search() was removed by 1.19)
    embedding = embed_fn(user_query)            # assume 1024-d vectors
    hits = qdrant.query_points(
        collection_name="claude_cache",
        query=embedding,
        query_filter=Filter(must=[
            FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
            FieldCondition(key="feature_id", match=MatchValue(value=feature_id)),
        ]),
        limit=1,
        score_threshold=SEMANTIC_THRESHOLD,    # below threshold returns nothing
        with_payload=True,
    ).points
    if hits:
        # backfill Tier 1 so the next exact match is even faster
        redis.setex(exact_key, EXACT_TTL_SEC, hits[0].payload["response"])
        return {"text": hits[0].payload["response"], "layer": "semantic", "score": hits[0].score}
 
    # Tier 3: Claude API (fallback)
    start = time.perf_counter()
    response = anthropic.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": user_query}],
    )
    latency_ms = int((time.perf_counter() - start) * 1000)
    text = response.content[0].text
 
    # Persist to both tiers only if the guards pass; the user gets the answer either way
    if is_safe_to_cache(user_query, response, anthropic):
        redis.setex(exact_key, EXACT_TTL_SEC, text)
        qdrant.upsert(
            collection_name="claude_cache",
            points=[PointStruct(
                id=hashlib.sha256(f"{tenant_id}:{feature_id}:{user_query}".encode()).hexdigest()[:16],
                vector=embedding,
                payload={"tenant_id": tenant_id, "feature_id": feature_id,
                         "query": user_query, "response": text, "ts": time.time()},
            )],
        )
    return {"text": text, "layer": "api", "latency_ms": latency_ms}

Expected log lines look like this:

{"layer": "exact",    "latency_ms": 1}
{"layer": "semantic", "score": 0.94}
{"layer": "api",      "latency_ms": 1820}

If layer is exact or semantic, you paid Claude nothing for that request. The score field on semantic hits is also valuable later when you debug a complaint — being able to answer "the system returned a near-match at 0.93 cosine; here is the original question" turns a vague support ticket into something you can actually triage.

A second confession about the first draft: the write path carried a comment saying "only after the safety check" and then wrote to both tiers unconditionally. Re-reading it months later was the moment I understood that a comment describes intent but guarantees nothing. The version above wraps the writes in is_safe_to_cache() and keeps the user-facing return independent of the guard's verdict.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Threshold tuning from a labeled precision-recall curve with Precision fixed at 0.98 to hold false positives under 2%
✦A three-guard write path (injection filter, stop_reason check, Haiku LLM-as-a-judge) that prevents cache poisoning, plus how the cost math changes when you stack it on Anthropic's prompt caching
✦A three-tier cache design with a measured 47% hit rate and 38% cost reduction (updated for qdrant-client 1.19's query_points), plus a full production checklist
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-05-05
The Real Cost of Claude API Extended Thinking in Production — ROI Data by Task Type
Three months of measured cost, quality, and speed data for Extended Thinking across five task categories. Learn exactly when extended thinking is worth it—and when it's not.
⬡ API & SDK2026-05-01
Claude API Telemetry on ClickHouse: A Production Guide to Cost, Latency, and Error Analytics
Stream per-request Claude API telemetry into ClickHouse, build sub-second dashboards with materialized views, and detect cost spikes, retry loops, and silent failures with practical SQL recipes.
⬡ API & SDK2026-04-30
Building a Production Claude API Pipeline on Cloudflare Queues: Fault Tolerance, Backpressure, and Cost Control
A practical, code-first walkthrough for routing Claude API calls through Cloudflare Queues — covering producer/consumer code, retry-vs-DLQ branching, priority lanes, and token budgeting for production workloads.