rmi-backend/app/rag/ann_index.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

240 lines
8.2 KiB
Python

"""M3 RAG ANN Index - numpy-based cosine similarity with Redis persistence.
Why numpy + Redis (not FAISS):
- 13 collections x ~5K docs = ~65K total - small enough for in-process numpy # noqa: RUF002
- No FAISS native dep, no rebuild on container restart
- Vector + metadata stored as JSON in Redis; loaded into a numpy matrix
on first search, then kept in process memory for fast repeated queries
- Cosine sim = dot product on L2-normalized vectors (we normalize on insert)
Persistence shape (per collection):
rag:ann:{collection}:meta → JSON {count, dim}
rag:ann:{collection}:docs → HASH doc_id → {"vector": [...], "metadata": {...}, "text": "..."}
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any
import numpy as np
from app.rag.embeddings import EMBEDDING_DIM
log = logging.getLogger(__name__)
# Lazy import - Redis client is created on first use, after app.core.redis is ready
_redis_client = None
def _get_redis():
global _redis_client
if _redis_client is None:
from app.core.redis import get_redis
_redis_client = get_redis()
return _redis_client
@dataclass
class Hit:
"""One search result."""
doc_id: str
score: float
text: str
metadata: dict[str, Any]
class ANNIndex:
"""Per-collection vector index. Holds vectors in memory, persists to Redis.
Usage:
idx = ANNIndex("scam_intel")
idx.add("doc1", vector, {"source": "x"}, text="...")
hits = idx.search(query_vec, top_k=5)
"""
def __init__(self, collection: str, dim: int = EMBEDDING_DIM):
self.collection = collection
self.dim = dim
self._ids: list[str] = []
self._matrix: np.ndarray | None = None # shape (N, dim), L2-normalized
self._meta: dict[str, dict] = {} # doc_id → {text, metadata}
self._loaded = False
# ── Redis keys ────────────────────────────────────────────────────
def _key_docs(self) -> str:
return f"rag:ann:{self.collection}:docs"
def _key_meta(self) -> str:
return f"rag:ann:{self.collection}:meta"
# ── Persistence ───────────────────────────────────────────────────
def _persist(self) -> None:
r = _get_redis()
pipe = r.pipeline()
for doc_id in self._ids:
entry = {
"vector": (self._matrix[self._ids.index(doc_id)] if self._matrix is not None else []).tolist(),
"metadata": self._meta[doc_id].get("metadata", {}),
"text": self._meta[doc_id].get("text", ""),
}
pipe.hset(self._key_docs(), doc_id, json.dumps(entry))
pipe.set(
self._key_meta(),
json.dumps({"count": len(self._ids), "dim": self.dim}),
)
pipe.execute()
def _load(self) -> None:
if self._loaded:
return
try:
r = _get_redis()
raw = r.hgetall(self._key_docs())
except Exception as e:
log.warning("ann_load_failed collection=%s err=%s", self.collection, e)
raw = {}
if not raw:
self._loaded = True
return
ids: list[str] = []
vecs: list[list[float]] = []
meta: dict[str, dict] = {}
for doc_id, blob in raw.items():
try:
entry = json.loads(blob)
ids.append(doc_id)
vecs.append(entry.get("vector", []))
meta[doc_id] = {
"metadata": entry.get("metadata", {}),
"text": entry.get("text", ""),
}
except Exception as e:
log.debug("ann_skip doc=%s err=%s", doc_id, e)
if vecs:
self._matrix = np.asarray(vecs, dtype=np.float32)
self._ids = ids
self._meta = meta
self._loaded = True
log.info("ann_loaded collection=%s count=%d", self.collection, len(ids))
# ── Mutations ─────────────────────────────────────────────────────
def add(
self,
doc_id: str,
vector: list[float],
metadata: dict[str, Any] | None = None,
text: str = "",
) -> None:
self._load()
arr = np.asarray(vector, dtype=np.float32)
if arr.shape[0] != self.dim:
# Pad or truncate
if arr.shape[0] >= self.dim:
arr = arr[: self.dim]
else:
arr = np.concatenate(
[arr, np.zeros(self.dim - arr.shape[0], dtype=np.float32)]
)
# L2 normalize
n = float(np.linalg.norm(arr))
if n > 0:
arr = arr / n
if doc_id in self._ids:
# Update - replace vector
idx = self._ids.index(doc_id)
self._matrix[idx] = arr
else:
if self._matrix is None:
self._matrix = arr.reshape(1, -1)
else:
self._matrix = np.vstack([self._matrix, arr.reshape(1, -1)])
self._ids.append(doc_id)
self._meta[doc_id] = {"metadata": metadata or {}, "text": text}
self._persist()
def delete(self, doc_id: str) -> bool:
self._load()
if doc_id not in self._ids:
return False
idx = self._ids.index(doc_id)
self._ids.pop(idx)
if self._matrix is not None and self._matrix.shape[0] > 1:
self._matrix = np.delete(self._matrix, idx, axis=0)
else:
self._matrix = None
self._meta.pop(doc_id, None)
self._persist()
return True
def clear(self) -> None:
self._ids = []
self._matrix = None
self._meta = {}
self._loaded = True
try:
r = _get_redis()
r.delete(self._key_docs(), self._key_meta())
except Exception as e:
log.warning("ann_clear_failed: %s", e)
# ── Search ────────────────────────────────────────────────────────
def search(
self,
query_vector: list[float],
top_k: int = 5,
min_similarity: float = 0.0,
) -> list[Hit]:
self._load()
if self._matrix is None or len(self._ids) == 0:
return []
q = np.asarray(query_vector, dtype=np.float32)
n = float(np.linalg.norm(q))
if n > 0:
q = q / n
# Cosine sim = dot product on normalized vectors
scores = self._matrix @ q
# Top-k by score
k = min(top_k, len(self._ids))
# argpartition is faster than full argsort for small top_k
idx = np.argpartition(-scores, k - 1)[:k] if k < len(scores) else np.arange(len(scores))
idx = idx[np.argsort(-scores[idx])]
hits: list[Hit] = []
for i in idx:
s = float(scores[i])
if s < min_similarity:
continue
doc_id = self._ids[int(i)]
meta = self._meta.get(doc_id, {})
hits.append(
Hit(
doc_id=doc_id,
score=s,
text=meta.get("text", ""),
metadata=meta.get("metadata", {}),
)
)
return hits
# ── Stats ─────────────────────────────────────────────────────────
def count(self) -> int:
self._load()
return len(self._ids)
# ── Per-collection cache ─────────────────────────────────────────────
_index_cache: dict[str, ANNIndex] = {}
def get_index(collection: str) -> ANNIndex:
"""Get or create the ANNIndex for a collection. Cached per process."""
if collection not in _index_cache:
_index_cache[collection] = ANNIndex(collection)
return _index_cache[collection]