nikeshn commited on
Commit
1dd5f41
Β·
verified Β·
1 Parent(s): 16acb54

Update src/services/rag_service.py

Browse files
Files changed (1) hide show
  1. src/services/rag_service.py +218 -7
src/services/rag_service.py CHANGED
@@ -1,13 +1,224 @@
 
 
 
 
1
  from typing import List, Optional
 
 
 
 
 
 
 
 
2
 
3
  class RAGService:
4
  def __init__(self):
5
  self.vectorstore = None
6
-
 
 
 
 
 
7
  def is_ready(self) -> bool:
8
- return self.vectorstore is not None
9
-
10
- async def hybrid_search(self, query: str, top_k: int = 7) -> List:
11
- """Placeholder for hybrid search"""
12
- # Will implement FAISS + BM25 here
13
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import logging
4
+ from pathlib import Path
5
  from typing import List, Optional
6
+ import numpy as np
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ KNOWLEDGE_DIR = Path("/app/data/knowledge")
11
+ CHUNK_SIZE = 400 # tokens approx β€” ~1600 chars
12
+ CHUNK_OVERLAP = 80 # chars overlap between chunks
13
+
14
 
15
  class RAGService:
16
  def __init__(self):
17
  self.vectorstore = None
18
+ self.bm25 = None
19
+ self.bm25_corpus: List[str] = []
20
+ self.bm25_docs: List[dict] = [] # parallel to bm25_corpus
21
+ self._embeddings = None
22
+ self._ready = False
23
+
24
  def is_ready(self) -> bool:
