Rifqi Hafizuddin
[NOTICKET] fix: review cheap batch β F-19 SSE order, F-22/F-24 analysis_id, F-26, F-16, F-4
cbc8d6a | """Retrieval router β dispatches to DocumentRetriever for unstructured sources. | |
| Routing rules: | |
| - unstructured / document / both β DocumentRetriever (PGVector, PDF/DOCX/TXT) | |
| - structured / schema β empty list; handled by query/service.py | |
| - chat β empty list; bypasses retrieval entirely | |
| Exposes the same interface as the old src/rag/retriever.py so call sites in | |
| chat.py require no changes beyond the import path. | |
| """ | |
| import hashlib | |
| import json | |
| from dataclasses import asdict | |
| from src.config.settings import settings | |
| from src.db.redis.connection import get_redis | |
| from src.middlewares.logging import get_logger | |
| from src.retrieval.base import RetrievalResult | |
| from src.retrieval.document import DocumentRetriever | |
| logger = get_logger("retrieval_router") | |
| _CACHE_TTL = 3600 | |
| # Namespaced with `settings.redis_prefix`, like every other cache key in the service | |
| # (cf. `api/v1/chat.py::build_cache_key`). Without it, two environments pointed at the | |
| # same Redis β which the single shared `.env` makes entirely plausible β cross-serve | |
| # each other's retrieval results. Not cross-TENANT (`user_id` is in the key), but | |
| # cross-environment, which is confusing in exactly the way stale-cache bugs are. The | |
| # one-time cost is a cache-miss storm bounded by the 1h TTL. (F-16) | |
| _CACHE_KEY_PREFIX = f"{settings.redis_prefix}retrieval" | |
| class RetrievalRouter: | |
| def __init__(self) -> None: | |
| self._retriever: DocumentRetriever | None = None | |
| def _get_retriever(self) -> DocumentRetriever: | |
| if self._retriever is None: | |
| self._retriever = DocumentRetriever() | |
| return self._retriever | |
| async def retrieve( | |
| self, | |
| query: str, | |
| user_id: str, | |
| k: int = 5, | |
| ) -> list[RetrievalResult]: | |
| redis = await get_redis() | |
| query_hash = hashlib.md5(query.encode()).hexdigest() | |
| cache_key = f"{_CACHE_KEY_PREFIX}:{user_id}:{query_hash}:{k}" | |
| cached = await redis.get(cache_key) | |
| if cached: | |
| try: | |
| raw = json.loads(cached) | |
| logger.info("returning cached retrieval results") | |
| return [RetrievalResult(**r) for r in raw] | |
| except Exception: | |
| logger.warning("corrupted retrieval cache, fetching fresh") | |
| try: | |
| results = await self._get_retriever().retrieve(query, user_id, k) | |
| except Exception as e: | |
| logger.error("retrieval failed", error=str(e)) | |
| return [] | |
| await redis.setex( | |
| cache_key, | |
| _CACHE_TTL, | |
| json.dumps([asdict(r) for r in results]), | |
| ) | |
| return results | |
| async def invalidate_cache(self, user_id: str) -> int: | |
| """Delete all cached retrieval entries for a user. Call after upload/delete.""" | |
| redis = await get_redis() | |
| pattern = f"{_CACHE_KEY_PREFIX}:{user_id}:*" | |
| keys = [key async for key in redis.scan_iter(match=pattern)] | |
| if not keys: | |
| return 0 | |
| deleted = await redis.delete(*keys) | |
| logger.info("retrieval cache invalidated", user_id=user_id, deleted=deleted) | |
| return int(deleted) | |
| retrieval_router = RetrievalRouter() | |