LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Semantic Caching for LLM Applications: Cutting Cost and Latency

llmcachingperformancerediscostaipythoninfrastructure

LLM API calls are expensive and slow. A GPT-4o call costs $0.005–0.015 per 1K tokens and returns results in 500–1500ms. For applications where many users ask semantically similar questions—customer support, FAQ chatbots, documentation assistants, internal knowledge bases—a large fraction of those calls are paying full price for answers already computed moments ago.

Exact-match caching solves a small part of this. If a user asks “what is your return policy?” and another user asks the identical string, a simple key-value cache serves the second request instantly. But “what’s your return policy?”, “can I return items?”, “how do returns work?”, “do you accept returns?” are all the same question from a cost perspective—the cached answer is equally correct for all of them. An exact-match cache misses every paraphrase.

Semantic caching addresses this by embedding each query into a high-dimensional vector and searching for similar previous queries. When a new query is close enough to a cached one—as measured by cosine similarity between their embeddings—the cached response is returned. The embedding lookup takes 15–25ms. The saved LLM call costs hundreds of milliseconds and real money.

At 67% cache hit rates, semantic caching reduces costs by 60–70% and improves median latency by 60–70%. These numbers vary dramatically with workload; a documentation assistant with repetitive questions hits 80%+ rates, while a creative writing tool hits under 5%. Knowing when to use it—and how to tune it—is the entire challenge.


How Semantic Caching Works

The pipeline has four steps: embed, search, decide, and return or compute.

Semantic cache pipeline:

  Incoming query
       │
       ▼
  Embed query ──────────────────────────────────────────────────────┐
  (embedding model, 15-25ms)                                        │
       │                                                            │
       ▼                                                            │
  Vector similarity search                                          │
  (ANN search in cache store, 5-10ms)                               │
       │                                                            │
       ├─── similarity >= threshold ────────────────────────────────►  Return cached response
       │    (cache hit)                                                 (< 30ms total)
       │
       └─── similarity < threshold                                  │
            (cache miss)                                            │
                │                                                   │
                ▼                                                   │
           Call LLM                                                 │
           (500–1500ms)                                             │
                │                                                   │
                ▼                                                   │
           Store (query_embedding, response) in cache               │
                │                                                   │
                ▼                                                   │
           Return response

The Embedding Model

The embedding model converts text into a dense vector (typically 768–3072 dimensions). Queries with similar meaning cluster near each other in this space; dissimilar queries are far apart. The quality of the cache depends entirely on the quality of the embedding model—a model that encodes “how do returns work” and “what is your return policy” into nearby vectors will produce useful cache hits; a weak model will not.

Embedding model choice involves a latency/quality tradeoff:

Embedding model options for semantic cache:

  Model                    | Dims | Latency  | Quality  | Cost       | Notes
  -------------------------|------|----------|----------|------------|------------------------
  text-embedding-3-small   | 1536 | ~50ms    | good     | $0.02/1M   | OpenAI, simple to use
  text-embedding-3-large   | 3072 | ~70ms    | v.good   | $0.13/1M   | Overkill for most caches
  all-MiniLM-L6-v2         | 384  | ~5ms     | decent   | free       | Local, small, fast
  all-mpnet-base-v2        | 768  | ~15ms    | good     | free       | Local, better quality
  BAAI/bge-small-en-v1.5   | 384  | ~5ms     | good     | free       | Best small local model
  BAAI/bge-large-en-v1.5   | 1024 | ~20ms    | v.good   | free       | Best large local model
  nomic-embed-text-v1.5    | 768  | ~10ms    | v.good   | free       | Local, strong on domains

For a production semantic cache, a local model like BAAI/bge-large-en-v1.5 is the right default: zero per-call cost, 20ms latency, and quality that matches or exceeds OpenAI’s small embedding model on most English-language tasks. Running it via sentence-transformers requires a one-time model download (~1.3 GB) and fits on a CPU.

Similarity Metrics

Cosine similarity is the standard metric for embedding spaces:

