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 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 — auto-registers its health check."""
 
 
 
 
 
 
 
 
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: legacy rag_engine + vector store available."""
10
  try:
11
- from app.rag_engine import search_similar
 
12
  return DomainHealth(
13
  name="rag",
14
  healthy=True,
15
- details={"engine": "legacy", "module": "app.rag_engine"},
 
 
 
 
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: F401
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: F401
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 14 legacy RAG modules.
2
 
3
- Provides a clean async Pydantic surface for search, ingest, feedback,
4
- firehose, and embeddings. Delegates to the proven implementations
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 system."""
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
- from app.rag_engine import three_pillar_search
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
- from app.rag_engine import ingest_document
68
- doc_id = req.doc_id or f"doc:{int(time.time())}"
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=1,
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
- log.info("rag_init_started")
 
 
 
 
146
  try:
147
- from app.rag_engine import init_rag as _legacy_init
148
- if asyncio.iscoroutinefunction(_legacy_init):
149
- await _legacy_init()
150
- else:
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("rag_init_failed", error=str(e))
 
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))