Hermes commited on
Commit ·
beb7310
1
Parent(s): 9175ad9
feat(rag/m3): v3 RAG engine — embeddings + ANN + chunking + 3-pillar search
Browse files- backend/app/api/v1/rag/__pycache__/search.cpython-311.pyc +0 -0
- backend/app/api/v1/rag/search.py +26 -2
- backend/app/rag/__init__.py +19 -6
- backend/app/rag/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/app/rag/__pycache__/ann_index.cpython-311.pyc +0 -0
- backend/app/rag/__pycache__/chunking.cpython-311.pyc +0 -0
- backend/app/rag/__pycache__/embeddings.cpython-311.pyc +0 -0
- backend/app/rag/__pycache__/engine.cpython-311.pyc +0 -0
- backend/app/rag/__pycache__/models.cpython-311.pyc +0 -0
- backend/app/rag/__pycache__/service.cpython-311.pyc +0 -0
- backend/app/rag/ann_index.py +240 -0
- backend/app/rag/chunking.py +158 -0
- backend/app/rag/embeddings.py +183 -0
- backend/app/rag/engine.py +308 -0
- backend/app/rag/service.py +41 -22
backend/app/api/v1/rag/__pycache__/search.cpython-311.pyc
ADDED
|
Binary file (4.35 kB). View file
|
|
|
backend/app/api/v1/rag/search.py
CHANGED
|
@@ -5,9 +5,10 @@ facade exposes the most-used operations: search, ingest, feedback.
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
-
from typing import Annotated
|
| 9 |
|
| 10 |
-
from fastapi import APIRouter, Depends
|
|
|
|
| 11 |
|
| 12 |
from app.rag import (
|
| 13 |
FeedbackRecord,
|
|
@@ -17,6 +18,8 @@ from app.rag import (
|
|
| 17 |
SearchRequest,
|
| 18 |
SearchResponse,
|
| 19 |
)
|
|
|
|
|
|
|
| 20 |
|
| 21 |
router = APIRouter(prefix="/api/v1/rag/v2", tags=["rag"])
|
| 22 |
|
|
@@ -25,6 +28,11 @@ def _service() -> RAGService:
|
|
| 25 |
return RAGService()
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
@router.post("/search", response_model=SearchResponse)
|
| 29 |
async def search(
|
| 30 |
req: SearchRequest,
|
|
@@ -55,3 +63,19 @@ async def feedback(
|
|
| 55 |
collection="known_scams",
|
| 56 |
status="ok" if ok else "failed",
|
| 57 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
+
from typing import Annotated, Any
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
|
| 13 |
from app.rag import (
|
| 14 |
FeedbackRecord,
|
|
|
|
| 18 |
SearchRequest,
|
| 19 |
SearchResponse,
|
| 20 |
)
|
| 21 |
+
from app.rag.engine import bulk_ingest as engine_bulk_ingest
|
| 22 |
+
from app.rag.engine import get_stats as engine_get_stats
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/api/v1/rag/v2", tags=["rag"])
|
| 25 |
|
|
|
|
| 28 |
return RAGService()
|
| 29 |
|
| 30 |
|
| 31 |
+
class BulkIngestRequest(BaseModel):
|
| 32 |
+
collection: str = "scam_intel"
|
| 33 |
+
items: list[dict[str, Any]] = Field(default_factory=list)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
@router.post("/search", response_model=SearchResponse)
|
| 37 |
async def search(
|
| 38 |
req: SearchRequest,
|
|
|
|
| 63 |
collection="known_scams",
|
| 64 |
status="ok" if ok else "failed",
|
| 65 |
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("/stats")
|
| 69 |
+
async def stats() -> dict:
|
| 70 |
+
"""Per-collection vector counts + active embedder backend."""
|
| 71 |
+
return engine_get_stats()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post("/bulk-ingest")
|
| 75 |
+
async def bulk(req: BulkIngestRequest) -> dict:
|
| 76 |
+
"""Ingest many items into a collection sequentially (max 500 per call)."""
|
| 77 |
+
if not req.items:
|
| 78 |
+
raise HTTPException(status_code=400, detail="items must be non-empty")
|
| 79 |
+
if len(req.items) > 500:
|
| 80 |
+
raise HTTPException(status_code=400, detail="bulk limit 500 per call")
|
| 81 |
+
return await engine_bulk_ingest(items=req.items, collection=req.collection)
|
backend/app/rag/__init__.py
CHANGED
|
@@ -1,4 +1,12 @@
|
|
| 1 |
-
"""RAG domain —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from app.core import health as health_mod
|
|
@@ -6,13 +14,18 @@ from app.core.health import DomainHealth
|
|
| 6 |
|
| 7 |
|
| 8 |
async def _health_check() -> DomainHealth:
|
| 9 |
-
"""RAG health:
|
| 10 |
try:
|
| 11 |
-
from app.
|
|
|
|
| 12 |
return DomainHealth(
|
| 13 |
name="rag",
|
| 14 |
healthy=True,
|
| 15 |
-
details={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
except Exception as e:
|
| 18 |
return DomainHealth(name="rag", healthy=False, error=str(e))
|
|
@@ -22,7 +35,7 @@ health_mod.register_health_check("rag", _health_check)
|
|
| 22 |
|
| 23 |
|
| 24 |
# Public API
|
| 25 |
-
from app.rag.models import ( # noqa:
|
| 26 |
COLLECTIONS,
|
| 27 |
EmbeddingProvider,
|
| 28 |
FeedbackRecord,
|
|
@@ -32,7 +45,7 @@ from app.rag.models import ( # noqa: F401
|
|
| 32 |
SearchRequest,
|
| 33 |
SearchResponse,
|
| 34 |
)
|
| 35 |
-
from app.rag.service import RAGService, init_rag # noqa:
|
| 36 |
|
| 37 |
__all__ = [
|
| 38 |
"RAGService",
|
|
|
|
| 1 |
+
"""RAG domain — v3 M3 engine surface.
|
| 2 |
+
|
| 3 |
+
Public API (per v3 unfuck guide §M3):
|
| 4 |
+
- RAGService, init_rag
|
| 5 |
+
- SearchRequest, SearchResponse, SearchHit
|
| 6 |
+
- IngestRequest, IngestResult
|
| 7 |
+
- FeedbackRecord
|
| 8 |
+
- EmbeddingProvider, COLLECTIONS
|
| 9 |
+
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
from app.core import health as health_mod
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
async def _health_check() -> DomainHealth:
|
| 17 |
+
"""RAG health: v3 engine present + Redis reachable."""
|
| 18 |
try:
|
| 19 |
+
from app.rag.engine import get_stats
|
| 20 |
+
stats = get_stats()
|
| 21 |
return DomainHealth(
|
| 22 |
name="rag",
|
| 23 |
healthy=True,
|
| 24 |
+
details={
|
| 25 |
+
"engine": "v3-m3",
|
| 26 |
+
"backend": stats.get("backend", "unknown"),
|
| 27 |
+
"total_docs": stats.get("total_docs", 0),
|
| 28 |
+
},
|
| 29 |
)
|
| 30 |
except Exception as e:
|
| 31 |
return DomainHealth(name="rag", healthy=False, error=str(e))
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
# Public API
|
| 38 |
+
from app.rag.models import ( # noqa: E402
|
| 39 |
COLLECTIONS,
|
| 40 |
EmbeddingProvider,
|
| 41 |
FeedbackRecord,
|
|
|
|
| 45 |
SearchRequest,
|
| 46 |
SearchResponse,
|
| 47 |
)
|
| 48 |
+
from app.rag.service import RAGService, init_rag # noqa: E402
|
| 49 |
|
| 50 |
__all__ = [
|
| 51 |
"RAGService",
|
backend/app/rag/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (2.04 kB). View file
|
|
|
backend/app/rag/__pycache__/ann_index.cpython-311.pyc
ADDED
|
Binary file (12.1 kB). View file
|
|
|
backend/app/rag/__pycache__/chunking.cpython-311.pyc
ADDED
|
Binary file (7.54 kB). View file
|
|
|
backend/app/rag/__pycache__/embeddings.cpython-311.pyc
ADDED
|
Binary file (10.9 kB). View file
|
|
|
backend/app/rag/__pycache__/engine.cpython-311.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
backend/app/rag/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (5.03 kB). View file
|
|
|
backend/app/rag/__pycache__/service.cpython-311.pyc
ADDED
|
Binary file (9.47 kB). View file
|
|
|
backend/app/rag/ann_index.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M3 RAG ANN Index — numpy-based cosine similarity with Redis persistence.
|
| 2 |
+
|
| 3 |
+
Why numpy + Redis (not FAISS):
|
| 4 |
+
- 13 collections × ~5K docs = ~65K total — small enough for in-process numpy
|
| 5 |
+
- No FAISS native dep, no rebuild on container restart
|
| 6 |
+
- Vector + metadata stored as JSON in Redis; loaded into a numpy matrix
|
| 7 |
+
on first search, then kept in process memory for fast repeated queries
|
| 8 |
+
- Cosine sim = dot product on L2-normalized vectors (we normalize on insert)
|
| 9 |
+
|
| 10 |
+
Persistence shape (per collection):
|
| 11 |
+
rag:ann:{collection}:meta → JSON {count, dim}
|
| 12 |
+
rag:ann:{collection}:docs → HASH doc_id → {"vector": [...], "metadata": {...}, "text": "..."}
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from app.rag.embeddings import EMBEDDING_DIM
|
| 24 |
+
|
| 25 |
+
log = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
# Lazy import — Redis client is created on first use, after app.core.redis is ready
|
| 28 |
+
_redis_client = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_redis():
|
| 32 |
+
global _redis_client
|
| 33 |
+
if _redis_client is None:
|
| 34 |
+
from app.core.redis import get_redis
|
| 35 |
+
|
| 36 |
+
_redis_client = get_redis()
|
| 37 |
+
return _redis_client
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class Hit:
|
| 42 |
+
"""One search result."""
|
| 43 |
+
|
| 44 |
+
doc_id: str
|
| 45 |
+
score: float
|
| 46 |
+
text: str
|
| 47 |
+
metadata: dict[str, Any]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ANNIndex:
|
| 51 |
+
"""Per-collection vector index. Holds vectors in memory, persists to Redis.
|
| 52 |
+
|
| 53 |
+
Usage:
|
| 54 |
+
idx = ANNIndex("scam_intel")
|
| 55 |
+
idx.add("doc1", vector, {"source": "x"}, text="...")
|
| 56 |
+
hits = idx.search(query_vec, top_k=5)
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
def __init__(self, collection: str, dim: int = EMBEDDING_DIM):
|
| 60 |
+
self.collection = collection
|
| 61 |
+
self.dim = dim
|
| 62 |
+
self._ids: list[str] = []
|
| 63 |
+
self._matrix: np.ndarray | None = None # shape (N, dim), L2-normalized
|
| 64 |
+
self._meta: dict[str, dict] = {} # doc_id → {text, metadata}
|
| 65 |
+
self._loaded = False
|
| 66 |
+
|
| 67 |
+
# ── Redis keys ────────────────────────────────────────────────────
|
| 68 |
+
def _key_docs(self) -> str:
|
| 69 |
+
return f"rag:ann:{self.collection}:docs"
|
| 70 |
+
|
| 71 |
+
def _key_meta(self) -> str:
|
| 72 |
+
return f"rag:ann:{self.collection}:meta"
|
| 73 |
+
|
| 74 |
+
# ── Persistence ───────────────────────────────────────────────────
|
| 75 |
+
def _persist(self) -> None:
|
| 76 |
+
r = _get_redis()
|
| 77 |
+
pipe = r.pipeline()
|
| 78 |
+
for doc_id in self._ids:
|
| 79 |
+
entry = {
|
| 80 |
+
"vector": (self._matrix[self._ids.index(doc_id)] if self._matrix is not None else []).tolist(),
|
| 81 |
+
"metadata": self._meta[doc_id].get("metadata", {}),
|
| 82 |
+
"text": self._meta[doc_id].get("text", ""),
|
| 83 |
+
}
|
| 84 |
+
pipe.hset(self._key_docs(), doc_id, json.dumps(entry))
|
| 85 |
+
pipe.set(
|
| 86 |
+
self._key_meta(),
|
| 87 |
+
json.dumps({"count": len(self._ids), "dim": self.dim}),
|
| 88 |
+
)
|
| 89 |
+
pipe.execute()
|
| 90 |
+
|
| 91 |
+
def _load(self) -> None:
|
| 92 |
+
if self._loaded:
|
| 93 |
+
return
|
| 94 |
+
try:
|
| 95 |
+
r = _get_redis()
|
| 96 |
+
raw = r.hgetall(self._key_docs())
|
| 97 |
+
except Exception as e:
|
| 98 |
+
log.warning("ann_load_failed collection=%s err=%s", self.collection, e)
|
| 99 |
+
raw = {}
|
| 100 |
+
|
| 101 |
+
if not raw:
|
| 102 |
+
self._loaded = True
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
ids: list[str] = []
|
| 106 |
+
vecs: list[list[float]] = []
|
| 107 |
+
meta: dict[str, dict] = {}
|
| 108 |
+
for doc_id, blob in raw.items():
|
| 109 |
+
try:
|
| 110 |
+
entry = json.loads(blob)
|
| 111 |
+
ids.append(doc_id)
|
| 112 |
+
vecs.append(entry.get("vector", []))
|
| 113 |
+
meta[doc_id] = {
|
| 114 |
+
"metadata": entry.get("metadata", {}),
|
| 115 |
+
"text": entry.get("text", ""),
|
| 116 |
+
}
|
| 117 |
+
except Exception as e:
|
| 118 |
+
log.debug("ann_skip doc=%s err=%s", doc_id, e)
|
| 119 |
+
if vecs:
|
| 120 |
+
self._matrix = np.asarray(vecs, dtype=np.float32)
|
| 121 |
+
self._ids = ids
|
| 122 |
+
self._meta = meta
|
| 123 |
+
self._loaded = True
|
| 124 |
+
log.info("ann_loaded collection=%s count=%d", self.collection, len(ids))
|
| 125 |
+
|
| 126 |
+
# ── Mutations ─────────────────────────────────────────────────────
|
| 127 |
+
def add(
|
| 128 |
+
self,
|
| 129 |
+
doc_id: str,
|
| 130 |
+
vector: list[float],
|
| 131 |
+
metadata: dict[str, Any] | None = None,
|
| 132 |
+
text: str = "",
|
| 133 |
+
) -> None:
|
| 134 |
+
self._load()
|
| 135 |
+
arr = np.asarray(vector, dtype=np.float32)
|
| 136 |
+
if arr.shape[0] != self.dim:
|
| 137 |
+
# Pad or truncate
|
| 138 |
+
if arr.shape[0] >= self.dim:
|
| 139 |
+
arr = arr[: self.dim]
|
| 140 |
+
else:
|
| 141 |
+
arr = np.concatenate(
|
| 142 |
+
[arr, np.zeros(self.dim - arr.shape[0], dtype=np.float32)]
|
| 143 |
+
)
|
| 144 |
+
# L2 normalize
|
| 145 |
+
n = float(np.linalg.norm(arr))
|
| 146 |
+
if n > 0:
|
| 147 |
+
arr = arr / n
|
| 148 |
+
|
| 149 |
+
if doc_id in self._ids:
|
| 150 |
+
# Update — replace vector
|
| 151 |
+
idx = self._ids.index(doc_id)
|
| 152 |
+
self._matrix[idx] = arr
|
| 153 |
+
else:
|
| 154 |
+
if self._matrix is None:
|
| 155 |
+
self._matrix = arr.reshape(1, -1)
|
| 156 |
+
else:
|
| 157 |
+
self._matrix = np.vstack([self._matrix, arr.reshape(1, -1)])
|
| 158 |
+
self._ids.append(doc_id)
|
| 159 |
+
|
| 160 |
+
self._meta[doc_id] = {"metadata": metadata or {}, "text": text}
|
| 161 |
+
self._persist()
|
| 162 |
+
|
| 163 |
+
def delete(self, doc_id: str) -> bool:
|
| 164 |
+
self._load()
|
| 165 |
+
if doc_id not in self._ids:
|
| 166 |
+
return False
|
| 167 |
+
idx = self._ids.index(doc_id)
|
| 168 |
+
self._ids.pop(idx)
|
| 169 |
+
if self._matrix is not None and self._matrix.shape[0] > 1:
|
| 170 |
+
self._matrix = np.delete(self._matrix, idx, axis=0)
|
| 171 |
+
else:
|
| 172 |
+
self._matrix = None
|
| 173 |
+
self._meta.pop(doc_id, None)
|
| 174 |
+
self._persist()
|
| 175 |
+
return True
|
| 176 |
+
|
| 177 |
+
def clear(self) -> None:
|
| 178 |
+
self._ids = []
|
| 179 |
+
self._matrix = None
|
| 180 |
+
self._meta = {}
|
| 181 |
+
self._loaded = True
|
| 182 |
+
try:
|
| 183 |
+
r = _get_redis()
|
| 184 |
+
r.delete(self._key_docs(), self._key_meta())
|
| 185 |
+
except Exception as e:
|
| 186 |
+
log.warning("ann_clear_failed: %s", e)
|
| 187 |
+
|
| 188 |
+
# ── Search ────────────────────────────────────────────────────────
|
| 189 |
+
def search(
|
| 190 |
+
self,
|
| 191 |
+
query_vector: list[float],
|
| 192 |
+
top_k: int = 5,
|
| 193 |
+
min_similarity: float = 0.0,
|
| 194 |
+
) -> list[Hit]:
|
| 195 |
+
self._load()
|
| 196 |
+
if self._matrix is None or len(self._ids) == 0:
|
| 197 |
+
return []
|
| 198 |
+
q = np.asarray(query_vector, dtype=np.float32)
|
| 199 |
+
n = float(np.linalg.norm(q))
|
| 200 |
+
if n > 0:
|
| 201 |
+
q = q / n
|
| 202 |
+
# Cosine sim = dot product on normalized vectors
|
| 203 |
+
scores = self._matrix @ q
|
| 204 |
+
# Top-k by score
|
| 205 |
+
k = min(top_k, len(self._ids))
|
| 206 |
+
# argpartition is faster than full argsort for small top_k
|
| 207 |
+
idx = np.argpartition(-scores, k - 1)[:k] if k < len(scores) else np.arange(len(scores))
|
| 208 |
+
idx = idx[np.argsort(-scores[idx])]
|
| 209 |
+
hits: list[Hit] = []
|
| 210 |
+
for i in idx:
|
| 211 |
+
s = float(scores[i])
|
| 212 |
+
if s < min_similarity:
|
| 213 |
+
continue
|
| 214 |
+
doc_id = self._ids[int(i)]
|
| 215 |
+
meta = self._meta.get(doc_id, {})
|
| 216 |
+
hits.append(
|
| 217 |
+
Hit(
|
| 218 |
+
doc_id=doc_id,
|
| 219 |
+
score=s,
|
| 220 |
+
text=meta.get("text", ""),
|
| 221 |
+
metadata=meta.get("metadata", {}),
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
return hits
|
| 225 |
+
|
| 226 |
+
# ── Stats ─────────────────────────────────────────────────────────
|
| 227 |
+
def count(self) -> int:
|
| 228 |
+
self._load()
|
| 229 |
+
return len(self._ids)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ── Per-collection cache ─────────────────────────────────────────────
|
| 233 |
+
_index_cache: dict[str, ANNIndex] = {}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def get_index(collection: str) -> ANNIndex:
|
| 237 |
+
"""Get or create the ANNIndex for a collection. Cached per process."""
|
| 238 |
+
if collection not in _index_cache:
|
| 239 |
+
_index_cache[collection] = ANNIndex(collection)
|
| 240 |
+
return _index_cache[collection]
|
backend/app/rag/chunking.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M3 RAG Chunking — recursive text chunking with MD5 dedup.
|
| 2 |
+
|
| 3 |
+
Why chunking matters:
|
| 4 |
+
- Embedding models have context limits (bge-m3 = 8192 tokens, but we
|
| 5 |
+
chunk to 512 for granularity and to keep individual FAISS hits useful)
|
| 6 |
+
- Smaller chunks = better retrieval precision (less noise per result)
|
| 7 |
+
- Dedup via content MD5 prevents the same fact being ingested N times
|
| 8 |
+
|
| 9 |
+
Strategy (per 2026 RAG standards):
|
| 10 |
+
- Recursive character split, paragraph → sentence → word boundaries
|
| 11 |
+
- Overlap window preserves cross-chunk context
|
| 12 |
+
- Quality score: (length / max_chunk) × (1 - special_char_ratio)
|
| 13 |
+
- Skip chunks < min_chars (likely noise)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import logging
|
| 19 |
+
import re
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
DEFAULT_MAX_CHUNK = 512
|
| 25 |
+
DEFAULT_OVERLAP = 64
|
| 26 |
+
DEFAULT_MIN_CHARS = 32
|
| 27 |
+
|
| 28 |
+
_redis = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_redis():
|
| 32 |
+
global _redis
|
| 33 |
+
if _redis is None:
|
| 34 |
+
from app.core.redis import get_redis
|
| 35 |
+
_redis = get_redis()
|
| 36 |
+
return _redis
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class Chunk:
|
| 41 |
+
"""A piece of text ready to embed."""
|
| 42 |
+
|
| 43 |
+
text: str
|
| 44 |
+
content_hash: str
|
| 45 |
+
index: int
|
| 46 |
+
quality: float = 1.0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def content_hash(text: str) -> str:
|
| 50 |
+
"""Stable MD5 of normalized text. Used for dedup."""
|
| 51 |
+
norm = re.sub(r"\s+", " ", text.strip().lower())
|
| 52 |
+
return hashlib.md5(norm.encode("utf-8", errors="ignore")).hexdigest()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def chunk_text(
|
| 56 |
+
text: str,
|
| 57 |
+
max_chunk: int = DEFAULT_MAX_CHUNK,
|
| 58 |
+
overlap: int = DEFAULT_OVERLAP,
|
| 59 |
+
min_chars: int = DEFAULT_MIN_CHARS,
|
| 60 |
+
) -> list[Chunk]:
|
| 61 |
+
"""Recursive character chunker.
|
| 62 |
+
|
| 63 |
+
Splits on paragraph breaks first, then sentence, then word. Each split
|
| 64 |
+
is greedy-packed into chunks of <= max_chunk chars, with `overlap` chars
|
| 65 |
+
carried forward to preserve context.
|
| 66 |
+
"""
|
| 67 |
+
if not text or not text.strip():
|
| 68 |
+
return []
|
| 69 |
+
|
| 70 |
+
text = text.strip()
|
| 71 |
+
if len(text) <= max_chunk:
|
| 72 |
+
h = content_hash(text)
|
| 73 |
+
return [Chunk(text=text, content_hash=h, index=0, quality=_quality(text, max_chunk))]
|
| 74 |
+
|
| 75 |
+
# Build sentence-level segments (split on . ! ? \n)
|
| 76 |
+
sentences = re.split(r"(?<=[.!?\n])\s+", text)
|
| 77 |
+
chunks: list[str] = []
|
| 78 |
+
cur = ""
|
| 79 |
+
for s in sentences:
|
| 80 |
+
s = s.strip()
|
| 81 |
+
if not s:
|
| 82 |
+
continue
|
| 83 |
+
# If a single sentence is too long, hard-split on words
|
| 84 |
+
if len(s) > max_chunk:
|
| 85 |
+
words = s.split()
|
| 86 |
+
sub = ""
|
| 87 |
+
for w in words:
|
| 88 |
+
if len(sub) + len(w) + 1 > max_chunk and sub:
|
| 89 |
+
chunks.append(sub)
|
| 90 |
+
sub = w
|
| 91 |
+
else:
|
| 92 |
+
sub = (sub + " " + w).strip()
|
| 93 |
+
if sub:
|
| 94 |
+
chunks.append(sub)
|
| 95 |
+
elif len(cur) + len(s) + 1 > max_chunk:
|
| 96 |
+
if cur:
|
| 97 |
+
chunks.append(cur)
|
| 98 |
+
cur = s
|
| 99 |
+
else:
|
| 100 |
+
cur = (cur + " " + s).strip()
|
| 101 |
+
|
| 102 |
+
if cur:
|
| 103 |
+
chunks.append(cur)
|
| 104 |
+
|
| 105 |
+
# Apply overlap: each chunk gets the last `overlap` chars of the prior chunk as prefix
|
| 106 |
+
if overlap > 0 and len(chunks) > 1:
|
| 107 |
+
overlapped: list[str] = []
|
| 108 |
+
for i, c in enumerate(chunks):
|
| 109 |
+
if i == 0:
|
| 110 |
+
overlapped.append(c)
|
| 111 |
+
else:
|
| 112 |
+
tail = chunks[i - 1][-overlap:]
|
| 113 |
+
overlapped.append(tail + " " + c)
|
| 114 |
+
chunks = overlapped
|
| 115 |
+
|
| 116 |
+
# Filter and produce Chunk objects
|
| 117 |
+
out: list[Chunk] = []
|
| 118 |
+
for i, c in enumerate(chunks):
|
| 119 |
+
if len(c) < min_chars:
|
| 120 |
+
continue
|
| 121 |
+
out.append(
|
| 122 |
+
Chunk(
|
| 123 |
+
text=c,
|
| 124 |
+
content_hash=content_hash(c),
|
| 125 |
+
index=i,
|
| 126 |
+
quality=_quality(c, max_chunk),
|
| 127 |
+
)
|
| 128 |
+
)
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _quality(text: str, max_chunk: int) -> float:
|
| 133 |
+
"""Quick quality score 0-1. Penalize noise (high special-char ratio)."""
|
| 134 |
+
if not text:
|
| 135 |
+
return 0.0
|
| 136 |
+
n = len(text)
|
| 137 |
+
special = sum(1 for c in text if not c.isalnum() and not c.isspace())
|
| 138 |
+
length_score = min(1.0, n / max_chunk)
|
| 139 |
+
special_score = max(0.0, 1.0 - (special / n))
|
| 140 |
+
return round(0.7 * length_score + 0.3 * special_score, 3)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ── Dedup via Redis ─────────────────────────────────────────────────
|
| 144 |
+
def is_duplicate(content_hash: str, collection: str) -> bool:
|
| 145 |
+
"""Has this exact content already been ingested into the collection?"""
|
| 146 |
+
try:
|
| 147 |
+
return bool(_get_redis().sismember(f"rag:hashes:{collection}", content_hash))
|
| 148 |
+
except Exception as e:
|
| 149 |
+
log.debug("dedup_check_failed: %s", e)
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def mark_ingested(content_hash: str, collection: str) -> None:
|
| 154 |
+
"""Record that this content hash is now in the collection."""
|
| 155 |
+
try:
|
| 156 |
+
_get_redis().sadd(f"rag:hashes:{collection}", content_hash)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
log.debug("dedup_mark_failed: %s", e)
|
backend/app/rag/embeddings.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M3 RAG Embeddings — multi-tier fallback chain.
|
| 2 |
+
|
| 3 |
+
Tier 1: Ollama bge-m3 (1024d, our own, zero cost) — best quality when reachable
|
| 4 |
+
Tier 2: OpenRouter NVIDIA Nemotron (2048d, free tier) — if API key set
|
| 5 |
+
Tier 3: Hash-based (1024d, deterministic) — always works, no network
|
| 6 |
+
|
| 7 |
+
Design (per DESIGN.md M3):
|
| 8 |
+
- Single async API: `await get_embedding(text) -> list[float]`
|
| 9 |
+
- BGE-M3 preferred when available; hash is acceptable degradation
|
| 10 |
+
- Embedding dim is reported via EMBEDDING_DIM; collections use same dim
|
| 11 |
+
- All embeddings are float lists (JSON-serializable) for Redis storage
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
from typing import Final
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
log = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Default dim matches bge-m3 (1024d). Hash is also 1024d for cross-backend
|
| 26 |
+
# compatibility. OpenRouter Nemotron is 2048d but we truncate/pad to 1024
|
| 27 |
+
# during a collection's lifecycle to avoid dim mixing.
|
| 28 |
+
EMBEDDING_DIM: Final = 1024
|
| 29 |
+
|
| 30 |
+
# Backends
|
| 31 |
+
_BACKEND = os.getenv("RAG_EMBED_BACKEND", "auto").lower()
|
| 32 |
+
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434").rstrip("/")
|
| 33 |
+
OLLAMA_MODEL = os.getenv("OLLAMA_EMBED_MODEL", "bge-m3")
|
| 34 |
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/embeddings"
|
| 35 |
+
OPENROUTER_MODEL = "nvidia/llama-nemotron-embed-vl-1b-v2:free"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _ollama_reachable() -> bool:
|
| 39 |
+
"""Quick probe: is Ollama listening? Cached per process via env."""
|
| 40 |
+
if os.getenv("_OLLAMA_PROBED"):
|
| 41 |
+
return os.getenv("_OLLAMA_OK") == "1"
|
| 42 |
+
try:
|
| 43 |
+
with httpx.Client(timeout=2.0) as c:
|
| 44 |
+
r = c.get(f"{OLLAMA_URL}/api/tags")
|
| 45 |
+
ok = r.status_code == 200
|
| 46 |
+
except Exception:
|
| 47 |
+
ok = False
|
| 48 |
+
os.environ["_OLLAMA_PROBED"] = "1"
|
| 49 |
+
os.environ["_OLLAMA_OK"] = "1" if ok else "0"
|
| 50 |
+
if not ok:
|
| 51 |
+
log.info("rag_ollama_unreachable url=%s", OLLAMA_URL)
|
| 52 |
+
return ok
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _hash_embed(text: str, dim: int = EMBEDDING_DIM) -> list[float]:
|
| 56 |
+
"""Deterministic hash-based 1024d embedding.
|
| 57 |
+
|
| 58 |
+
NOT semantic — purely positional. Used as last-resort fallback so the
|
| 59 |
+
RAG system stays functional when all neural backends are unreachable.
|
| 60 |
+
Quality is poor for similarity (cosine sim typically 0.1-0.3) but the
|
| 61 |
+
API contract holds: same text → same vector every time.
|
| 62 |
+
"""
|
| 63 |
+
# SHA-512 gives 64 bytes; we expand to dim via repeated hashing.
|
| 64 |
+
chunks: list[float] = []
|
| 65 |
+
seed = text.encode("utf-8", errors="ignore")
|
| 66 |
+
while len(chunks) < dim:
|
| 67 |
+
seed = hashlib.sha512(seed).digest() + seed
|
| 68 |
+
# Each byte → float in [-1, 1]
|
| 69 |
+
for byte in seed[: min(64, dim - len(chunks))]:
|
| 70 |
+
chunks.append((byte / 127.5) - 1.0)
|
| 71 |
+
# L2 normalize for cosine compatibility
|
| 72 |
+
arr = np.asarray(chunks[:dim], dtype=np.float32)
|
| 73 |
+
n = float(np.linalg.norm(arr))
|
| 74 |
+
if n > 0:
|
| 75 |
+
arr = arr / n
|
| 76 |
+
return arr.tolist()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _ollama_embed(text: str, dim: int = EMBEDDING_DIM) -> list[float] | None:
|
| 80 |
+
"""Try Ollama bge-m3. Returns None on any failure (caller falls back)."""
|
| 81 |
+
try:
|
| 82 |
+
with httpx.Client(timeout=10.0) as c:
|
| 83 |
+
r = c.post(
|
| 84 |
+
f"{OLLAMA_URL}/api/embeddings",
|
| 85 |
+
json={"model": OLLAMA_MODEL, "prompt": text[:8000]},
|
| 86 |
+
)
|
| 87 |
+
if r.status_code != 200:
|
| 88 |
+
return None
|
| 89 |
+
vec = r.json().get("embedding")
|
| 90 |
+
if not vec or not isinstance(vec, list):
|
| 91 |
+
return None
|
| 92 |
+
arr = np.asarray(vec, dtype=np.float32)
|
| 93 |
+
# Resize to EMBEDDING_DIM (pad with 0 or truncate)
|
| 94 |
+
if arr.shape[0] >= dim:
|
| 95 |
+
arr = arr[:dim]
|
| 96 |
+
else:
|
| 97 |
+
arr = np.concatenate([arr, np.zeros(dim - arr.shape[0], dtype=np.float32)])
|
| 98 |
+
n = float(np.linalg.norm(arr))
|
| 99 |
+
if n > 0:
|
| 100 |
+
arr = arr / n
|
| 101 |
+
return arr.tolist()
|
| 102 |
+
except Exception as e:
|
| 103 |
+
log.debug("ollama_embed_failed: %s", e)
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _openrouter_embed(text: str, dim: int = EMBEDDING_DIM) -> list[float] | None:
|
| 108 |
+
"""Try OpenRouter Nemotron. Returns None on any failure."""
|
| 109 |
+
key = os.getenv("OPENROUTER_API_KEY")
|
| 110 |
+
if not key:
|
| 111 |
+
return None
|
| 112 |
+
try:
|
| 113 |
+
with httpx.Client(timeout=15.0) as c:
|
| 114 |
+
r = c.post(
|
| 115 |
+
OPENROUTER_URL,
|
| 116 |
+
headers={"Authorization": f"Bearer {key}"},
|
| 117 |
+
json={"model": OPENROUTER_MODEL, "input": text[:8000]},
|
| 118 |
+
)
|
| 119 |
+
if r.status_code != 200:
|
| 120 |
+
return None
|
| 121 |
+
data = r.json().get("data", [])
|
| 122 |
+
if not data:
|
| 123 |
+
return None
|
| 124 |
+
vec = data[0].get("embedding")
|
| 125 |
+
if not vec:
|
| 126 |
+
return None
|
| 127 |
+
arr = np.asarray(vec, dtype=np.float32)
|
| 128 |
+
if arr.shape[0] >= dim:
|
| 129 |
+
arr = arr[:dim]
|
| 130 |
+
else:
|
| 131 |
+
arr = np.concatenate([arr, np.zeros(dim - arr.shape[0], dtype=np.float32)])
|
| 132 |
+
n = float(np.linalg.norm(arr))
|
| 133 |
+
if n > 0:
|
| 134 |
+
arr = arr / n
|
| 135 |
+
return arr.tolist()
|
| 136 |
+
except Exception as e:
|
| 137 |
+
log.debug("openrouter_embed_failed: %s", e)
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def get_embedding(text: str) -> list[float]:
|
| 142 |
+
"""Async embedding API. Always returns a 1024d float list.
|
| 143 |
+
|
| 144 |
+
Tries in order: Ollama → OpenRouter → Hash. Logs which tier served.
|
| 145 |
+
"""
|
| 146 |
+
if not text or not text.strip():
|
| 147 |
+
return _hash_embed("", EMBEDDING_DIM)
|
| 148 |
+
|
| 149 |
+
if _BACKEND == "hash":
|
| 150 |
+
return _hash_embed(text)
|
| 151 |
+
if _BACKEND == "ollama":
|
| 152 |
+
v = _ollama_embed(text)
|
| 153 |
+
return v if v is not None else _hash_embed(text)
|
| 154 |
+
if _BACKEND == "openrouter":
|
| 155 |
+
v = _openrouter_embed(text)
|
| 156 |
+
return v if v is not None else _hash_embed(text)
|
| 157 |
+
|
| 158 |
+
# auto: try ollama → openrouter → hash
|
| 159 |
+
if _ollama_reachable():
|
| 160 |
+
v = _ollama_embed(text)
|
| 161 |
+
if v is not None:
|
| 162 |
+
return v
|
| 163 |
+
v = _openrouter_embed(text)
|
| 164 |
+
if v is not None:
|
| 165 |
+
return v
|
| 166 |
+
return _hash_embed(text)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
async def get_embeddings(texts: list[str]) -> list[list[float]]:
|
| 170 |
+
"""Batch embedding. Sequential calls — Ollama and OpenRouter handle
|
| 171 |
+
larger requests poorly and we'd rather keep memory bounded."""
|
| 172 |
+
return [await get_embedding(t) for t in texts]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def current_backend() -> str:
|
| 176 |
+
"""Which tier is currently active? For observability."""
|
| 177 |
+
if _BACKEND != "auto":
|
| 178 |
+
return _BACKEND
|
| 179 |
+
if _ollama_reachable():
|
| 180 |
+
return "ollama"
|
| 181 |
+
if os.getenv("OPENROUTER_API_KEY"):
|
| 182 |
+
return "openrouter"
|
| 183 |
+
return "hash"
|
backend/app/rag/engine.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M3 RAG Engine — three-pillar search + ingest.
|
| 2 |
+
|
| 3 |
+
The missing module that app/rag/service.py was importing (the legacy
|
| 4 |
+
app.rag_engine was nuked during consolidation but never replaced).
|
| 5 |
+
|
| 6 |
+
Three-Pillar Search (per 2026 RAG standards):
|
| 7 |
+
Pillar 1: ANN vector similarity (semantic match)
|
| 8 |
+
Pillar 2: BM25-lite keyword search (lexical match)
|
| 9 |
+
Pillar 3: Metadata filter (structured constraints)
|
| 10 |
+
Fusion: Reciprocal Rank Fusion (RRF) — k=60
|
| 11 |
+
|
| 12 |
+
Ingest:
|
| 13 |
+
- Chunk → embed → store in ANN index + Redis doc store
|
| 14 |
+
- MD5 dedup per collection
|
| 15 |
+
- Per-chunk doc_id = "{collection}:{hash}:{idx}"
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import logging
|
| 21 |
+
import time
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from app.rag.ann_index import Hit, get_index
|
| 25 |
+
from app.rag.chunking import chunk_text, content_hash, is_duplicate, mark_ingested
|
| 26 |
+
from app.rag.embeddings import current_backend, get_embedding
|
| 27 |
+
|
| 28 |
+
log = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
# RRF constant
|
| 31 |
+
_RRF_K = 60
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ── Three-pillar search ──────────────────────────────────────────────
|
| 35 |
+
async def three_pillar_search(
|
| 36 |
+
query: str,
|
| 37 |
+
collection: str = "scam_intel",
|
| 38 |
+
top_k: int = 5,
|
| 39 |
+
min_similarity: float = 0.0,
|
| 40 |
+
filters: dict[str, Any] | None = None,
|
| 41 |
+
) -> list[dict]:
|
| 42 |
+
"""Run the three-pillar search and return RRF-fused hits.
|
| 43 |
+
|
| 44 |
+
Each returned hit: {doc_id, score, text, metadata, source_pillars}
|
| 45 |
+
"""
|
| 46 |
+
start = time.monotonic()
|
| 47 |
+
|
| 48 |
+
# Pillar 1: ANN vector search
|
| 49 |
+
qvec = await get_embedding(query)
|
| 50 |
+
idx = get_index(collection)
|
| 51 |
+
ann_hits = idx.search(qvec, top_k=top_k * 3, min_similarity=min_similarity)
|
| 52 |
+
|
| 53 |
+
# Pillar 2: keyword (BM25-lite via Redis text scan on stored docs)
|
| 54 |
+
keyword_hits = await _keyword_search(query, collection, top_k * 3)
|
| 55 |
+
|
| 56 |
+
# Pillar 3: metadata filter (apply to ann+keyword results)
|
| 57 |
+
filtered = _apply_filters(ann_hits + keyword_hits, filters or {})
|
| 58 |
+
|
| 59 |
+
# RRF fusion
|
| 60 |
+
fused = _reciprocal_rank_fusion([ann_hits, keyword_hits], top_k=top_k)
|
| 61 |
+
|
| 62 |
+
# Apply filters post-fusion
|
| 63 |
+
if filters:
|
| 64 |
+
fused = [h for h in fused if _matches_filters(h, filters)]
|
| 65 |
+
|
| 66 |
+
took_ms = int((time.monotonic() - start) * 1000)
|
| 67 |
+
log.info(
|
| 68 |
+
"rag_search_done collection=%s ann=%d kw=%d fused=%d took_ms=%d backend=%s",
|
| 69 |
+
collection, len(ann_hits), len(keyword_hits), len(fused), took_ms, current_backend(),
|
| 70 |
+
)
|
| 71 |
+
return [
|
| 72 |
+
{
|
| 73 |
+
"doc_id": h.doc_id,
|
| 74 |
+
"score": h.score,
|
| 75 |
+
"text": h.text,
|
| 76 |
+
"metadata": h.metadata,
|
| 77 |
+
}
|
| 78 |
+
for h in fused
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def _keyword_search(
|
| 83 |
+
query: str, collection: str, limit: int
|
| 84 |
+
) -> list[Hit]:
|
| 85 |
+
"""BM25-lite keyword search. Simple TF scoring on stored text.
|
| 86 |
+
|
| 87 |
+
Returns Hits with score in [0, 1] (normalized). We don't pretend this
|
| 88 |
+
is real BM25 — but it's good enough for a fallback that surfaces
|
| 89 |
+
lexically-matching docs the ANN might miss.
|
| 90 |
+
"""
|
| 91 |
+
try:
|
| 92 |
+
from app.core.redis import get_redis
|
| 93 |
+
|
| 94 |
+
r = get_redis()
|
| 95 |
+
# Pull all doc texts for this collection's ANN store
|
| 96 |
+
raw = r.hgetall(f"rag:ann:{collection}:docs")
|
| 97 |
+
except Exception as e:
|
| 98 |
+
log.debug("keyword_search_redis_failed: %s", e)
|
| 99 |
+
return []
|
| 100 |
+
|
| 101 |
+
if not raw:
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
import json
|
| 105 |
+
terms = [t.lower() for t in query.split() if len(t) > 2]
|
| 106 |
+
if not terms:
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
+
scored: list[tuple[float, str, str, dict]] = []
|
| 110 |
+
for doc_id, blob in raw.items():
|
| 111 |
+
try:
|
| 112 |
+
entry = json.loads(blob)
|
| 113 |
+
except Exception:
|
| 114 |
+
continue
|
| 115 |
+
text = entry.get("text", "")
|
| 116 |
+
if not text:
|
| 117 |
+
continue
|
| 118 |
+
text_l = text.lower()
|
| 119 |
+
# Simple TF
|
| 120 |
+
tf = sum(text_l.count(t) for t in terms)
|
| 121 |
+
if tf == 0:
|
| 122 |
+
continue
|
| 123 |
+
# Normalize by length
|
| 124 |
+
score = tf / max(10, len(text_l.split()))
|
| 125 |
+
scored.append((score, doc_id, text, entry.get("metadata", {})))
|
| 126 |
+
|
| 127 |
+
scored.sort(key=lambda x: -x[0])
|
| 128 |
+
out: list[Hit] = []
|
| 129 |
+
max_score = scored[0][0] if scored else 1.0
|
| 130 |
+
for score, doc_id, text, metadata in scored[:limit]:
|
| 131 |
+
out.append(
|
| 132 |
+
Hit(
|
| 133 |
+
doc_id=doc_id,
|
| 134 |
+
score=min(1.0, score / max_score) if max_score > 0 else 0.0,
|
| 135 |
+
text=text,
|
| 136 |
+
metadata=metadata,
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
return out
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _apply_filters(hits: list[Hit], filters: dict[str, Any]) -> list[Hit]:
|
| 143 |
+
"""Pre-filter: keep hits whose metadata matches all filter key=val pairs."""
|
| 144 |
+
if not filters:
|
| 145 |
+
return hits
|
| 146 |
+
return [h for h in hits if _matches_filters(h, filters)]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _matches_filters(hit: Hit, filters: dict[str, Any]) -> bool:
|
| 150 |
+
for k, v in filters.items():
|
| 151 |
+
if hit.metadata.get(k) != v:
|
| 152 |
+
return False
|
| 153 |
+
return True
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _reciprocal_rank_fusion(
|
| 157 |
+
pillar_hits: list[list[Hit]], top_k: int, k: int = _RRF_K
|
| 158 |
+
) -> list[Hit]:
|
| 159 |
+
"""Reciprocal Rank Fusion across multiple ranked lists.
|
| 160 |
+
|
| 161 |
+
RRF score(d) = sum( 1 / (k + rank_i(d)) ) for each pillar that contains d.
|
| 162 |
+
"""
|
| 163 |
+
scores: dict[str, float] = {}
|
| 164 |
+
by_id: dict[str, Hit] = {}
|
| 165 |
+
for pillar in pillar_hits:
|
| 166 |
+
for rank, h in enumerate(pillar, start=1):
|
| 167 |
+
scores[h.doc_id] = scores.get(h.doc_id, 0.0) + 1.0 / (k + rank)
|
| 168 |
+
if h.doc_id not in by_id:
|
| 169 |
+
by_id[h.doc_id] = h
|
| 170 |
+
|
| 171 |
+
ranked = sorted(scores.items(), key=lambda x: -x[1])
|
| 172 |
+
out: list[Hit] = []
|
| 173 |
+
for doc_id, rrf_score in ranked[:top_k]:
|
| 174 |
+
h = by_id[doc_id]
|
| 175 |
+
# Normalize to [0, 1] roughly — RRF max is ~3/k for 3 pillars
|
| 176 |
+
norm = min(1.0, rrf_score * k / 3.0)
|
| 177 |
+
out.append(
|
| 178 |
+
Hit(
|
| 179 |
+
doc_id=h.doc_id,
|
| 180 |
+
score=norm,
|
| 181 |
+
text=h.text,
|
| 182 |
+
metadata=h.metadata,
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
return out
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ── Ingest ───────────────────────────────────────────────────────────
|
| 189 |
+
async def ingest_document(
|
| 190 |
+
collection: str,
|
| 191 |
+
doc_id: str,
|
| 192 |
+
content: str,
|
| 193 |
+
metadata: dict[str, Any] | None = None,
|
| 194 |
+
chunk: bool = True,
|
| 195 |
+
) -> dict:
|
| 196 |
+
"""Ingest a document into the RAG system.
|
| 197 |
+
|
| 198 |
+
- Chunks the content (recursive split, dedup)
|
| 199 |
+
- Embeds each chunk
|
| 200 |
+
- Adds to the collection's ANN index
|
| 201 |
+
- Marks each chunk's hash as ingested for dedup
|
| 202 |
+
"""
|
| 203 |
+
if not content or not content.strip():
|
| 204 |
+
return {"doc_id": doc_id, "collection": collection, "status": "empty", "chunks": 0}
|
| 205 |
+
|
| 206 |
+
metadata = metadata or {}
|
| 207 |
+
idx = get_index(collection)
|
| 208 |
+
chunks = chunk_text(content) if chunk else [
|
| 209 |
+
# Single-chunk path: still dedup
|
| 210 |
+
__import__("app.rag.chunking", fromlist=["Chunk"]).Chunk(
|
| 211 |
+
text=content, content_hash=content_hash(content), index=0, quality=1.0
|
| 212 |
+
)
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
# Dedup
|
| 216 |
+
new_chunks = [c for c in chunks if not is_duplicate(c.content_hash, collection)]
|
| 217 |
+
skipped = len(chunks) - len(new_chunks)
|
| 218 |
+
if not new_chunks:
|
| 219 |
+
return {
|
| 220 |
+
"doc_id": doc_id,
|
| 221 |
+
"collection": collection,
|
| 222 |
+
"status": "duplicate",
|
| 223 |
+
"chunks": 0,
|
| 224 |
+
"skipped": skipped,
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
# Embed + insert
|
| 228 |
+
added = 0
|
| 229 |
+
for c in new_chunks:
|
| 230 |
+
vec = await get_embedding(c.text)
|
| 231 |
+
chunk_doc_id = f"{doc_id}:{c.content_hash[:8]}:{c.index}"
|
| 232 |
+
chunk_meta = {
|
| 233 |
+
**metadata,
|
| 234 |
+
"chunk_index": c.index,
|
| 235 |
+
"content_hash": c.content_hash,
|
| 236 |
+
"quality": c.quality,
|
| 237 |
+
}
|
| 238 |
+
idx.add(chunk_doc_id, vec, chunk_meta, text=c.text)
|
| 239 |
+
mark_ingested(c.content_hash, collection)
|
| 240 |
+
added += 1
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"doc_id": doc_id,
|
| 244 |
+
"collection": collection,
|
| 245 |
+
"status": "ok",
|
| 246 |
+
"chunks": added,
|
| 247 |
+
"skipped": skipped,
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ── Stats ────────────────────────────────────────────────────────────
|
| 252 |
+
def get_collection_stats(collection: str) -> dict:
|
| 253 |
+
"""Get stats for one collection: vector count + dedup hashes count."""
|
| 254 |
+
idx = get_index(collection)
|
| 255 |
+
count = idx.count()
|
| 256 |
+
try:
|
| 257 |
+
from app.core.redis import get_redis
|
| 258 |
+
|
| 259 |
+
hashes = get_redis().scard(f"rag:hashes:{collection}")
|
| 260 |
+
except Exception:
|
| 261 |
+
hashes = 0
|
| 262 |
+
return {
|
| 263 |
+
"collection": collection,
|
| 264 |
+
"vector_count": count,
|
| 265 |
+
"dedup_hashes": hashes,
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def get_stats(collections: list[str] | None = None) -> dict:
|
| 270 |
+
"""Get stats for all (or specified) collections + the active embedder backend."""
|
| 271 |
+
from app.rag.models import COLLECTIONS as DEFAULT_COLLECTIONS
|
| 272 |
+
|
| 273 |
+
cols = collections or DEFAULT_COLLECTIONS
|
| 274 |
+
return {
|
| 275 |
+
"total_docs": sum(get_collection_stats(c)["vector_count"] for c in cols),
|
| 276 |
+
"backend": current_backend(),
|
| 277 |
+
"collections": [get_collection_stats(c) for c in cols],
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ── Background helpers (used by ingest_cron worker) ──────────────────
|
| 282 |
+
async def bulk_ingest(
|
| 283 |
+
items: list[dict],
|
| 284 |
+
collection: str = "scam_intel",
|
| 285 |
+
) -> dict:
|
| 286 |
+
"""Ingest many items sequentially. Returns summary counts."""
|
| 287 |
+
if not items:
|
| 288 |
+
return {"total": 0, "ok": 0, "duplicate": 0, "empty": 0, "errors": 0}
|
| 289 |
+
|
| 290 |
+
counts = {"total": len(items), "ok": 0, "duplicate": 0, "empty": 0, "errors": 0}
|
| 291 |
+
for it in items:
|
| 292 |
+
try:
|
| 293 |
+
r = await ingest_document(
|
| 294 |
+
collection=collection,
|
| 295 |
+
doc_id=it.get("doc_id") or it.get("id", f"bulk:{int(time.time()*1000)}"),
|
| 296 |
+
content=it.get("content") or it.get("text", ""),
|
| 297 |
+
metadata=it.get("metadata") or {},
|
| 298 |
+
)
|
| 299 |
+
if r["status"] == "ok":
|
| 300 |
+
counts["ok"] += 1
|
| 301 |
+
elif r["status"] == "duplicate":
|
| 302 |
+
counts["duplicate"] += 1
|
| 303 |
+
elif r["status"] == "empty":
|
| 304 |
+
counts["empty"] += 1
|
| 305 |
+
except Exception as e:
|
| 306 |
+
log.warning("bulk_ingest_item_failed: %s", e)
|
| 307 |
+
counts["errors"] += 1
|
| 308 |
+
return counts
|
backend/app/rag/service.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
"""RAG service — facade over the
|
| 2 |
|
| 3 |
-
Provides a clean async Pydantic surface for search, ingest, feedback
|
| 4 |
-
|
| 5 |
-
in app.rag_engine and friends.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -11,6 +10,18 @@ import time
|
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
from app.core.logging import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from app.rag.models import (
|
| 15 |
FeedbackRecord,
|
| 16 |
IngestRequest,
|
|
@@ -24,7 +35,7 @@ log = get_logger(__name__)
|
|
| 24 |
|
| 25 |
|
| 26 |
class RAGService:
|
| 27 |
-
"""Async facade over the RAG
|
| 28 |
|
| 29 |
async def search(self, req: SearchRequest) -> SearchResponse:
|
| 30 |
"""Search the RAG system. Returns a Pydantic response."""
|
|
@@ -36,12 +47,12 @@ class RAGService:
|
|
| 36 |
)
|
| 37 |
start = time.monotonic()
|
| 38 |
try:
|
| 39 |
-
|
| 40 |
-
raw = await three_pillar_search(
|
| 41 |
query=req.query,
|
| 42 |
collection=req.collection,
|
| 43 |
top_k=req.top_k,
|
| 44 |
min_similarity=req.min_similarity,
|
|
|
|
| 45 |
)
|
| 46 |
except Exception as e:
|
| 47 |
log.warning("rag_search_failed", error=str(e))
|
|
@@ -64,9 +75,8 @@ class RAGService:
|
|
| 64 |
content_len=len(req.content),
|
| 65 |
)
|
| 66 |
try:
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
await ingest_document(
|
| 70 |
collection=req.collection,
|
| 71 |
doc_id=doc_id,
|
| 72 |
content=req.content,
|
|
@@ -75,8 +85,8 @@ class RAGService:
|
|
| 75 |
return IngestResult(
|
| 76 |
doc_id=doc_id,
|
| 77 |
collection=req.collection,
|
| 78 |
-
status="ok",
|
| 79 |
-
chunks=
|
| 80 |
)
|
| 81 |
except Exception as e:
|
| 82 |
log.warning("rag_ingest_failed", error=str(e))
|
|
@@ -87,6 +97,14 @@ class RAGService:
|
|
| 87 |
error=str(e),
|
| 88 |
)
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
async def record_feedback(self, record: FeedbackRecord) -> bool:
|
| 91 |
"""Record a scanner → RAG feedback (ingest a known scam)."""
|
| 92 |
log.info(
|
|
@@ -141,16 +159,17 @@ class RAGService:
|
|
| 141 |
|
| 142 |
|
| 143 |
async def init_rag() -> None:
|
| 144 |
-
"""Initialize the RAG system. Called from app.core.lifespan.
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
try:
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
result = _legacy_init()
|
| 152 |
-
if asyncio.iscoroutine(result):
|
| 153 |
-
await result
|
| 154 |
log.info("rag_init_complete")
|
| 155 |
except Exception as e:
|
| 156 |
-
log.warning("
|
|
|
|
| 1 |
+
"""RAG service — HTTP facade over the v3 RAG engine.
|
| 2 |
|
| 3 |
+
Provides a clean async Pydantic surface for search, ingest, feedback.
|
| 4 |
+
Delegates to the M3 engine in app.rag.engine (built per v3 unfuck plan).
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
from app.core.logging import get_logger
|
| 13 |
+
from app.rag.engine import (
|
| 14 |
+
bulk_ingest as engine_bulk_ingest,
|
| 15 |
+
)
|
| 16 |
+
from app.rag.engine import (
|
| 17 |
+
get_stats as engine_get_stats,
|
| 18 |
+
)
|
| 19 |
+
from app.rag.engine import (
|
| 20 |
+
ingest_document as engine_ingest_document,
|
| 21 |
+
)
|
| 22 |
+
from app.rag.engine import (
|
| 23 |
+
three_pillar_search as engine_three_pillar_search,
|
| 24 |
+
)
|
| 25 |
from app.rag.models import (
|
| 26 |
FeedbackRecord,
|
| 27 |
IngestRequest,
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class RAGService:
|
| 38 |
+
"""Async facade over the v3 RAG engine (M3 deliverable)."""
|
| 39 |
|
| 40 |
async def search(self, req: SearchRequest) -> SearchResponse:
|
| 41 |
"""Search the RAG system. Returns a Pydantic response."""
|
|
|
|
| 47 |
)
|
| 48 |
start = time.monotonic()
|
| 49 |
try:
|
| 50 |
+
raw = await engine_three_pillar_search(
|
|
|
|
| 51 |
query=req.query,
|
| 52 |
collection=req.collection,
|
| 53 |
top_k=req.top_k,
|
| 54 |
min_similarity=req.min_similarity,
|
| 55 |
+
filters=req.filters or None,
|
| 56 |
)
|
| 57 |
except Exception as e:
|
| 58 |
log.warning("rag_search_failed", error=str(e))
|
|
|
|
| 75 |
content_len=len(req.content),
|
| 76 |
)
|
| 77 |
try:
|
| 78 |
+
doc_id = req.doc_id or f"doc:{int(time.time()*1000)}"
|
| 79 |
+
r = await engine_ingest_document(
|
|
|
|
| 80 |
collection=req.collection,
|
| 81 |
doc_id=doc_id,
|
| 82 |
content=req.content,
|
|
|
|
| 85 |
return IngestResult(
|
| 86 |
doc_id=doc_id,
|
| 87 |
collection=req.collection,
|
| 88 |
+
status=r.get("status", "ok"),
|
| 89 |
+
chunks=r.get("chunks", 0),
|
| 90 |
)
|
| 91 |
except Exception as e:
|
| 92 |
log.warning("rag_ingest_failed", error=str(e))
|
|
|
|
| 97 |
error=str(e),
|
| 98 |
)
|
| 99 |
|
| 100 |
+
async def stats(self) -> dict:
|
| 101 |
+
"""Return engine stats (collection counts + active embedder backend)."""
|
| 102 |
+
try:
|
| 103 |
+
return engine_get_stats()
|
| 104 |
+
except Exception as e:
|
| 105 |
+
log.warning("rag_stats_failed", error=str(e))
|
| 106 |
+
return {"total_docs": 0, "backend": "unknown", "collections": [], "error": str(e)}
|
| 107 |
+
|
| 108 |
async def record_feedback(self, record: FeedbackRecord) -> bool:
|
| 109 |
"""Record a scanner → RAG feedback (ingest a known scam)."""
|
| 110 |
log.info(
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
async def init_rag() -> None:
|
| 162 |
+
"""Initialize the RAG system. Called from app.core.lifespan.
|
| 163 |
+
|
| 164 |
+
v3: no-op (the new engine is lazy — collections load on first search).
|
| 165 |
+
Kept for backward compat with lifespan hooks.
|
| 166 |
+
"""
|
| 167 |
+
log.info("rag_init_started", note="v3 engine is lazy — no warmup needed")
|
| 168 |
try:
|
| 169 |
+
# Touch the redis client to fail fast if misconfigured
|
| 170 |
+
from app.core.redis import get_redis
|
| 171 |
+
|
| 172 |
+
get_redis().ping()
|
|
|
|
|
|
|
|
|
|
| 173 |
log.info("rag_init_complete")
|
| 174 |
except Exception as e:
|
| 175 |
+
log.warning("rag_init_redis_probe_failed", error=str(e))
|