cosine_similarity(a, b) = (a · b) / (||a|| * ||b||)

Range: -1 to 1
  1.0 = identical direction (semantically very similar)
  0.0 = orthogonal (unrelated)
 -1.0 = opposite direction (rare in practice for text)

In practice for text embeddings:
  > 0.97  = near-identical phrasing
  0.93-0.97 = same question, different words
  0.88-0.93 = related topic, not identical question
  0.80-0.88 = same general domain, different question
  < 0.80  = different topics

L2 (Euclidean) distance also works and is equivalent to cosine similarity for unit-normalized vectors. Most vector databases store pre-normalized vectors and default to cosine or inner product distance.


Similarity Threshold: The Hard Part

The threshold is a single number that controls everything—cache hit rate, accuracy, and whether your users start getting wrong answers. There is no universal correct value. It must be tuned for your specific workload.

What Goes Wrong at Each Extreme

Threshold too low (e.g., 0.80): Queries that are topically related but semantically distinct get the same cached response. “What is your return policy?” and “what is your shipping policy?” have similar embeddings—both about policies—but different answers. At threshold 0.80, one might incorrectly serve the other’s cached response. Users get wrong answers. This is the worst failure mode.

Threshold too high (e.g., 0.99): Only near-verbatim rephrases hit the cache. “How do returns work?” and “how does the return process work?” might score 0.975—a miss. Cache hit rate drops to near zero, and you have added 25ms of lookup overhead for no benefit.

The workload dependence problem: Code queries cluster tightly in embedding space (technical vocabulary is precise; “sort a list in Python” and “Python list sorting” are extremely close). Conversational queries distribute more broadly. A single threshold performs poorly across mixed workloads—too aggressive for code, too conservative for conversation.

Tuning Procedure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# Step 1: Collect a dataset of query pairs from production logs
# Label each pair: same_answer (True/False) via human review or LLM-as-judge

query_pairs = [
    ("what is your return policy?", "how do I return an item?", True),
    ("what is your return policy?", "how do I track my order?", False),
    ("sort a list in Python", "Python list sort example", True),
    ("sort a list in Python", "sort a dict in Python", False),
    # ... 500+ pairs for reliable estimates
]

# Step 2: Compute similarity scores for all pairs
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("BAAI/bge-large-en-v1.5")

pairs_with_scores = []
for q1, q2, same_answer in query_pairs:
    e1 = model.encode(q1, normalize_embeddings=True)
    e2 = model.encode(q2, normalize_embeddings=True)
    score = float(np.dot(e1, e2))
    pairs_with_scores.append((q1, q2, same_answer, score))

# Step 3: Compute precision/recall at each threshold
thresholds = np.arange(0.75, 0.99, 0.01)
results = []
for thresh in thresholds:
    tp = sum(1 for _, _, same, score in pairs_with_scores if same and score >= thresh)
    fp = sum(1 for _, _, same, score in pairs_with_scores if not same and score >= thresh)
    fn = sum(1 for _, _, same, score in pairs_with_scores if same and score < thresh)
    
    precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
    
    results.append({
        "threshold": thresh,
        "precision": precision,  # fraction of cache hits that were correct
        "recall": recall,        # fraction of valid cache opportunities captured
        "f1": f1,
    })

# Step 4: Pick the threshold where precision >= your acceptable false-positive target
# Most applications: precision >= 0.97 (3% of cache hits may be wrong answers)
# High-stakes: precision >= 0.99
target_precision = 0.97
valid = [r for r in results if r["precision"] >= target_precision]
best = max(valid, key=lambda r: r["recall"])
print(f"Optimal threshold: {best['threshold']:.2f}")
print(f"  Precision: {best['precision']:.3f}, Recall: {best['recall']:.3f}")

A false positive rate of 1–3% (precision 0.97–0.99) is the practical target for most applications. Above this, you are occasionally giving users wrong answers; below it, you are leaving hit rate on the table. For customer-facing support, aim for 0.99+. For internal tooling where users can recognize wrong answers and re-ask, 0.95 is acceptable.


