#!/usr/bin/env python3 """ FAISS-based ANN Index Manager for RMI RAG ========================================== Replaces O(n) brute-force Redis cosine scan with sub-millisecond FAISS HNSW / IVFFlat approximate nearest-neighbor search. Architecture: - Loads all vectors from Redis for each collection into a FAISS index - Keeps index in memory; auto-rebuilds when stale - Persists pickled indexes to /app/data/faiss/{collection}.index - Tracks version counter in Redis key rag:idx_version:{collection} - Invalidate on new ingestion (version bump) """ import asyncio import json import logging import os import pickle 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", "") FAISS_DATA_DIR = os.getenv("FAISS_DATA_DIR", "/app/data/faiss") # HNSW defaults HNSW_M = 16 HNSW_EF_CONSTRUCTION = 200 HNSW_EF_SEARCH = 128 # IVFFlat defaults IVF_LISTS_FACTOR = 40 # lists = n_vectors / factor, min 4 IVF_NPROBE = 16 # Minimum docs to use IVFFlat/HNSW; below this, flat search is fine MIN_DOCS_FOR_ANN = 50 class ANNIndex: """ FAISS-backed approximate nearest-neighbor index manager. Each collection gets its own FAISS index built from Redis-stored vectors. The index is kept in process memory and persisted to disk so it survives restarts. Usage: idx = ANNIndex() await idx.build_index("scam_patterns") results = idx.search(query_embedding, "scam_patterns", limit=10) """ _instance: Optional["ANNIndex"] = None def __init__(self): self._indexes: dict[str, Any] = {} # collection -> faiss index self._id_maps: dict[str, list[str]] = {} # collection -> [doc_id, ...] self._meta: dict[str, dict] = {} # collection -> build metadata self._redis = None @classmethod def get_instance(cls) -> "ANNIndex": 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 # ── Build ──────────────────────────────────────────────────── async def build_index(self, collection: str, force: bool = False) -> dict[str, Any]: """ Build (or rebuild) a FAISS index for *collection*. Reads all documents from Redis rag:{collection}:* and builds an HNSW or IVFFlat index depending on document count. Returns build metadata dict. """ # Skip if fresh enough (unless forced) if not force and self.is_built(collection): version_redis = await self._get_version(collection) version_local = self._meta.get(collection, {}).get("version", -1) if version_redis == version_local: logger.info(f"ANN index for {collection} is fresh (v{version_local})") return self._meta.get(collection, {}) r = await self._get_redis() # Fetch all document IDs doc_ids = list(await r.smembers(f"rag:idx:{collection}")) n = len(doc_ids) if n == 0: logger.warning(f"No documents found for {collection}") self._meta[collection] = {"status": "empty", "n": 0, "collection": collection} return self._meta[collection] # Batch-fetch documents keys = [f"rag:{collection}:{did}" for did in doc_ids] pipe = r.pipeline() for k in keys: pipe.get(k) raw_docs = await pipe.execute() # Extract vectors and metadata; track dimension vectors = [] valid_ids = [] dims = 0 for i, data in enumerate(raw_docs): if not data: continue try: doc = json.loads(data) except json.JSONDecodeError: continue vec = doc.get("vector", []) # Handle JSON-string vectors (from hash re-embed) if isinstance(vec, str): try: vec = json.loads(vec) except (json.JSONDecodeError, TypeError): continue if not vec or not isinstance(vec, list): continue if dims == 0: dims = len(vec) if len(vec) != dims: # Pad or truncate to match first vector's dimension vec = vec + [0.0] * (dims - len(vec)) if len(vec) < dims else vec[:dims] vectors.append(vec) valid_ids.append(doc_ids[i]) n_valid = len(vectors) if n_valid == 0: logger.warning(f"No valid vectors for {collection}") self._meta[collection] = {"status": "no_vectors", "n": 0, "collection": collection} return self._meta[collection] mat = np.array(vectors, dtype=np.float32) # Choose index type import faiss if n_valid < MIN_DOCS_FOR_ANN: # Flat index - exact search, small collection index = faiss.IndexFlatIP(dims) # inner product (cosine after norm) index_type = "flat" else: # Normalize vectors for cosine similarity via inner product faiss.normalize_L2(mat) # Try HNSW first (best quality, no training needed) try: index = faiss.IndexHNSWFlat(dims, HNSW_M, faiss.METRIC_INNER_PRODUCT) index.hnsw.efConstruction = HNSW_EF_CONSTRUCTION index.hnsw.efSearch = HNSW_EF_SEARCH index_type = "hnsw" logger.info(f"Building HNSW index for {collection}: {n_valid} vectors, {dims}d") except Exception as e: logger.warning(f"HNSW failed, falling back to IVFFlat: {e}") # IVFFlat fallback nlist = max(4, n_valid // IVF_LISTS_FACTOR) quantizer = faiss.IndexFlatIP(dims) index = faiss.IndexIVFFlat(quantizer, dims, nlist, faiss.METRIC_INNER_PRODUCT) index.nprobe = IVF_NPROBE index.train(mat) index_type = "ivfflat" # Normalize for cosine via inner product (skip if already done for HNSW path) if index_type == "flat": faiss.normalize_L2(mat) index.add(mat) # Store in memory self._indexes[collection] = index self._id_maps[collection] = valid_ids version = await self._get_version(collection) # Persist to disk os.makedirs(FAISS_DATA_DIR, exist_ok=True) index_path = os.path.join(FAISS_DATA_DIR, f"{collection}.index") try: # faiss indexes can be serialized directly faiss.write_index(index, index_path) # Save id_map alongside id_map_path = os.path.join(FAISS_DATA_DIR, f"{collection}.ids") with open(id_map_path, "wb") as f: pickle.dump(valid_ids, f) logger.info(f"Persisted FAISS index to {index_path}") except Exception as e: logger.warning(f"Failed to persist FAISS index: {e}") build_meta = { "status": "built", "collection": collection, "n": n_valid, "dims": dims, "index_type": index_type, "version": version, "built_at": time.time(), "persisted": os.path.exists(index_path), } self._meta[collection] = build_meta logger.info(f"ANN index built: {collection} ({n_valid} docs, {dims}d, {index_type})") return build_meta # ── Load from disk ──────────────────────────────────────────── def _load_from_disk(self, collection: str) -> bool: """Try to load a persisted FAISS index and id_map from disk.""" import faiss index_path = os.path.join(FAISS_DATA_DIR, f"{collection}.index") id_map_path = os.path.join(FAISS_DATA_DIR, f"{collection}.ids") if not os.path.exists(index_path) or not os.path.exists(id_map_path): return False try: index = faiss.read_index(index_path) with open(id_map_path, "rb") as f: id_list = pickle.load(f) self._indexes[collection] = index self._id_maps[collection] = id_list self._meta[collection] = { "status": "loaded", "collection": collection, "n": len(id_list), "dims": index.d, "index_type": type(index).__name__, "loaded_at": time.time(), } logger.info(f"Loaded FAISS index for {collection} from disk ({len(id_list)} vectors)") return True except Exception as e: logger.warning(f"Failed to load FAISS index from disk: {e}") return False # ── Search ──────────────────────────────────────────────────── async def search( self, query_embedding: list[float], collection: str, limit: int = 10, min_similarity: float = 0.0, ) -> list[dict[str, Any]]: """ ANN search: find top-k documents similar to query_embedding. Auto-builds the index on first search if not yet built. Hydrates results with content/metadata from Redis. Returns list of {id, similarity, content, metadata, source, severity} dicts. """ # Auto-build if needed if not self.is_built(collection): # Try disk first, then build from Redis (disk I/O offloaded to thread) loaded = await asyncio.to_thread(self._load_from_disk, collection) if not loaded: await self.build_index(collection) if not self.is_built(collection): logger.warning(f"No ANN index available for {collection}") return [] index = self._indexes[collection] id_list = self._id_maps[collection] dims = index.d # Prepare query vector q = np.array([query_embedding[:dims]], dtype=np.float32) # Pad if query is shorter if q.shape[1] < dims: q = np.pad(q, ((0, 0), (0, dims - q.shape[1]))) # Truncate if query is longer if q.shape[1] > dims: q = q[:, :dims] # Normalize for cosine via inner product import faiss faiss.normalize_L2(q) # Search search_k = min(limit * 2, len(id_list)) # fetch extra for filtering distances, indices = index.search(q, search_k) # Collect matching doc IDs for hydration raw_hits = [] for rank, (dist, idx) in enumerate(zip(distances[0], indices[0], strict=False)): if idx < 0: continue # FAISS returns -1 for empty slots sim = float(dist) # inner product on normalized vectors = cosine similarity if sim < min_similarity: continue doc_id = id_list[idx] if idx < len(id_list) else f"unknown_{idx}" raw_hits.append((doc_id, sim, rank)) if not raw_hits: return [] # Hydrate from Redis - batch-fetch all matched docs r = await self._get_redis() keys = [f"rag:{collection}:{doc_id}" for doc_id, _, _ in raw_hits] pipe = r.pipeline() for k in keys: pipe.get(k) raw_docs = await pipe.execute() results = [] for (doc_id, sim, rank), data in zip(raw_hits, raw_docs, strict=False): result = { "id": doc_id, "similarity": round(sim, 4), "rank": rank, } if data: try: doc = json.loads(data) result["content"] = doc.get("content", "")[:500] result["metadata"] = doc.get("metadata", {}) result["source"] = doc.get("source", "") result["severity"] = doc.get("severity", "") except json.JSONDecodeError: pass results.append(result) results.sort(key=lambda x: x["similarity"], reverse=True) return results[:limit] # ── Status ──────────────────────────────────────────────────── def is_built(self, collection: str) -> bool: """Return True if an in-memory index exists for the collection.""" return collection in self._indexes and collection in self._id_maps def stats(self) -> dict[str, Any]: """Return stats for all loaded indexes.""" out = {} for coll in set(list(self._indexes.keys()) + list(self._meta.keys())): idx = self._indexes.get(coll) out[coll] = { "built": coll in self._indexes, "n_vectors": len(self._id_maps.get(coll, [])), "dims": idx.d if idx else 0, "index_type": type(idx).__name__ if idx else "none", **self._meta.get(coll, {}), } return out # ── Version tracking ───────────────────────────────────────── async def _get_version(self, collection: str) -> int: """Get the current version counter from Redis.""" r = await self._get_redis() val = await r.get(f"rag:idx_version:{collection}") return int(val) if val else 0 async def bump_version(self, collection: str) -> int: """ Bump the version counter (call after new ingestion). This signals that the index needs rebuilding. """ r = await self._get_redis() new_ver = await r.incr(f"rag:idx_version:{collection}") # Invalidate in-memory index self._indexes.pop(collection, None) self._id_maps.pop(collection, None) logger.info(f"Version bumped for {collection}: now v{new_ver}") return new_ver # ── Invalidate ──────────────────────────────────────────────── def invalidate(self, collection: str) -> None: """Drop the in-memory index for *collection* (next search will rebuild).""" self._indexes.pop(collection, None) self._id_maps.pop(collection, None) self._meta.pop(collection, None) logger.info(f"Invalidated ANN index for {collection}") # ══════════════════════════════════════════════════════════════════════ # Singleton accessor # ══════════════════════════════════════════════════════════════════════ _ann_index: ANNIndex | None = None def get_ann_index() -> ANNIndex: """Return the singleton ANNIndex instance.""" global _ann_index if _ann_index is None: _ann_index = ANNIndex() return _ann_index