LightRAG/lightrag/cache/fts_cache.py
clssck 59e89772de refactor: consolidate to PostgreSQL-only backend and modernize stack
Remove legacy storage implementations and deprecated examples:
- Delete FAISS, JSON, Memgraph, Milvus, MongoDB, Nano Vector DB, Neo4j, NetworkX, Qdrant, Redis storage backends
- Remove Kubernetes deployment manifests and installation scripts
- Delete unofficial examples for deprecated backends and offline deployment docs
Streamline core infrastructure:
- Consolidate storage layer to PostgreSQL-only implementation
- Add full-text search caching with FTS cache module
- Implement metrics collection and monitoring pipeline
- Add explain and metrics API routes
Modernize frontend and tooling:
- Switch web UI to Bun with bun.lock, remove npm and pnpm lockfiles
- Update Dockerfile for PostgreSQL-only deployment
- Add Makefile for common development tasks
- Update environment and configuration examples
Enhance evaluation and testing capabilities:
- Add prompt optimization with DSPy and auto-tuning
- Implement ground truth regeneration and variant testing
- Add prompt debugging and response comparison utilities
- Expand test coverage with new integration scenarios
Simplify dependencies and configuration:
- Remove offline-specific requirement files
- Update pyproject.toml with streamlined dependencies
- Add Python version pinning with .python-version
- Create project guidelines in CLAUDE.md and AGENTS.md
2025-12-12 16:28:49 +01:00

244 lines
8.2 KiB
Python