Cache Invalidation

The hardest problem in semantic caching is invalidation. When the correct answer to a question changes—prices update, policies change, documentation is revised—cached responses become stale. Unlike exact-match caches where you can invalidate by key, semantic caches cannot be selectively invalidated by content without re-embedding everything.

TTL (Time-to-Live)

The simplest approach: every cache entry expires after a fixed time. Set TTL based on how frequently the underlying data changes.

TTL guidelines by content type:

  Content type                    | Suggested TTL
  --------------------------------|------------------------
  Static docs (rarely change)     | 24–72 hours
  Product information             | 4–12 hours
  Pricing / availability          | 30–60 minutes
  Real-time data (weather, stock) | Do not cache
  Code documentation              | 48–168 hours
  FAQ / support content           | 6–24 hours

Add jitter (random variance) to TTL values to prevent thundering herd—without it, entries created at the same time expire simultaneously and cause a burst of LLM calls.

1
2
3
4
5
6
import random

def compute_ttl(base_ttl_seconds: int, jitter_fraction: float = 0.2) -> int:
    """Add ±20% jitter to TTL to spread expirations."""
    jitter = int(base_ttl_seconds * jitter_fraction * random.uniform(-1, 1))
    return base_ttl_seconds + jitter

Version-Based Invalidation

When you update a knowledge base, increment a version number and store it with each cache entry. On lookup, only return hits that match the current version.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Cache key includes content version
cache_key_metadata = {
    "query_embedding": embedding_vector,
    "kb_version": "2026-05-22-v3",   # bumped when content updates
    "created_at": time.time(),
}

# On lookup: filter by kb_version before returning
results = vector_store.search(
    query_embedding,
    filter={"kb_version": current_kb_version},
    top_k=1,
)

Event-Driven Invalidation

For content that changes on specific events (product catalog update, policy change, new release), trigger cache flushes directly from your CI/deployment pipeline.

1
2
3
4
# In your deployment pipeline, after updating docs:
curl -X POST http://cache-service/flush \
  -H "Authorization: Bearer $CACHE_ADMIN_TOKEN" \
  -d '{"namespace": "docs", "reason": "docs-deploy-v2.3.1"}'

Failure Modes

Semantic Collision on Dense Query Types

Some query domains cluster very tightly in embedding space. Legal questions, financial questions, and medical questions are all phrased carefully and embed near each other—but their answers diverge dramatically. “What are the tax implications of a Roth IRA?” and “What are the tax implications of a traditional IRA?” embed at 0.93 similarity, but their answers are importantly different.

Mitigation: Use category-aware thresholds. Detect query category (via a classifier or keyword matching) and apply a stricter threshold for high-stakes domains.

1
2
3
4
5
6
7
8
9
# Category-specific thresholds
THRESHOLDS = {
    "legal":        0.98,
    "medical":      0.98,
    "financial":    0.97,
    "technical":    0.92,
    "conversational": 0.90,
    "default":      0.93,
}

Context Dependence

Semantic similarity is computed on the query text, not on the full context. Two identical questions asked by different users, or in different conversation states, may warrant different answers. “What is my account balance?” is the same question semantically but requires user-specific responses—it should never hit a shared cache.

Mitigation: Namespace cache by user/session for personalized queries. Only use a shared cache for queries whose correct answer is independent of user identity.

1
2
3
4
def cache_key_prefix(query: str, user_id: str | None, is_personalized: bool) -> str:
    if is_personalized and user_id:
        return f"user:{user_id}:"
    return "shared:"

Embedding Model Drift

If you change embedding models, all existing cache entries have incompatible embeddings. A cached response from all-MiniLM-L6-v2 cannot be meaningfully compared to a new query embedded with BAAI/bge-large-en-v1.5.

Mitigation: Store the embedding model name and version with each cache entry. On model upgrade, flush all entries (or filter to current model version). For zero-downtime upgrades, run both models in parallel during a transition period.


