Spaces:
Sleeping
Sleeping
| import hashlib | |
| import json | |
| import logging | |
| import redis | |
| from langchain_community.utils.math import cosine_similarity | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| from langfuse import observe | |
| from config.settings import SETTINGS | |
| logger = logging.getLogger(__name__) | |
| class SemanticCache: | |
| """ | |
| Redis-backed semantic cache placed before the retrieval step. | |
| Flow: | |
| query → embed → search cache (cosine similarity) → HIT: return cached result | |
| → MISS: retrieve → store in cache | |
| """ | |
| KEY_PREFIX = "rag:retrieval:" | |
| DEFAULT_TTL = 60 * 60 * 24 # 24 hours | |
| def __init__( | |
| self, | |
| embeddings: HuggingFaceEmbeddings, | |
| threshold: float = SETTINGS.REDIS_CACHE_THRESHOLD, | |
| key_prefix: str = "rag:retrieval:", | |
| ): | |
| self.embeddings = embeddings | |
| self.threshold = threshold | |
| self.KEY_PREFIX = key_prefix | |
| # Ưu tiên REDIS_URL (cloud API) nếu có, fallback sang host:port (local Docker) | |
| if SETTINGS.REDIS_URL: | |
| self._client = redis.from_url( | |
| SETTINGS.REDIS_URL, | |
| decode_responses=True, | |
| ) | |
| else: | |
| self._client = redis.Redis( | |
| host=SETTINGS.REDIS_HOST, | |
| port=SETTINGS.REDIS_PORT, | |
| password=SETTINGS.REDIS_PASSWORD.get_secret_value() if SETTINGS.REDIS_PASSWORD else None, | |
| decode_responses=True, | |
| ) | |
| self._ping() | |
| # ------------------------------------------------------------------ | |
| # Internal helpers | |
| # ------------------------------------------------------------------ | |
| def _ping(self) -> None: | |
| try: | |
| self._client.ping() | |
| logger.info("Redis connection established (%s:%s)", SETTINGS.REDIS_HOST, SETTINGS.REDIS_PORT) | |
| except redis.ConnectionError as exc: | |
| logger.warning("Cannot connect to Redis – cache will be skipped: %s", exc) | |
| def _embed(self, text: str) -> list[float]: | |
| return self.embeddings.embed_query(text) | |
| def _build_key(self, query: str) -> str: | |
| digest = hashlib.md5(query.encode()).hexdigest() | |
| return f"{self.KEY_PREFIX}{digest}" | |
| # ------------------------------------------------------------------ | |
| # Public API | |
| # ------------------------------------------------------------------ | |
| def get(self, query: str) -> str | None: | |
| """ | |
| Search cache using semantic similarity. | |
| Returns the cached result if score >= threshold, otherwise None. | |
| """ | |
| try: | |
| query_vector = self._embed(query) | |
| best_score = 0 | |
| best_response = "" | |
| best_query = "" | |
| for key in self._client.scan_iter(match=f"{self.KEY_PREFIX}*"): | |
| embedding_cache = json.loads(self._client.hget(key, "embedding")) | |
| similarity_scores = cosine_similarity([query_vector], [embedding_cache])[0][0] | |
| if similarity_scores > best_score: | |
| best_score = similarity_scores | |
| best_response = self._client.hget(key, "result") | |
| best_query = self._client.hget(key, "query") | |
| print(f"best query: {best_query}") | |
| print(f"best score: {best_score}") | |
| if best_score >= self.threshold: | |
| logger.info(f"Cache HIT (score={best_score}, threshold={self.threshold})") | |
| return best_response | |
| logger.info(f"Cache MISS (score={best_score}, threshold={self.threshold})") | |
| return None | |
| except Exception as exc: | |
| logger.warning("Cache lookup failed – falling through to retriever: %s", exc) | |
| return None | |
| def set(self, query: str, result: str) -> None: | |
| """Store a retrieval result in cache.""" | |
| try: | |
| print(f"DEBUG query type: {type(query)}") | |
| print(f"DEBUG query value: {repr(query)}") | |
| vector = self._embed(query) | |
| key = self._build_key(query) | |
| pipe = self._client.pipeline(transaction=False) | |
| pipe.hset( | |
| key, | |
| mapping={ | |
| "query": query, | |
| "embedding": json.dumps(vector, ensure_ascii=False), | |
| "result": result, | |
| }, | |
| ) | |
| pipe.expire(key, self.DEFAULT_TTL) | |
| pipe.execute() | |
| logger.info("Cached retrieval result for query: %r", query[:60]) | |
| except Exception as exc: | |
| logger.warning("Cache write failed: %s", exc) | |
| def invalidate_all(self) -> int: | |
| """Delete all retrieval cache keys. Call when S3 / vector store is updated.""" | |
| keys = list(self._client.scan_iter(match=f"{self.KEY_PREFIX}*")) | |
| if keys: | |
| self._client.delete(*keys) | |
| logger.info("Cache invalidated: %d key(s) removed", len(keys)) | |
| return len(keys) | |