"""
Full-text search result caching for LightRAG.
Follows the same dual-tier pattern as operate.py's query embedding cache:
- Local LRU cache for single-process performance
- Optional Redis for cross-worker sharing
Cache invalidation strategy:
- TTL-based expiration (default 5 minutes - shorter than embedding cache since content changes)
- Manual invalidation on document changes via invalidate_fts_cache_for_workspace()
Environment Variables:
FTS_CACHE_ENABLED: Enable/disable FTS caching (default: true)
FTS_CACHE_TTL: Cache TTL in seconds (default: 300 = 5 minutes)
FTS_CACHE_MAX_SIZE: Maximum local cache entries (default: 5000)
REDIS_FTS_CACHE: Enable Redis for cross-worker caching (default: false)
REDIS_URI: Redis connection URI (default: redis://localhost:6379)
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import time
from typing import Any
from lightrag.utils import logger
# Configuration from environment (matching operate.py pattern)
FTS_CACHE_TTL = int(os.getenv('FTS_CACHE_TTL', '300')) # 5 minutes
FTS_CACHE_MAX_SIZE = int(os.getenv('FTS_CACHE_MAX_SIZE', '5000'))
FTS_CACHE_ENABLED = os.getenv('FTS_CACHE_ENABLED', 'true').lower() == 'true'
REDIS_FTS_CACHE_ENABLED = os.getenv('REDIS_FTS_CACHE', 'false').lower() == 'true'
REDIS_URI = os.getenv('REDIS_URI', 'redis://localhost:6379')
# Local in-memory cache: {cache_key: (results, timestamp)}
_fts_cache: dict[str, tuple[list[dict[str, Any]], float]] = {}
_fts_cache_lock = asyncio.Lock()
# Redis client (lazy initialized, can be shared with embedding cache)
_redis_client = None
def _compute_cache_key(query: str, workspace: str, limit: int, language: str) -> str:
"""Compute deterministic cache key from search parameters.
Uses SHA256 truncated to 16 characters for compact keys while
maintaining very low collision probability.
"""
key_data = f'{query}|{workspace}|{limit}|{language}'
return hashlib.sha256(key_data.encode()).hexdigest()[:16]
async def _get_redis_client():
"""Lazy initialize Redis client for FTS cache."""
global _redis_client
if _redis_client is None and REDIS_FTS_CACHE_ENABLED:
try:
import redis.asyncio as redis
_redis_client = redis.from_url(REDIS_URI, decode_responses=True)
await _redis_client.ping()
logger.info(f'Redis FTS cache connected: {REDIS_URI}')
except ImportError:
logger.warning('Redis package not installed for FTS cache')
return None
except Exception as e:
logger.warning(f'Failed to connect to Redis for FTS cache: {e}')
return None
return _redis_client
async def get_cached_fts_results(
query: str,
workspace: str,
limit: int,
language: str = 'english',
) -> list[dict[str, Any]] | None:
"""Get cached FTS results if available and not expired.
Args:
query: Search query string
workspace: Workspace identifier
limit: Maximum results limit
language: Language for text search (default: english)
Returns:
Cached results list if cache hit, None if cache miss or disabled
"""
if not FTS_CACHE_ENABLED:
return None
cache_key = _compute_cache_key(query, workspace, limit, language)
redis_key = f'lightrag:fts:{cache_key}'
current_time = time.time()
# Try Redis first (if enabled)
if REDIS_FTS_CACHE_ENABLED:
try:
redis_client = await _get_redis_client()
if redis_client:
cached_json = await redis_client.get(redis_key)
if cached_json:
results = json.loads(cached_json)
logger.debug(f'Redis FTS cache hit for hash {cache_key[:8]}')
# Update local cache for faster subsequent hits
_fts_cache[cache_key] = (results, current_time)
return results
except Exception as e:
logger.debug(f'Redis FTS cache read error: {e}')
# Check local cache
cached = _fts_cache.get(cache_key)
if cached and (current_time - cached[1]) < FTS_CACHE_TTL:
logger.debug(f'Local FTS cache hit for hash {cache_key[:8]}')
return cached[0]
return None
async def store_fts_results(
query: str,
workspace: str,
limit: int,
language: str,
results: list[dict[str, Any]],
) -> None:
"""Store FTS results in cache.
Args:
query: Search query string
workspace: Workspace identifier
limit: Maximum results limit
language: Language for text search
results: Search results to cache
"""
if not FTS_CACHE_ENABLED:
return
cache_key = _compute_cache_key(query, workspace, limit, language)
redis_key = f'lightrag:fts:{cache_key}'
current_time = time.time()
# Manage local cache size - LRU eviction
async with _fts_cache_lock:
if len(_fts_cache) >= FTS_CACHE_MAX_SIZE:
# Remove oldest 10% of entries
sorted_entries = sorted(_fts_cache.items(), key=lambda x: x[1][1])
for old_key, _ in sorted_entries[: FTS_CACHE_MAX_SIZE // 10]:
del _fts_cache[old_key]
_fts_cache[cache_key] = (results, current_time)
# Store in Redis (if enabled)
if REDIS_FTS_CACHE_ENABLED:
try:
redis_client = await _get_redis_client()
if redis_client:
await redis_client.setex(
redis_key,
FTS_CACHE_TTL,
json.dumps(results),
)
logger.debug(f'FTS results cached in Redis for hash {cache_key[:8]}')
except Exception as e:
logger.debug(f'Redis FTS cache write error: {e}')
async def invalidate_fts_cache_for_workspace(workspace: str) -> int:
"""Invalidate all FTS cache entries for a workspace.
Call this when documents are added/modified/deleted in a workspace.
Note: Since cache keys are hashes and don't encode workspace in a
recoverable way, this clears all local cache entries. For Redis,
it clears all FTS cache entries. In practice, this is acceptable
because:
1. Document changes are relatively infrequent
2. FTS cache TTL is short (5 minutes)
3. Cache rebuild cost is low
Args:
workspace: Workspace identifier (for logging)
Returns:
Number of entries invalidated
"""
invalidated = 0
# Clear local cache
async with _fts_cache_lock:
invalidated = len(_fts_cache)
_fts_cache.clear()
logger.info(f'FTS cache invalidated for workspace {workspace}: cleared {invalidated} local entries')
# Clear Redis FTS cache (if enabled)
if REDIS_FTS_CACHE_ENABLED:
try:
redis_client = await _get_redis_client()
if redis_client:
# Use SCAN to find and delete FTS cache keys
cursor = 0
redis_deleted = 0
while True:
cursor, keys = await redis_client.scan(
cursor=cursor,
match='lightrag:fts:*',
count=100,
)
if keys:
await redis_client.delete(*keys)
redis_deleted += len(keys)
if cursor == 0:
break
if redis_deleted > 0:
logger.info(f'FTS cache invalidated in Redis: cleared {redis_deleted} entries')
invalidated += redis_deleted
except Exception as e:
logger.warning(f'Redis FTS cache invalidation error: {e}')
return invalidated
def get_fts_cache_stats() -> dict[str, Any]:
"""Get current FTS cache statistics.
Returns:
Dictionary with cache statistics
"""
current_time = time.time()
# Count valid (non-expired) entries
valid_entries = sum(1 for _, (_, ts) in _fts_cache.items() if (current_time - ts) < FTS_CACHE_TTL)
return {
'enabled': FTS_CACHE_ENABLED,
'total_entries': len(_fts_cache),
'valid_entries': valid_entries,
'max_size': FTS_CACHE_MAX_SIZE,
'ttl_seconds': FTS_CACHE_TTL,
'redis_enabled': REDIS_FTS_CACHE_ENABLED,
}