Implementation: From Scratch with Redis and FastAPI

Redis with the redis-search module (RediSearch) provides vector similarity search alongside traditional key-value operations. This makes it a natural cache store—you get TTL, persistence, and ANN search in one system.

1
2
3
4
5
6
7
# Run Redis Stack (includes RediSearch with vector support)
docker run -d \
  --name redis-cache \
  -p 6379:6379 \
  redis/redis-stack:latest

pip install redis sentence-transformers fastapi uvicorn numpy
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# semantic_cache.py
import redis
import numpy as np
import json
import time
import hashlib
import random
from sentence_transformers import SentenceTransformer
from typing import Optional

class SemanticCache:
    def __init__(
        self,
        redis_url: str = "redis://localhost:6379",
        embedding_model: str = "BAAI/bge-large-en-v1.5",
        similarity_threshold: float = 0.93,
        default_ttl: int = 3600,
        index_name: str = "semantic_cache",
        vector_dim: int = 1024,
    ):
        self.client = redis.from_url(redis_url, decode_responses=False)
        self.model = SentenceTransformer(embedding_model)
        self.threshold = similarity_threshold
        self.default_ttl = default_ttl
        self.index_name = index_name
        self.vector_dim = vector_dim
        self.model_version = embedding_model.replace("/", "-")
        self._ensure_index()

    def _ensure_index(self):
        """Create the RediSearch vector index if it doesn't exist."""
        try:
            self.client.execute_command("FT.INFO", self.index_name)
        except redis.ResponseError:
            self.client.execute_command(
                "FT.CREATE", self.index_name,
                "ON", "HASH",
                "PREFIX", "1", f"semcache:{self.model_version}:",
                "SCHEMA",
                "embedding", "VECTOR", "HNSW", "6",
                    "TYPE", "FLOAT32",
                    "DIM", str(self.vector_dim),
                    "DISTANCE_METRIC", "COSINE",
                "response", "TEXT", "NOSTEM",
                "namespace", "TAG",
                "created_at", "NUMERIC",
            )

    def _embed(self, text: str) -> np.ndarray:
        return self.model.encode(text, normalize_embeddings=True).astype(np.float32)

    def _cache_key(self, query: str, namespace: str) -> str:
        qhash = hashlib.sha256(query.encode()).hexdigest()[:16]
        return f"semcache:{self.model_version}:{namespace}:{qhash}"

    def get(self, query: str, namespace: str = "default") -> Optional[str]:
        """Look up a query, returning the cached response or None."""
        embedding = self._embed(query)
        embedding_bytes = embedding.tobytes()

        # Vector KNN search within namespace
        query_cmd = (
            f"*=>[KNN 1 @embedding $vec AS score]"
        )
        try:
            results = self.client.execute_command(
                "FT.SEARCH", self.index_name,
                f"(@namespace:{{{namespace}}})=>[KNN 1 @embedding $vec AS score]",
                "PARAMS", "2", "vec", embedding_bytes,
                "RETURN", "3", "score", "response", "created_at",
                "SORTBY", "score",
                "DIALECT", "2",
            )
        except Exception:
            return None

        if not results or results[0] == 0:
            return None

        # results[1] is the key, results[2] is field list
        fields = dict(zip(results[2][::2], results[2][1::2]))
        score = float(fields.get(b"score", b"1.0"))

        # RediSearch COSINE distance: 0 = identical, 1 = orthogonal
        # Convert to similarity: similarity = 1 - distance
        similarity = 1.0 - score
        if similarity < self.threshold:
            return None

        return fields.get(b"response", b"").decode("utf-8")

    def set(
        self,
        query: str,
        response: str,
        namespace: str = "default",
        ttl: Optional[int] = None,
    ):
        """Store a query-response pair in the cache."""
        embedding = self._embed(query)
        key = self._cache_key(query, namespace)
        ttl = ttl or self.default_ttl
        # Add jitter: ±15%
        jitter = int(ttl * 0.15 * random.uniform(-1, 1))
        actual_ttl = ttl + jitter

        self.client.hset(key, mapping={
            "embedding":  embedding.tobytes(),
            "response":   response,
            "namespace":  namespace,
            "query":      query,
            "created_at": int(time.time()),
        })
        self.client.expire(key, actual_ttl)

    def flush_namespace(self, namespace: str):
        """Remove all cache entries for a namespace (e.g., after content update)."""
        results = self.client.execute_command(
            "FT.SEARCH", self.index_name,
            f"@namespace:{{{namespace}}}",
            "NOCONTENT",
            "LIMIT", "0", "10000",
        )
        if results and results[0] > 0:
            keys = results[1::2]  # every other element is a key
            if keys:
                self.client.delete(*keys)

FastAPI Proxy Wrapper

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# proxy.py — transparent semantic cache in front of any OpenAI-compatible API
from fastapi import FastAPI, Request, HTTPException
from openai import AsyncOpenAI
import json
import hashlib
from semantic_cache import SemanticCache

app = FastAPI()
cache = SemanticCache(similarity_threshold=0.93, default_ttl=3600)
openai = AsyncOpenAI()

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    body = await request.json()
    messages = body.get("messages", [])

    # Extract the last user message as the cache key
    user_messages = [m["content"] for m in messages if m["role"] == "user"]
    if not user_messages:
        raise HTTPException(400, "No user message found")

    query = user_messages[-1]
    namespace = body.get("namespace", "default")  # optional caller-provided namespace

    # Try cache first
    cached = cache.get(query, namespace=namespace)
    if cached:
        # Return in OpenAI response format
        return {
            "id": f"cache-{hashlib.md5(query.encode()).hexdigest()[:8]}",
            "object": "chat.completion",
            "model": body.get("model", "cached"),
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": cached},
                "finish_reason": "stop",
            }],
            "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
            "cached": True,
        }

    # Cache miss: call the real API
    response = await openai.chat.completions.create(**body)
    response_text = response.choices[0].message.content

    # Store in cache (don't await — let it complete in background)
    cache.set(query, response_text, namespace=namespace)

    result = response.model_dump()
    result["cached"] = False
    return result
1
2
3
4
5
# Run the proxy
uvicorn proxy:app --host 0.0.0.0 --port 8001

# Point your application at the proxy instead of api.openai.com
export OPENAI_BASE_URL=http://localhost:8001/v1

LiteLLM Semantic Cache

LiteLLM’s proxy server has built-in semantic caching that works transparently across all supported providers. It is the fastest path to production for teams already using LiteLLM.

1
pip install litellm[proxy]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# litellm_config.yaml
model_list:
  - model_name: gpt-4o
    litellm_params:
      model: openai/gpt-4o
      api_key: os.environ/OPENAI_API_KEY

  - model_name: claude-opus-4-7
    litellm_params:
      model: anthropic/claude-opus-4-7
      api_key: os.environ/ANTHROPIC_API_KEY

cache:
  type: redis-semantic
  host: localhost
  port: 6379
  password: ""
  similarity_threshold: 0.93
  embedding_model: text-embedding-3-small   # or a local model
  ttl: 3600
1
litellm --config litellm_config.yaml --port 8000

Your application calls http://localhost:8000/v1/chat/completions with any supported provider, and LiteLLM handles caching transparently. The X-Cache response header indicates hit/miss.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Using LiteLLM with semantic cache enabled
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="anything")

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "What is the capital of France?"}],
)
print(response.choices[0].message.content)  # Paris
# Second call with similar phrasing — cache hit
response2 = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "What's France's capital city?"}],
)
print(response2.choices[0].message.content)  # Paris (from cache, < 30ms)

GPTCache

GPTCache (Zilliz) is a standalone semantic caching library designed for direct embedding into Python applications. It integrates with LangChain and llama_index and supports a wider range of backends than LiteLLM.

1
pip install gptcache
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from gptcache import cache
from gptcache.adapter import openai
from gptcache.embedding import Onnx
from gptcache.manager import CacheBase, VectorBase, get_data_manager
from gptcache.similarity_evaluation.distance import SearchDistanceEvaluation

# Configure cache with local embedding model and Redis vector store
onnx = Onnx()   # uses ONNX-optimized embedding model (fast, CPU)

data_manager = get_data_manager(
    CacheBase("redis", url="redis://localhost:6379"),
    VectorBase(
        "redis",
        url="redis://localhost:6379",
        dimension=onnx.dimension,
    ),
)

cache.init(
    embedding_func=onnx.to_embeddings,
    data_manager=data_manager,
    similarity_evaluation=SearchDistanceEvaluation(),
    similarity_threshold=0.8,   # GPTCache uses 0–1 distance, lower = more similar
)

# Drop-in replacement for openai module
response = openai.ChatCompletion.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "What is machine learning?"}],
)

GPTCache’s similarity_threshold is a distance threshold rather than a similarity threshold—0.0 means identical, 1.0 means completely different. A threshold of 0.8 roughly corresponds to cosine similarity ≥ 0.8. The naming is inverted from most other systems; be careful when porting thresholds.


Monitoring Cache Performance

Instrument your cache to measure the metrics that actually matter: hit rate, false positive rate, and latency distributions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Prometheus metrics for semantic cache
from prometheus_client import Counter, Histogram, Gauge

cache_hits = Counter("semantic_cache_hits_total", "Cache hits", ["namespace"])
cache_misses = Counter("semantic_cache_misses_total", "Cache misses", ["namespace"])
cache_false_positives = Counter(
    "semantic_cache_false_positives_total",
    "Cache hits where response was incorrect (user-reported or rated)",
    ["namespace"],
)
lookup_latency = Histogram(
    "semantic_cache_lookup_seconds",
    "Time spent on cache lookup (embed + search)",
    buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25],
)
cache_size = Gauge("semantic_cache_entries", "Total entries in cache", ["namespace"])

Key dashboards to build:

  • Hit rate by namespace (should be stable; sudden drops indicate workload shift or cache flush)
  • P50/P95 lookup latency (embedding dominates; should be < 30ms at P95)
  • False positive rate (track via user thumbs-down or explicit correction signals)
  • Cache entry age distribution (shows staleness risk—entries older than TTL should be zero)

When Semantic Caching is Worth It

Use case fitness for semantic caching:

  Use case                        | Cache fit  | Expected hit rate | Notes
  --------------------------------|------------|-------------------|---------------------------
  FAQ / support chatbot           | excellent  | 60–85%            | High query repetition
  Documentation assistant         | excellent  | 70–90%            | Stable content, similar qs
  Internal knowledge base         | excellent  | 65–85%            | Closed domain, repetition
  Product search / catalog        | good       | 40–65%            | Some variation in phrasing
  Code generation (same patterns) | good       | 30–60%            | Tight embedding clustering
  Customer support (personalized) | partial    | 20–40%            | Namespace by user required
  Creative writing                | poor       | < 10%             | Each request unique
  Real-time data queries          | poor       | near 0%           | Answers change constantly
  Multi-turn conversation         | poor       | < 10%             | Context-dependent
  Agent tool calls                | poor       | < 5%              | Action-oriented, varied

The investment in semantic caching pays off proportionally to query repetition and answer stability. If your workload is customer support, documentation search, or any other closed-domain Q&A where the same questions arrive frequently and the answers change on day/week timescales, semantic caching is one of the highest-ROI infrastructure investments available.

If your workload is generative (creative writing, unique coding tasks, multi-turn personalized dialogue), cache infrastructure adds complexity and latency overhead for minimal benefit. Do the hit rate analysis on your actual query logs before building the cache.

The break-even point is roughly a 15–20% hit rate: at that level, the saved LLM calls offset the embedding overhead and the operational cost of maintaining the cache. At 60%+ hit rates, semantic caching halves your LLM spend with no visible impact on response quality.

Comments