25
+ return self._ready
26
+
27
+ # ── Startup ────────────────────────────────────────────────────────────
28
+
29
+ async def initialize(self, openai_api_key: str):
30
+ """Call once from lifespan. Loads files, chunks, vectorizes."""
31
+ t0 = time.time()
32
+ logger.info("RAGService: starting initialization...")
33
+
34
+ try:
35
+ from langchain_openai import OpenAIEmbeddings
36
+ from langchain_community.vectorstores import FAISS
37
+ from rank_bm25 import BM25Okapi
38
+
39
+ self._embeddings = OpenAIEmbeddings(
40
+ model="text-embedding-3-small", # cheaper + fast, plenty for KB this size
41
+ openai_api_key=openai_api_key
42
+ )
43
+
44
+ # 1. Load and chunk all .txt files
45
+ chunks, metadatas = self._load_and_chunk_all()
46
+ if not chunks:
47
+ logger.error("RAGService: no chunks loaded β€” check KNOWLEDGE_DIR path")
48
+ return
49
+
50
+ logger.info(f"RAGService: loaded {len(chunks)} chunks from {KNOWLEDGE_DIR}")
51
+
52
+ # 2. Build FAISS index
53
+ self.vectorstore = await FAISS.afrom_texts(
54
+ chunks,
55
+ self._embeddings,
56
+ metadatas=metadatas
57
+ )
58
+
59
+ # 3. Build BM25 index (parallel corpus)
60
+ self.bm25_corpus = chunks
61
+ self.bm25_docs = metadatas
62
+ tokenized = [c.lower().split() for c in chunks]
63
+ self.bm25 = BM25Okapi(tokenized)
64
+
65
+ self._ready = True
66
+ logger.info(
67
+ f"RAGService: ready β€” {len(chunks)} chunks indexed in {time.time()-t0:.1f}s"
68
+ )
69
+
70
+ except Exception as e:
71
+ logger.error(f"RAGService initialization failed: {e}", exc_info=True)
72
+
73
+ # ── Document Loading & Chunking ────────────────────────────────────────
74
+
75
+ def _load_and_chunk_all(self) -> tuple[List[str], List[dict]]:
76
+ """Load all .txt files and split into overlapping chunks."""
77
+ all_chunks = []
78
+ all_meta = []
79
+
80
+ if not KNOWLEDGE_DIR.exists():
81
+ logger.error(f"KNOWLEDGE_DIR not found: {KNOWLEDGE_DIR}")
82
+ return [], []
83
+
84
+ txt_files = sorted(KNOWLEDGE_DIR.glob("*.txt"))
85
+ logger.info(f"RAGService: found {len(txt_files)} .txt files")
86
+
87
+ for fpath in txt_files:
88
+ try:
89
+ text = fpath.read_text(encoding="utf-8").strip()
90
+ if not text:
91
+ continue
92
+
93
+ # Extract SOURCE and TITLE from header if present
94
+ source_url = ""
95
+ title = fpath.stem.replace("_", " ").replace("-", " ")
96
+ for line in text.splitlines()[:5]:
97
+ if line.startswith("SOURCE:"):
98
+ source_url = line.replace("SOURCE:", "").strip()
99
+ elif line.startswith("TITLE:"):
100
+ title = line.replace("TITLE:", "").strip()
101
+
102
+ chunks = self._chunk_text(text, fpath.name)
103
+ for chunk in chunks:
104
+ all_chunks.append(chunk)
105
+ all_meta.append({
106
+ "source": source_url,
107
+ "title": title,
108
+ "filename": fpath.name,
109
+ })
110
+
111
+ except Exception as e:
112
+ logger.warning(f"Failed to load {fpath.name}: {e}")
113
+
114
+ return all_chunks, all_meta
115
+
116
+ def _chunk_text(self, text: str, filename: str) -> List[str]:
117
+ """
118
+ Split on TOPIC: headers first (respects your file structure),
119
+ then hard-split oversized sections by character count.
120
+ """
121
+ chunks = []
122
+
123
+ # Split on TOPIC: section markers present in your files
124
+ import re
125
+ sections = re.split(r"\n(?=TOPIC:|[A-Z][A-Z\s/&]+:)", text)
126
+
127
+ for section in sections:
128
+ section = section.strip()
129
+ if not section:
130
+ continue
131
+
132
+ if len(section) <= CHUNK_SIZE * 4: # ~1600 chars fits in one chunk
133
+ chunks.append(section)
134
+ else:
135
+ # Hard split with overlap for large sections
136
+ chars = section
137
+ start = 0
138
+ while start < len(chars):
139
+ end = start + CHUNK_SIZE * 4
140
+ chunk = chars[start:end].strip()
141
+ if chunk:
142
+ chunks.append(chunk)
143
+ start += (CHUNK_SIZE * 4) - (CHUNK_OVERLAP * 4)
144
+
145
+ return chunks
146
+
147
+ # ── Retrieval ──────────────────────────────────────────────────────────
148
+
149
+ async def hybrid_search(
150
+ self,
151
+ query: str,
152
+ top_k: int = 5,
153
+ alpha: float = 0.6 # 0=pure BM25, 1=pure FAISS
154
+ ) -> List[dict]:
155
+ """
156
+ Reciprocal Rank Fusion of FAISS (dense) + BM25 (sparse).
157
+ Returns list of {content, source, title, filename, score}
158
+ """
159
+ if not self._ready:
160
+ logger.warning("hybrid_search called before RAGService is ready")
161
+ return []
162
+
163
+ try:
164
+ # Dense retrieval
165
+ dense_results = await self.vectorstore.asimilarity_search_with_score(
166
+ query, k=top_k * 3
167
+ )
168
+
169
+ # Sparse retrieval
170
+ tokenized_query = query.lower().split()
171
+ bm25_scores = self.bm25.get_scores(tokenized_query)
172
+ top_bm25_idx = np.argsort(bm25_scores)[::-1][:top_k * 3]
173
+
174
+ # Reciprocal Rank Fusion
175
+ rrf_scores: dict[int, float] = {}
176
+ K = 60 # RRF constant
177
+
178
+ # Score dense results
179
+ for rank, (doc, _) in enumerate(dense_results):
180
+ # Find corpus index by content match
181
+ idx = self._find_corpus_index(doc.page_content)
182
+ if idx >= 0:
183
+ rrf_scores[idx] = rrf_scores.get(idx, 0) + alpha * (1 / (K + rank + 1))
184
+
185
+ # Score BM25 results
186
+ for rank, idx in enumerate(top_bm25_idx):
187
+ rrf_scores[int(idx)] = rrf_scores.get(int(idx), 0) + (1 - alpha) * (1 / (K + rank + 1))
188
+
189
+ # Sort by RRF score and return top_k
190
+ sorted_idx = sorted(rrf_scores, key=lambda i: rrf_scores[i], reverse=True)[:top_k]
191
+
192
+ results = []
193
+ for idx in sorted_idx:
194
+ if idx < len(self.bm25_corpus):
195
+ meta = self.bm25_docs[idx]
196
+ results.append({
197
+ "content": self.bm25_corpus[idx],
198
+ "source": meta.get("source", ""),
199
+ "title": meta.get("title", ""),
200
+ "filename": meta.get("filename", ""),
201
+ "score": rrf_scores[idx],
202
+ })
203
+
204
+ return results
205
+
206
+ except Exception as e:
207
+ logger.error(f"hybrid_search error: {e}", exc_info=True)
208
+ return []
209
+
210
+ def _find_corpus_index(self, content: str) -> int:
211
+ """Find index in bm25_corpus by content match."""
212
+ content_strip = content.strip()
213
+ for i, c in enumerate(self.bm25_corpus):
214
+ if c.strip() == content_strip:
215
+ return i
216
+ return -1
217
+
218
+ def get_stats(self) -> dict:
219
+ return {
220
+ "ready": self._ready,
221
+ "chunk_count": len(self.bm25_corpus),
222
+ "vectorstore_loaded": self.vectorstore is not None,
223
+ "bm25_loaded": self.bm25 is not None,
224
+ }