#!/usr/bin/env python3 """ Semantic Query Cache for RMI RAG ================================= Caches search results keyed by query embedding similarity. If a new query's embedding is > 0.95 cosine-similar to a cached query, the cached results are returned instantly - no vector search needed. Redis key layout: sem_cache:{sha256(embedding_prefix)} -> JSON({embedding, results, timestamp}) TTL: 1 hour (crypto data changes fast) Max: 10 000 entries, LRU eviction via Redis volatile-lru policy. """ import hashlib import json import logging import os import time from typing import Any, Optional import numpy as np logger = logging.getLogger(__name__) REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis") REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") CACHE_TTL = 3600 # 1 hour MAX_CACHE_SIZE = 10000 SIMILARITY_THRESHOLD = 0.95 EMBEDDING_PREFIX_DIMS = 64 # Use first 64 dims for the lookup hash (fast) class SemanticCache: """ Semantic query result cache. On check: embed the query → compute a 64-dim prefix hash → scan nearby cached entries → if cosine > 0.95 → return cached results. On store: save query embedding + results to Redis with TTL. """ _instance: Optional["SemanticCache"] = None def __init__(self): self._redis = None self._hits = 0 self._misses = 0 @classmethod def get_instance(cls) -> "SemanticCache": if cls._instance is None: cls._instance = cls() return cls._instance async def _get_redis(self): import redis.asyncio as aioredis if self._redis is None: self._redis = aioredis.Redis( host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, db=0, decode_responses=True, ) return self._redis # ── Cache key from embedding ──────────────────────────────── @staticmethod def _embedding_hash(embedding: list[float]) -> str: """Deterministic hash of the first N dims of an embedding.""" prefix = embedding[:EMBEDDING_PREFIX_DIMS] raw = ",".join(f"{v:.6f}" for v in prefix) return hashlib.sha256(raw.encode()).hexdigest()[:24] @staticmethod def _cosine(a: list[float], b: list[float]) -> float: """Fast cosine similarity on two embedding vectors.""" a_arr = np.array(a, dtype=np.float32) b_arr = np.array(b, dtype=np.float32) # Use common length n = min(len(a_arr), len(b_arr)) a_arr = a_arr[:n] b_arr = b_arr[:n] dot = float(np.dot(a_arr, b_arr)) na = float(np.linalg.norm(a_arr)) nb = float(np.linalg.norm(b_arr)) if na == 0 or nb == 0: return 0.0 return dot / (na * nb) # ── Check ──────────────────────────────────────────────────── async def check(self, query_embedding: list[float]) -> list[dict[str, Any]] | None: """ Check if a sufficiently similar query is cached. Returns cached results if found (cosine > SIMILARITY_THRESHOLD), or None on cache miss. """ r = await self._get_redis() key_prefix = "sem_cache:" # Compute our hash bucket and a few adjacent buckets for robustness self._embedding_hash(query_embedding) candidate_keys = [] # Scan recent cache entries - use Redis SCAN for efficiency # But limit the scan to prevent O(N) scans on huge caches try: # Use a pattern scan limited by count cursor = 0 scanned = 0 while scanned < MAX_CACHE_SIZE: cursor, keys = await r.scan(cursor, match=f"{key_prefix}*", count=200) candidate_keys.extend(keys) scanned += len(keys) if cursor == 0: break if not candidate_keys: self._misses += 1 return None # Fetch all candidate entries pipe = r.pipeline() for k in candidate_keys: pipe.get(k) raw_entries = await pipe.execute() best_sim = 0.0 best_results = None for raw in raw_entries: if not raw: continue try: entry = json.loads(raw) except json.JSONDecodeError: continue cached_emb = entry.get("embedding", []) if not cached_emb: continue sim = self._cosine(query_embedding, cached_emb) if sim > best_sim: best_sim = sim best_results = entry.get("results", []) if sim >= SIMILARITY_THRESHOLD: break # Found a match, no need to check more if best_sim >= SIMILARITY_THRESHOLD and best_results is not None: self._hits += 1 logger.info(f"Semantic cache HIT: similarity={best_sim:.4f}") return best_results except Exception as e: logger.warning(f"Semantic cache check error: {e}") self._misses += 1 return None # ── Store ──────────────────────────────────────────────────── async def store( self, query_embedding: list[float], results: list[dict[str, Any]], ) -> None: """ Store search results for a query embedding. """ r = await self._get_redis() h = self._embedding_hash(query_embedding) key = f"sem_cache:{h}" entry = { "embedding": query_embedding, "results": results, "timestamp": time.time(), } try: await r.setex(key, CACHE_TTL, json.dumps(entry)) # Enforce max cache size - delete oldest if over limit # (Redis volatile-lru handles this, but let's be explicit) cache_keys = [] cursor = 0 while True: cursor, keys = await r.scan(cursor, match="sem_cache:*", count=500) cache_keys.extend(keys) if cursor == 0: break if len(cache_keys) > MAX_CACHE_SIZE: # Delete oldest entries (by TTL remaining - lowest TTL = oldest) pipe = r.pipeline() for k in cache_keys: pipe.ttl(k) ttls = await pipe.execute() # Sort by remaining TTL ascending (smallest = oldest) pairs = sorted(zip(ttls, cache_keys, strict=False)) to_delete = len(cache_keys) - MAX_CACHE_SIZE for _, k in pairs[:to_delete]: await r.delete(k) logger.info(f"Pruned {to_delete} semantic cache entries") except Exception as e: logger.warning(f"Semantic cache store error: {e}") # ── Stats ──────────────────────────────────────────────────── async def stats(self) -> dict[str, Any]: """Return cache statistics.""" r = await self._get_redis() try: count = 0 cursor = 0 while True: cursor, keys = await r.scan(cursor, match="sem_cache:*", count=500) count += len(keys) if cursor == 0: break except Exception: count = -1 return { "entries": count, "hits": self._hits, "misses": self._misses, "hit_rate": round(self._hits / max(1, self._hits + self._misses), 4), "similarity_threshold": SIMILARITY_THRESHOLD, "ttl": CACHE_TTL, "max_size": MAX_CACHE_SIZE, } # ── Clear ──────────────────────────────────────────────────── async def clear(self) -> int: """Clear all semantic cache entries.""" r = await self._get_redis() deleted = 0 cursor = 0 while True: cursor, keys = await r.scan(cursor, match="sem_cache:*", count=500) if keys: await r.delete(*keys) deleted += len(keys) if cursor == 0: break logger.info(f"Cleared {deleted} semantic cache entries") return deleted # ══════════════════════════════════════════════════════════════════════ # Singleton accessor # ══════════════════════════════════════════════════════════════════════ _semantic_cache: SemanticCache | None = None def get_semantic_cache() -> SemanticCache: """Return the singleton SemanticCache instance.""" global _semantic_cache if _semantic_cache is None: _semantic_cache = SemanticCache() return _semantic_cache