TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Build a Semantic Cache for LLM Calls With Ollama and Python
Learning Hub

How to Build a Semantic Cache for LLM Calls With Ollama and Python

Build a cache that recognizes when a new prompt means the same thing as one you already answered, using Ollama embeddings and cosine similarity in Python, and learn why a bare similarity threshold...

August 29, 2026 19 Min Read
84

If you have ever built a chatbot or an AI-powered support tool, you have probably noticed that the same question keeps arriving in a dozen different disguises. “What is the capital of France?” and “What’s France’s capital city?” mean exactly the same thing to a human, but to a computer they are two completely different strings of text. If your app calls a large language model (LLM) every single time, you pay for and wait on the same answer over and over, just because the wording changed.

Table Of Content

  • What you will build, and why it matters
  • Prerequisites
  • Step 1: Pull an embedding model
  • Step 2: Generate your first embedding
  • Step 3: Measure meaning with cosine similarity
  • Step 4: Watch a naive cache miss an obvious paraphrase
  • Step 5: Build the semantic cache
  • Step 6: See real savings across paraphrased questions
  • Step 7: The gotcha, a “safe-looking” threshold that serves a wrong answer
  • Step 8: Why raising the threshold alone doesn’t fix it
  • Step 9: Add a second safety layer: a distinguishing-token guard
  • A bug I found while testing the guard
  • Step 10: Confirm the fix, then add TTL and a size cap for production hygiene
  • Step 11: Verify everything end-to-end with pytest
  • Common mistakes and gotchas
  • How to verify your own semantic cache actually works
  • Next steps

In this tutorial you will build a semantic cache: a cache that recognizes when a new question means the same thing as one it has already answered, even when the words are different, and reuses the old answer instead of generating a new one. You will do this with Ollama running locally and plain Python, no cloud API keys and no paid services required. Along the way you will hit a real, dangerous failure mode of semantic caching (a cache that confidently returns the wrong answer) and learn how to guard against it.

Everything in this post was written by actually running the code against a local Ollama server. Every number, every log line, and every “gotcha” below is copied from a real terminal, not invented for effect.

What you will build, and why it matters

A cache, in the general programming sense, is a place to store the result of an expensive operation so you can reuse it instead of redoing the work. The classic web-development version of this is keying a dictionary (or a Redis store) by the exact input string: if the new request’s text matches a previous request’s text byte for byte, return the stored result.

That “exact match” approach works fine for something like an HTTP GET request to a fixed URL. It falls apart for LLM traffic, because real users (and real upstream systems) never ask the same question the same way twice. “What is the capital of France?”, “What’s France’s capital city?”, and “Can you tell me the capital city of France” are three different strings, so an exact-match cache treats them as three unrelated questions and pays for three full LLM generations, even though a human would answer all three identically.

A semantic cache fixes this by comparing meaning instead of text. It does this with embeddings: a way of converting a sentence into a list of numbers (a vector) such that sentences with similar meaning produce similar vectors. To decide whether two vectors are “similar,” you compute their cosine similarity, a single number between -1 and 1 that measures the angle between them, where 1 means “pointing in exactly the same direction” (same meaning) and 0 means “unrelated.” You do not need any prior machine-learning background to follow along; both of those concepts are explained and built from scratch below.

Prerequisites

  • Ollama installed and running on Windows, macOS, or Linux. If you don’t have it yet, our guide to installing Ollama and running your first local chatbot covers that from scratch first.
  • Python 3.10 or newer, with the requests package installed (pip install requests).
  • About 400 MB of free disk space for a small embedding model, on top of whatever chat model you already have pulled.
  • Basic Python: functions, dictionaries, and classes. No prior experience with embeddings, vectors, or machine learning is assumed; every formula is written out and explained.

This tutorial uses qwen2.5:1.5b as the chat model, a small model that runs comfortably on CPU. Any chat model already pulled on your machine will work the same way; just change CHAT_MODEL in the code below.

Step 1: Pull an embedding model

Ollama’s chat models (like qwen2.5:1.5b or llama3.2) are built to generate text, not to produce embeddings. For that you need a model specifically trained to output vectors. Pull one now:

ollama pull nomic-embed-text

This downloads a 274 MB model that “can only be used to generate embeddings,” according to its own model card. It has a 2K token context window, which is more than enough for typical questions. The pull itself is quick:

pulling manifest
pulling 970aa74c0a90: 100% |################| 274 MB
pulling c71d239df917: 100% |################|  11 KB
verifying sha256 digest
writing manifest
success

Step 2: Generate your first embedding

Ollama exposes embeddings through a REST endpoint, POST /api/embed, which takes a model name and an input string and returns a list of floating-point numbers. Create a small helper file, ollama_client.py:

import time
import requests

OLLAMA_URL = "http://127.0.0.1:11434"
EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "qwen2.5:1.5b"


def embed(text: str) -> list[float]:
    resp = requests.post(
        f"{OLLAMA_URL}/api/embed",
        json={"model": EMBED_MODEL, "input": text},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()["embeddings"][0]


def chat(prompt: str) -> tuple[str, float]:
    """Returns (answer_text, wall_clock_seconds)."""
    start = time.perf_counter()
    resp = requests.post(
        f"{OLLAMA_URL}/api/generate",
        json={
            "model": CHAT_MODEL,
            "prompt": prompt,
            "stream": False,
        },
        timeout=120,
    )
    resp.raise_for_status()
    elapsed = time.perf_counter() - start
    return resp.json()["response"].strip(), elapsed


def cosine_similarity(a: list[float], b: list[float]) -> float:
    if len(a) != len(b):
        raise ValueError(f"embedding length mismatch: {len(a)} vs {len(b)} (did the embedding model change?)")
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x * x for x in a) ** 0.5
    norm_b = sum(y * y for y in b) ** 0.5
    return dot / (norm_a * norm_b)

Notice the URL uses 127.0.0.1 rather than localhost. On some Windows machines, resolving the hostname localhost adds a large, consistent delay to every single request (multiple seconds), even though the server itself replies in milliseconds. We measured this directly: 10 requests to localhost averaged over 2 seconds each, and the identical 10 requests to 127.0.0.1 averaged about 0.1 seconds each. If your own local Ollama calls feel mysteriously slow, that hostname is worth a look; our benchmarking-LLM-inference tutorial digs into that specific quirk in more depth. For the rest of this post we use 127.0.0.1 so the timings below reflect real caching behavior, not a DNS quirk.

Now call it once:

from ollama_client import embed

vector = embed("What is the capital of France?")
print("Embedding length:", len(vector))
print("First 5 numbers:", [round(v, 4) for v in vector[:5]])
Embedding length: 768
First 5 numbers: [-0.0017, 0.0227, -0.1531, -0.0208, 0.0559]

That is the whole idea of an embedding: one sentence became a list of 768 numbers. Those numbers on their own mean nothing to a human, but their relationship to other sentences’ numbers is exactly what lets a computer recognize “same meaning, different words.”

Step 3: Measure meaning with cosine similarity

The cosine_similarity function above computes the cosine of the angle between two vectors: multiply the vectors’ matching components together and sum them (the dot product), then divide by the product of their lengths (their norms). The result stays between -1 and 1 no matter how long the original sentences are, which is what makes it useful for comparing meaning rather than just word overlap.

Let’s see what real similarity scores look like for a few sentence pairs, from an obvious paraphrase down to something totally unrelated:

from ollama_client import embed, cosine_similarity

pairs = [
    ("What is the capital of France?", "What's France's capital city?"),
    ("What is the capital of France?", "Can you tell me the capital city of France"),
    ("What is the capital of France?", "What is the capital of Germany?"),
    ("What is the capital of France?", "How do I bake sourdough bread?"),
]

for a, b in pairs:
    vec_a = embed(a)
    vec_b = embed(b)
    sim = cosine_similarity(vec_a, vec_b)
    print(f"{sim:.4f}  |  {a!r}  vs  {b!r}")
0.9641  |  'What is the capital of France?'  vs  "What's France's capital city?"
0.9415  |  'What is the capital of France?'  vs  'Can you tell me the capital city of France'
0.7674  |  'What is the capital of France?'  vs  'What is the capital of Germany?'
0.3398  |  'What is the capital of France?'  vs  'How do I bake sourdough bread?'

The two real paraphrases score above 0.94. The completely unrelated sourdough-bread question scores 0.34, comfortably far away. But look at the France-versus-Germany pair: 0.7674. That is a different question with a different correct answer, yet it scores a lot closer to the paraphrases than to the unrelated question. Keep that number in mind; it is about to cause a real bug.

Step 4: Watch a naive cache miss an obvious paraphrase

Before building the semantic version, it’s worth seeing the problem it solves. Here is the simplest possible cache, keyed by the exact prompt string:

from ollama_client import chat

naive_cache: dict[str, str] = {}


def naive_cached_chat(prompt: str) -> tuple[str, bool, float]:
    if prompt in naive_cache:
        return naive_cache[prompt], True, 0.0
    answer, elapsed = chat(prompt)
    naive_cache[prompt] = answer
    return answer, False, elapsed


queries = [
    "What is the capital of France?",
    "What's France's capital city?",
    "What is the capital of France?",  # exact repeat
]

for q in queries:
    answer, hit, elapsed = naive_cached_chat(q)
    print(f"hit={hit}  time={elapsed:.2f}s  query={q!r}")
    print(f"    answer: {answer}")
hit=False  time=0.49s  query='What is the capital of France?'
    answer: The capital of France is Paris.
hit=False  time=0.35s  query="What's France's capital city?"
    answer: France's capital city is Paris.
hit=True  time=0.00s  query='What is the capital of France?'
    answer: The capital of France is Paris.

The paraphrase in the second line pays full price again, even though it needed the exact same answer as the first line. Only the byte-for-byte repeat on the third line hits the cache. This is the gap a semantic cache is meant to close.

Step 5: Build the semantic cache

The idea: for each new prompt, embed it, compare it against the embeddings of every prompt already in the cache, and if the closest one scores above some threshold, reuse its stored answer instead of calling the model again. Save this as semantic_cache.py:

import time
from dataclasses import dataclass, field

from ollama_client import chat, cosine_similarity, embed


@dataclass
class CacheEntry:
    prompt: str
    embedding: list[float]
    answer: str
    created_at: float = field(default_factory=time.time)


class SemanticCache:
    def __init__(self, threshold: float = 0.90, ttl_seconds: float | None = None):
        self.threshold = threshold
        self.ttl_seconds = ttl_seconds
        self.entries: list[CacheEntry] = []
        self.hits = 0
        self.misses = 0

    def _best_match(self, query_embedding: list[float]) -> tuple[CacheEntry | None, float]:
        best_entry = None
        best_score = -1.0
        for entry in self.entries:
            score = cosine_similarity(query_embedding, entry.embedding)
            if score > best_score:
                best_score = score
                best_entry = entry
        return best_entry, best_score

    def get(self, prompt: str) -> tuple[str, bool, float, float]:
        """Returns (answer, was_cache_hit, similarity_score, wall_clock_seconds)."""
        start = time.perf_counter()
        query_embedding = embed(prompt)
        best_entry, best_score = self._best_match(query_embedding)

        if best_entry is not None and best_score >= self.threshold:
            self.hits += 1
            elapsed = time.perf_counter() - start
            return best_entry.answer, True, best_score, elapsed

        answer, _ = chat(prompt)
        self.entries.append(CacheEntry(prompt=prompt, embedding=query_embedding, answer=answer))
        self.misses += 1
        elapsed = time.perf_counter() - start
        return answer, False, best_score, elapsed

This first version is intentionally simple (a linear scan over every stored embedding), which is plenty fast for a cache of a few thousand entries and easy to reason about. We’ll harden it with a TTL and a size cap in Step 10.

Step 6: See real savings across paraphrased questions

from semantic_cache import SemanticCache

cache = SemanticCache(threshold=0.90)

queries = [
    "What is the capital of France?",
    "What's France's capital city?",
    "Can you tell me the capital city of France",
    "Tell me France's capital.",
]

for q in queries:
    answer, hit, score, elapsed = cache.get(q)
    print(f"hit={hit}  similarity={score:.4f}  time={elapsed:.2f}s  query={q!r}")
    print(f"    answer: {answer[:80]}")

print(f"\nTotals: {cache.hits} hits, {cache.misses} misses")
hit=False  similarity=-1.0000  time=0.50s  query='What is the capital of France?'
    answer: The capital of France is Paris.
hit=True  similarity=0.9641  time=0.06s  query="What's France's capital city?"
    answer: The capital of France is Paris.
hit=True  similarity=0.9415  time=0.05s  query='Can you tell me the capital city of France'
    answer: The capital of France is Paris.
hit=True  similarity=0.9373  time=0.05s  query="Tell me France's capital."
    answer: The capital of France is Paris.

Totals: 3 hits, 1 misses

The first call pays the full generation cost (0.50s). Every rephrasing after that returns in about 0.05 to 0.06 seconds, roughly an 8 to 10 times speedup, because it only had to embed the new question and compare vectors, never running the chat model again. This is the entire value proposition of a semantic cache: real savings on real paraphrases, with no exact-string matching required.

Step 7: The gotcha, a “safe-looking” threshold that serves a wrong answer

Here is where semantic caching gets dangerous if you are not careful. A threshold of 0.90 felt strict in Step 6. But recall the France-versus-Germany pair from Step 3 scored 0.7674, and suppose a slightly less cautious threshold of 0.75 is chosen instead, because it “still looks high”:

from semantic_cache import SemanticCache

# A threshold that looks "reasonably strict" at first glance.
cache = SemanticCache(threshold=0.75)

answer1, hit1, score1, _ = cache.get("What is the capital of France?")
print(f"hit={hit1}  similarity={score1:.4f}  query='What is the capital of France?'")
print(f"    answer: {answer1}")

answer2, hit2, score2, _ = cache.get("What is the capital of Germany?")
print(f"hit={hit2}  similarity={score2:.4f}  query='What is the capital of Germany?'")
print(f"    answer: {answer2}")

if hit2:
    print("\n*** WRONG: served the France cache entry for a Germany question ***")
hit=False  similarity=-1.0000  query='What is the capital of France?'
    answer: The capital of France is Paris.
hit=True  similarity=0.7674  query='What is the capital of Germany?'
    answer: The capital of France is Paris.

*** WRONG: served the France cache entry for a Germany question ***

That is a genuinely wrong answer, delivered with total confidence and zero indication anything went wrong. This is the single biggest risk of semantic caching: unlike a slow cache miss, a wrong cache hit looks exactly like a correct answer to both your code and your user. A cache that is merely slow is an inconvenience. A cache that is confidently wrong is a trust problem.

Step 8: Why raising the threshold alone doesn’t fix it

The instinctive fix is “just raise the threshold higher.” Before doing that, it’s worth actually measuring where real paraphrases and real near-miss questions land, instead of guessing. Here is a small labeled test set: five genuine paraphrase pairs (should cache-hit) and five “hard negative” pairs, questions that share almost the same sentence structure but need a different answer (should not cache-hit):

from ollama_client import embed, cosine_similarity

# label, query_a, query_b
pairs = [
    ("MATCH", "What is the capital of France?", "What's France's capital city?"),
    ("MATCH", "How do I reset my password?", "What are the steps to reset my password?"),
    ("MATCH", "What's the weather like today?", "Can you tell me today's weather?"),
    ("MATCH", "How much RAM do I need to run a 7B model?", "What's the minimum RAM for running a 7 billion parameter model?"),
    ("MATCH", "Is Python installed on this machine?", "Do I have Python installed?"),
    ("HARD_NEGATIVE", "What is the capital of France?", "What is the capital of Germany?"),
    ("HARD_NEGATIVE", "How do I reset my password?", "How do I reset my email?"),
    ("HARD_NEGATIVE", "What's the weather like today?", "What was the weather like yesterday?"),
    ("HARD_NEGATIVE", "How much RAM do I need to run a 7B model?", "How much RAM do I need to run a 70B model?"),
    ("HARD_NEGATIVE", "Is Python installed on this machine?", "Is Docker installed on this machine?"),
]

results = [(label, cosine_similarity(embed(a), embed(b)), a, b) for label, a, b in pairs]
results.sort(key=lambda r: -r[1])
for label, sim, a, b in results:
    print(f"{sim:.4f}  {label:14s}  {a!r} <-> {b!r}")
0.9641  MATCH           'What is the capital of France?' <-> "What's France's capital city?"
0.9541  MATCH           'How do I reset my password?' <-> 'What are the steps to reset my password?'
0.9072  HARD_NEGATIVE   "What's the weather like today?" <-> 'What was the weather like yesterday?'
0.8841  MATCH           'Is Python installed on this machine?' <-> 'Do I have Python installed?'
0.8783  MATCH           "What's the weather like today?" <-> "Can you tell me today's weather?"
0.8331  HARD_NEGATIVE   'How much RAM do I need to run a 7B model?' <-> 'How much RAM do I need to run a 70B model?'
0.7993  MATCH           'How much RAM do I need to run a 7B model?' <-> "What's the minimum RAM for running a 7 billion parameter model?"
0.7850  HARD_NEGATIVE   'How do I reset my password?' <-> 'How do I reset my email?'
0.7674  HARD_NEGATIVE   'What is the capital of France?' <-> 'What is the capital of Germany?'
0.6834  HARD_NEGATIVE   'Is Python installed on this machine?' <-> 'Is Docker installed on this machine?'

Look closely at the ordering. The real matches range from 0.7993 to 0.9641. The hard negatives range from 0.6834 to 0.9072. Those two ranges overlap: the “what was the weather like yesterday” hard negative (0.9072) scores higher than the legitimate “7B model RAM” paraphrase (0.7993). There is no single threshold value that perfectly separates every real match from every dangerous near-miss in this test set. Push the threshold up to avoid the Germany mistake, and you also start rejecting real paraphrases as fresh, more expensive cache misses; push it down to catch more paraphrases, and some hard negatives sneak through as wrong-answer cache hits. A single similarity number is a useful signal, but on its own it is not a safe gate.

Step 9: Add a second safety layer: a distinguishing-token guard

Since a pure similarity score cannot be trusted alone, add a second, independent check: does either sentence contain a specific fact (a number, a proper noun, or a word like “today” or “yesterday”) that the other sentence contradicts? These are exactly the tokens most likely to flip the correct answer. Save this as guard.py:

import re

RELATIVE_TIME_WORDS = {"today", "yesterday", "tomorrow", "now", "currently", "yet"}


def distinguishing_tokens(text: str) -> set[str]:
    """Numbers, mid-sentence proper nouns, and relative-time words: the
    tokens most likely to flip the correct answer if they change."""
    words = re.findall(r"[A-Za-z0-9]+", text)
    tokens = set()
    for i, word in enumerate(words):
        lowered = word.lower()
        if lowered in RELATIVE_TIME_WORDS:
            tokens.add(lowered)
        elif re.search(r"\d", word):
            digits = re.findall(r"\d+", word)
            tokens.update(digits)
        elif word[0].isupper() and i > 0:  # skip sentence-initial capital
            tokens.add(lowered)
    return tokens


def safe_to_reuse(cached_prompt: str, new_prompt: str) -> bool:
    """False if both prompts carry distinguishing tokens and those tokens
    disagree. True (allow) when there's nothing to disagree about."""
    cached_tokens = distinguishing_tokens(cached_prompt)
    new_tokens = distinguishing_tokens(new_prompt)
    if not cached_tokens or not new_tokens:
        return True
    return bool(cached_tokens & new_tokens)

The rule in safe_to_reuse is deliberately conservative: if neither sentence has anything worth flagging (no numbers, no proper nouns, no relative-time words), allow the reuse. If either does, only allow it when the two sets of tokens actually overlap. “What is the capital of France?” and “What’s France’s capital city?” both mention France, so they overlap and the guard allows it. “What is the capital of France?” and “What is the capital of Germany?” mention different countries and don’t overlap, so the guard blocks it, independent of whatever the similarity score says.

A bug I found while testing the guard

The first version of this function used the regex [A-Za-z0-9']+ (including the apostrophe) to split words, so it could handle contractions cleanly. Testing it against the labeled pairs above turned up a real bug: it classified the genuine “capital of France” paraphrase pair as unsafe, when it should have been allowed. The cause was a tokenization mismatch: “France” in the first sentence became the token france, but “France‘s” in the second sentence, apostrophe included, became the token france's, a different string that never matched. Switching the regex to [A-Za-z0-9]+ (dropping the apostrophe) splits “France’s” into “France” and “s” instead, so the meaningful token normalizes to france on both sides and the two texts finally agree. The code above already has that fix; if you ever build a similar guard yourself and it starts rejecting things that clearly should match, check exactly this: how possessives and contractions are being tokenized.

Re-running the full labeled set with a threshold of 0.85 plus this guard:

THRESHOLD = 0.85
for label, a, b in pairs:
    sim = cosine_similarity(embed(a), embed(b))
    would_hit_threshold_only = sim >= THRESHOLD
    would_hit_with_guard = would_hit_threshold_only and safe_to_reuse(a, b)
    print(f"label={label:14s} sim={sim:.4f}  threshold_only_hit={would_hit_threshold_only!s:5s}  with_guard_hit={would_hit_with_guard!s:5s}")
MATCH          sim=0.9641  threshold_only_hit=True   with_guard_hit=True
MATCH          sim=0.9541  threshold_only_hit=True   with_guard_hit=True
MATCH          sim=0.8783  threshold_only_hit=True   with_guard_hit=True
MATCH          sim=0.7993  threshold_only_hit=False  with_guard_hit=False
MATCH          sim=0.8841  threshold_only_hit=True   with_guard_hit=True
HARD_NEGATIVE  sim=0.7674  threshold_only_hit=False  with_guard_hit=False
HARD_NEGATIVE  sim=0.7850  threshold_only_hit=False  with_guard_hit=False
HARD_NEGATIVE  sim=0.9072  threshold_only_hit=True   with_guard_hit=False
HARD_NEGATIVE  sim=0.8331  threshold_only_hit=False  with_guard_hit=False
HARD_NEGATIVE  sim=0.6834  threshold_only_hit=False  with_guard_hit=False

9/10 correctly classified with threshold=0.85 + guard

All five hard negatives are now correctly blocked, four of them by the threshold alone and the “weather today versus yesterday” one specifically by the guard (its 0.9072 similarity clears the threshold, but “today” and “yesterday” don’t overlap as distinguishing tokens). The one “wrong” row is the RAM paraphrase at 0.7993: it falls below the 0.85 threshold, so it becomes a cache miss and pays for a fresh generation. That is a real limitation, not a bug: this configuration trades away a little bit of hit rate on borderline phrasing in exchange for zero wrong-answer hits on every hard negative we tested. It is also worth being honest that the guard itself is a heuristic, not a proof: a hard negative that differs only in an ordinary noun (like “reset my password” versus “reset my email”, both lowercase, neither a number) would slip past the guard on similarity alone if it ever scored above the threshold. In our test set it didn’t (0.7850, well below 0.85), but a production system should not assume every such case will be so lucky; treat this as one layer of defense, not a guarantee.

Step 10: Confirm the fix, then add TTL and a size cap for production hygiene

Wire the guard into SemanticCache itself, add an optional time-to-live so stale answers eventually expire, and cap how many entries it will hold:

from guard import safe_to_reuse
from ollama_client import chat, cosine_similarity, embed

class SemanticCache:
    def __init__(
        self,
        threshold: float = 0.90,
        ttl_seconds: float | None = None,
        use_guard: bool = True,
        max_entries: int = 1000,
    ):
        self.threshold = threshold
        self.ttl_seconds = ttl_seconds
        self.use_guard = use_guard
        self.max_entries = max_entries
        self.entries: list[CacheEntry] = []
        self.hits = 0
        self.misses = 0
        self.guard_blocked = 0

    def _prune_expired(self) -> None:
        if self.ttl_seconds is None:
            return
        cutoff = time.time() - self.ttl_seconds
        self.entries = [e for e in self.entries if e.created_at >= cutoff]

    def get(self, prompt: str) -> tuple[str, bool, float, float]:
        self._prune_expired()
        start = time.perf_counter()
        query_embedding = embed(prompt)
        best_entry, best_score = self._best_match(query_embedding)

        above_threshold = best_entry is not None and best_score >= self.threshold
        if above_threshold and self.use_guard and not safe_to_reuse(best_entry.prompt, prompt):
            above_threshold = False
            self.guard_blocked += 1

        if above_threshold:
            self.hits += 1
            elapsed = time.perf_counter() - start
            return best_entry.answer, True, best_score, elapsed

        answer, _ = chat(prompt)
        self.entries.append(CacheEntry(prompt=prompt, embedding=query_embedding, answer=answer))
        if len(self.entries) > self.max_entries:
            self.entries.pop(0)  # evict oldest
        self.misses += 1
        elapsed = time.perf_counter() - start
        return answer, False, best_score, elapsed

(The _best_match method is unchanged from Step 5.) Now confirm the original Germany bug is actually fixed with this real configuration, and that genuine paraphrases still work:

cache = SemanticCache(threshold=0.85, use_guard=True)

a1, hit1, score1, _ = cache.get("What is the capital of France?")
a2, hit2, score2, _ = cache.get("What is the capital of Germany?")
assert hit2 is False, "guard should have blocked this"

a3, hit3, score3, _ = cache.get("What's France's capital city?")
assert hit3 is True, "a genuine paraphrase should still hit"
hit=False  similarity=-1.0000  query='What is the capital of France?'
    answer: The capital of France is Paris.
hit=False  similarity=0.7674  query='What is the capital of Germany?'
    answer: The capital of Germany is Berlin.

Fixed: the Germany question correctly triggered a fresh generation instead of reusing Paris.

hit=True  similarity=0.9641  query="What's France's capital city?"
    answer: The capital of France is Paris.

Germany now correctly gets its own, correct answer (“Berlin”), and the real paraphrase still hits the cache. Finally, confirm TTL expiry actually prunes stale entries instead of just tracking a number nobody checks:

import time
cache = SemanticCache(threshold=0.85, ttl_seconds=2)

cache.get("What is the capital of France?")
cache.get("What's France's capital city?")  # hits immediately, entry is fresh

print("sleeping 3s so the entry ages past the 2s TTL...")
time.sleep(3)

_, hit3, _, _ = cache.get("What's France's capital city?")
assert hit3 is False, "expired entry should not be reused"
hit=False  (first call, always a miss)
hit=True  (immediate paraphrase, still fresh)
sleeping 3s so the entry ages past the 2s TTL...
hit=False  (same paraphrase, but entry expired)

TTL expiry confirmed: the stale entry was pruned and a fresh answer was generated.

Step 11: Verify everything end-to-end with pytest

Before trusting any of this, write it down as tests you can rerun after every change. Save as test_semantic_cache.py:

import time
from semantic_cache import SemanticCache


def test_exact_repeat_hits():
    cache = SemanticCache(threshold=0.85)
    cache.get("What is the capital of France?")
    _, hit, score, _ = cache.get("What is the capital of France?")
    assert hit is True
    assert score > 0.99


def test_paraphrase_hits():
    cache = SemanticCache(threshold=0.85)
    cache.get("What is the capital of France?")
    answer, hit, _, _ = cache.get("What's France's capital city?")
    assert hit is True
    assert "Paris" in answer


def test_different_country_does_not_reuse_wrong_answer():
    cache = SemanticCache(threshold=0.85)
    cache.get("What is the capital of France?")
    answer, hit, _, _ = cache.get("What is the capital of Germany?")
    assert hit is False
    assert "Paris" not in answer


def test_unrelated_question_misses():
    cache = SemanticCache(threshold=0.85)
    cache.get("What is the capital of France?")
    _, hit, score, _ = cache.get("How do I bake sourdough bread?")
    assert hit is False
    assert score < 0.5


def test_ttl_expiry():
    cache = SemanticCache(threshold=0.85, ttl_seconds=1)
    cache.get("What is the capital of France?")
    time.sleep(1.5)
    _, hit, _, _ = cache.get("What's France's capital city?")
    assert hit is False


def test_max_entries_evicts_oldest():
    cache = SemanticCache(threshold=0.85, max_entries=2)
    cache.get("What is the capital of France?")
    cache.get("How do I bake sourdough bread?")
    cache.get("What is the weather like on Mars?")
    assert len(cache.entries) == 2
    assert cache.entries[0].prompt != "What is the capital of France?"
$ pytest test_semantic_cache.py -v
test_semantic_cache.py::test_exact_repeat_hits PASSED
test_semantic_cache.py::test_paraphrase_hits PASSED
test_semantic_cache.py::test_different_country_does_not_reuse_wrong_answer PASSED
test_semantic_cache.py::test_unrelated_question_misses PASSED
test_semantic_cache.py::test_ttl_expiry PASSED
test_semantic_cache.py::test_max_entries_evicts_oldest PASSED

6 passed in 27.94s

Common mistakes and gotchas

Trusting a single threshold without measuring your own data. As Step 8 showed, "match" and "hard negative" similarity ranges can overlap. A threshold that looks safe by eyeballing one or two examples can still let real mistakes through. Build a small labeled set of your own real traffic patterns (or at least your app's most common question categories) and check the actual score distribution before picking a number.

Mixing embeddings from two different models. A vector from nomic-embed-text and a vector from any other embedding model are not comparable, even if they happen to be the same length; the numbers encode meaning according to that specific model's training, not some universal scale. If you ever change your embedding model, you must re-embed everything already in the cache, not just new entries. Our cosine_similarity function above raises a clear ValueError if the two vectors' lengths don't even match, which is a cheap first line of defense, but it cannot catch the more dangerous case of two same-length vectors from different models producing a technically valid, meaningless similarity score.

Caching personalized or per-user answers globally. If your LLM calls include per-user context ("what's on my calendar today"), a semantic cache shared across users will happily serve one person's calendar to another person who asked a similarly worded question. Scope the cache per user (or per tenant) whenever the answer depends on who is asking, the same principle covered in our per-user token isolation tutorial.

No invalidation path for a bad or outdated cached answer. A wrong answer, once cached, stays wrong for as long as its TTL allows (or forever, if you set no TTL at all). Always cap the cache with a TTL that matches how quickly the underlying facts can change, and keep a way to manually evict a specific bad entry once you find one.

Assuming a "high" similarity score is inherently safe. As Step 3 showed, 0.7674 sounds high in isolation, but it was still a wrong-answer risk. Only comparative measurement against your own real match and near-miss pairs tells you whether a given number is actually safe for your use case.

How to verify your own semantic cache actually works

Before shipping a semantic cache in front of real traffic, confirm at minimum:

  1. An exact repeat of a previous question hits the cache with a similarity at or near 1.0.
  2. A genuine paraphrase of a previous question also hits the cache, and returns the same stored answer.
  3. A "hard negative", a question that shares vocabulary or structure with a cached question but needs a different answer, does not hit the cache. Build this test set from your own domain, not just the examples in this post.
  4. An unrelated question always misses and triggers a fresh generation.
  5. Entries actually expire once their TTL passes, and the cache actually stops growing once it reaches its size cap.

If you cannot check all five with real, reproducible tests (not just a spot check by hand), the cache is not ready for real traffic.

Next steps

This tutorial built a semantic cache from scratch, on purpose, so the mechanics and the failure modes are fully visible. Once you understand what is happening under the hood, a few natural directions to keep learning:

  • Swap the linear-scan lookup for a real vector index (like pgvector or a dedicated vector database) once you have far more cache entries than a simple loop can compare quickly; our vector database latency piece is a good next read on what that lookup actually costs at scale.
  • Look at existing semantic-caching libraries, like GPTCache, to see how a mature project handles the same trade-offs discussed in Step 8, and compare their default thresholds against your own measurements.
  • For borderline similarity scores that the threshold and guard can't confidently resolve either way, consider a third layer: asking the LLM itself a short, cheap "are these two questions asking for the same information?" verification prompt, similar in spirit to our structured-output validation and retries tutorial.
  • If your cache is meant to reduce cost against a hosted, metered API rather than a local Ollama model, revisit our prompt cache hit rate tutorial, which covers a different but related kind of caching: the model provider's own prefix-based prompt cache, as opposed to the application-level semantic cache built here.

Tags:

EmbeddingsOllamaPythonSemantic CachingVector Similarity

Share

Yellow robotic arms perform automated chemical genomics drug screening in a laboratory
Previous Post

Vijay Pande’s New VC Firm Built Its Own AI Agents Instead of Hiring Associates

Office multifunction printer with its toner cartridge drawer open, showing yellow and magenta toner cartridges
Next Post

A PaperCut Zero-Day Has Now Outlasted Two Emergency Patches

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026