Exact-Match & Semantic Caching

10 min read Module 6 of 10 Lesson 16 of 28

What you'll learn

  • Implement exact-match caching keyed on a normalized hash of the request
  • Implement semantic caching with an embedding similarity threshold, and explain why the threshold value is a real production tuning decision
  • Order a two-stage cache check (exact first, semantic second) and justify that ordering
  • Identify the specific risk semantic caching introduces that exact-match caching does not
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Lesson 4’s request lifecycle placed a cache lookup at stage 4, before any provider is even contacted. This lesson builds that cache for real, in two layers: exact-match, for genuinely repeated requests, and semantic, for requests that are different in wording but ask effectively the same thing, which is where most of a cache’s real savings usually come from in production.

Exact-Match Caching

The simple case: identical requests should never pay for a second LLM call. The key detail is normalization, two requests that are logically identical but serialized differently (key order, whitespace) need to hash to the same cache key:

import hashlib
import json
import redis

r = redis.Redis(host="localhost", port=6379)

def cache_key(model: str, messages: list[dict], temperature: float) -> str:
    # Normalize before hashing: sort keys, fixed separators, so two
    # logically identical requests always produce the same hash
    # regardless of how the client happened to serialize the JSON.
    payload = json.dumps(
        {"model": model, "messages": messages, "temperature": temperature},
        sort_keys=True, separators=(",", ":"),
    )
    return "exact:" + hashlib.sha256(payload.encode()).hexdigest()

def get_cached_or_none(model, messages, temperature) -> str | None:
    key = cache_key(model, messages, temperature)
    cached = r.get(key)
    return cached.decode() if cached else None

def store_in_cache(model, messages, temperature, response_text: str, ttl_seconds=3600):
    key = cache_key(model, messages, temperature)
    r.setex(key, ttl_seconds, response_text)

This alone handles a real and common case, retry storms where a client sends the exact same request multiple times, repeated automated checks, or multiple users independently asking a truly identical FAQ, but it does nothing for two users who ask “what’s your refund policy” and “how do refunds work,” which are the same question in different words and would each pay for a full LLM call under exact-match alone.

Semantic Caching

Semantic caching closes that gap by comparing meaning rather than exact text, using embedding similarity:

import numpy as np
from openai import OpenAI

embed_client = OpenAI(api_key="sk-...")

SIMILARITY_THRESHOLD = 0.95   # cosine similarity; tuned per note below

def embed(text: str) -> np.ndarray:
    resp = embed_client.embeddings.create(model="text-embedding-3-small", input=text)
    return np.array(resp.data[0].embedding)

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

class SemanticCache:
    def __init__(self):
        self._entries: list[tuple[np.ndarray, str, str]] = []  # (embedding, prompt, response)

    def lookup(self, prompt: str) -> str | None:
        query_embedding = embed(prompt)
        best_score, best_response = 0.0, None
        for cached_embedding, _cached_prompt, cached_response in self._entries:
            score = cosine_similarity(query_embedding, cached_embedding)
            if score > best_score:
                best_score, best_response = score, cached_response
        if best_score >= SIMILARITY_THRESHOLD:
            return best_response
        return None

    def store(self, prompt: str, response: str):
        self._entries.append((embed(prompt), prompt, response))

A production deployment backs the _entries list with a real vector store (Redis with a vector index, Faiss, or Qdrant, GPTCache, the open-source library from Zilliz built specifically for this, supports all three) rather than a Python list scanned linearly, but the logic is identical: embed the incoming prompt, find the closest cached entry, and serve it if similarity clears the threshold.

The Threshold Is a Real Tuning Decision

SIMILARITY_THRESHOLD is not a constant to set once and forget. Set it too low (permissive) and prompts that are merely topically related but actually ask different things get served the wrong cached answer, silently, since a semantic cache hit looks identical to a real response to whoever receives it. Set it too high (conservative) and the cache rarely fires, capturing little of the savings semantic caching exists for. Published guidance and common production practice lands cosine-similarity thresholds around 0.92-0.97 for most conversational use cases, with a common tuning approach: start conservative (e.g. 0.95), monitor false-positive hits for a period, and adjust in small increments rather than guessing a final value up front.

Two-Stage Lookup, in Order

flowchart LR
    Req["Incoming request"] --> Exact{"Exact-match\ncache hit?"}
    Exact -->|Yes, cheap hash lookup| Return1["Return cached response\n(near-zero cost)"]
    Exact -->|No| Semantic{"Semantic cache\nhit >= threshold?"}
    Semantic -->|Yes| Return2["Return cached response\n(one embedding call, no LLM call)"]
    Semantic -->|No| Provider["Call provider\n(Module 4 routing)"]

    style Req fill:#4338CA,stroke:#4338CA,color:#ffffff,rx:8px,ry:8px
    style Return1 fill:#F7F8FC,stroke:#0D9488,color:#0F172A,rx:8px,ry:8px
    style Return2 fill:#F7F8FC,stroke:#0D9488,color:#0F172A,rx:8px,ry:8px
    style Provider fill:#EEF0F7,stroke:#6366F1,color:#0F172A,rx:8px,ry:8px

Checking exact-match first is a pure cost optimization: a hash lookup is essentially free, while a semantic lookup pays for an embedding call and a similarity search, so the cheap, common case (truly repeated requests) is captured before paying for the broader, more expensive check. Both layers only ever save cost and latency for requests the gateway would otherwise have paid a provider for in full; Lesson 17 covers the complementary mechanism, prompt caching, that providers themselves offer for content that isn’t identical across requests but is reused within them, like a long system prompt repeated on every call.

Knowledge Check

3 questions to test your understanding

1 What key should an exact-match cache use to identify identical requests?

2 Why does a semantic cache need a similarity threshold, and what happens if it's set too low (too permissive)?

3 Why check the exact-match cache before the semantic cache, rather than only using semantic caching?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan