semantic_cache_demo.py

script

← Back to skill

Content hash: 16827355457cd645bee23c97032160397e10a46a15de2ef0033fd81a5560b2a3
#!/usr/bin/env python3
"""Two-layer semantic cache: exact match first, then cosine similarity.

Self-contained (no external vector DB): uses a simple in-memory embedding
(simulated) + numpy cosine. Swap the embed() function for a real embedding
model in production.
"""

from __future__ import annotations

import hashlib
import time
from dataclasses import dataclass, field
from typing import Callable, Optional


# ── Simulated embedding (replace with sentence-transformers / API in prod) ─

def embed(text: str) -> list[float]:
    """Deterministic fake embedding: hashes token bigrams to vector dims.

    Real usage: model.encode(text) from sentence-transformers, or an embedding
    API call. This stands in so the demo runs with zero dependencies.
    """
    tokens = text.lower().split()
    vec = [0.0] * 64
    for i in range(len(tokens)):
        for gram in (tokens[i], " ".join(tokens[i:i + 2])):
            h = int(hashlib.md5(gram.encode()).hexdigest(), 16)
            vec[h % 64] += 1.0
    norm = sum(v * v for v in vec) ** 0.5 or 1.0
    return [v / norm for v in vec]


def cosine(a: list[float], b: list[float]) -> float:
    return sum(x * y for x, y in zip(a, b))


# ── Cache store ────────────────────────────────────────────────────────────

@dataclass
class Entry:
    question: str
    answer: str
    embedding: list[float]
    created_at: float


class SemanticCache:
    """Exact-match layer + semantic (cosine) layer, then fall back to LLM."""

    def __init__(
        self,
        threshold: float = 0.93,
        llm_fn: Optional[Callable[[str], str]] = None,
    ) -> None:
        self.threshold = threshold
        self.llm_fn = llm_fn or (lambda q: f"[LLM answer for: {q}]")
        self.exact: dict[str, str] = {}
        self.semantic: list[Entry] = []
        self.hits = 0
        self.misses = 0

    def _key(self, question: str, context: str = "") -> str:
        """Hash question + context so different tasks don't collide."""
        return hashlib.sha256(f"{context}||{question}".encode()).hexdigest()

    def get(self, question: str, context: str = "") -> tuple[str, str]:
        """Return (answer, provenance). Provenance: exact|semantic|miss."""
        key = self._key(question, context)

        # Layer 1: exact match (zero risk, instant)
        if key in self.exact:
            self.hits += 1
            return self.exact[key], "exact"

        # Layer 2: semantic similarity
        q_emb = embed(question)
        for entry in self.semantic:
            if cosine(q_emb, entry.embedding) >= self.threshold:
                self.hits += 1
                return entry.answer, "semantic"

        # Layer 3: LLM (last resort)
        self.misses += 1
        answer = self.llm_fn(question)
        self._store(key, question, answer, q_emb)
        return answer, "miss"

    def _store(self, key: str, question: str, answer: str, emb: list[float]) -> None:
        self.exact[key] = answer
        self.semantic.append(Entry(question, answer, emb, time.time()))

    def stats(self) -> dict[str, int]:
        return {
            "hits": self.hits,
            "misses": self.misses,
            "hit_rate": self.hits / (self.hits + self.misses) if (self.hits + self.misses) else 0,
        }


# ── Demo ───────────────────────────────────────────────────────────────────

def main() -> None:
    cache = SemanticCache(threshold=0.93)

    queries = [
        "How do I reset my password?",            # miss -> LLM
        "How do I reset my password?",            # exact hit
        "What's the way to change my password?",  # semantic hit (paraphrase)
        "Tell me how to recover my password",     # semantic hit
        "What's the capital of France?",          # miss (unrelated)
    ]

    for q in queries:
        answer, provenance = cache.get(q, context="helpdesk")
        print(f"  [{provenance:9s}] {q!r} -> {answer[:50]!r}")

    print("\n=== Stats ===")
    for k, v in cache.stats().items():
        print(f"  {k}: {v}")

    print("\nProduction notes:")
    print("  - Swap embed() for sentence-transformers or an embedding API")
    print("  - Use Redis + RediSearch or a vector DB for persistence")
    print("  - Scope cache keys by user/tenant to avoid cross-user leaks")
    print("  - Never cache mutable-state answers (inventory, prices)")


if __name__ == "__main__":
    main()