nikeshn commited on
Commit
3bbd8be
Β·
verified Β·
1 Parent(s): c8e1f58

Update src/services/rag_service.py

Browse files
Files changed (1) hide show
  1. src/services/rag_service.py +71 -57
src/services/rag_service.py CHANGED
@@ -1,4 +1,4 @@
1
- import os
2
  import time
3
  import logging
4
  from pathlib import Path
@@ -8,8 +8,8 @@ import numpy as np
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:
@@ -17,7 +17,7 @@ class RAGService:
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
 
@@ -27,7 +27,11 @@ class RAGService:
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
 
@@ -37,34 +41,38 @@ class RAGService:
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:
@@ -73,9 +81,8 @@ class RAGService:
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}")
@@ -90,17 +97,18 @@ class RAGService:
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,
@@ -108,39 +116,45 @@ class RAGService:
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
 
@@ -150,7 +164,7 @@ class RAGService:
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).
@@ -161,44 +175,43 @@ class RAGService:
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
@@ -208,7 +221,7 @@ class RAGService:
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:
@@ -219,6 +232,7 @@ class RAGService:
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
- }
 
1
+ import re
2
  import time
3
  import logging
4
  from pathlib import Path
 
8
  logger = logging.getLogger(__name__)
9
 
10
  KNOWLEDGE_DIR = Path("/app/data/knowledge")
11
+ CHUNK_MAX_CHARS = 1600 # ~400 tokens β€” fits well in context without over-chunking
12
+ CHUNK_OVERLAP_CHARS = 200
13
 
14
 
15
  class RAGService:
 
17
  self.vectorstore = None
18
  self.bm25 = None
19
  self.bm25_corpus: List[str] = []
20
+ self.bm25_meta: List[dict] = []
21
  self._embeddings = None
22
  self._ready = False
23
 
 
27
  # ── Startup ────────────────────────────────────────────────────────────
28
 
29
  async def initialize(self, openai_api_key: str):
30
+ """
31
+ Called once from app.py lifespan at startup.
32
+ Loads all .txt files from KNOWLEDGE_DIR, chunks them,
33
+ builds FAISS (dense) + BM25 (sparse) indexes.
34
+ """
35
  t0 = time.time()
36
  logger.info("RAGService: starting initialization...")
37
 
 
41
  from rank_bm25 import BM25Okapi
42
 
43
  self._embeddings = OpenAIEmbeddings(
44
+ model="text-embedding-3-small",
45
+ openai_api_key=openai_api_key,
46
  )
47
 
48
+ # 1. Load and chunk
49
  chunks, metadatas = self._load_and_chunk_all()
50
  if not chunks:
51
+ logger.error(
52
+ "RAGService: no chunks loaded. "
53
+ f"Check that {KNOWLEDGE_DIR} exists and contains .txt files."
54
+ )
55
  return
56
 
57
+ logger.info(f"RAGService: {len(chunks)} chunks from {KNOWLEDGE_DIR}")
58
 
59
+ # 2. FAISS index (calls OpenAI embeddings API)
60
  self.vectorstore = await FAISS.afrom_texts(
61
  chunks,
62
  self._embeddings,
63
+ metadatas=metadatas,
64
  )
65
 
66
+ # 3. BM25 index (in-memory, instant)
67
  self.bm25_corpus = chunks
68
+ self.bm25_meta = metadatas
69
  tokenized = [c.lower().split() for c in chunks]
70
  self.bm25 = BM25Okapi(tokenized)
71
 
72
  self._ready = True
73
+ elapsed = time.time() - t0
74
  logger.info(
75
+ f"RAGService: βœ… ready β€” {len(chunks)} chunks indexed in {elapsed:.1f}s"
76
  )
77
 
78
  except Exception as e:
 
81
  # ── Document Loading & Chunking ────────────────────────────────────────
82
 
83
  def _load_and_chunk_all(self) -> tuple[List[str], List[dict]]:
84
+ all_chunks: List[str] = []
85
+ all_meta: List[dict] = []
 
86
 
87
  if not KNOWLEDGE_DIR.exists():
88
  logger.error(f"KNOWLEDGE_DIR not found: {KNOWLEDGE_DIR}")
 
97
  if not text:
98
  continue
99
 
100
+ # Extract SOURCE and TITLE from header lines
101
  source_url = ""
102
  title = fpath.stem.replace("_", " ").replace("-", " ")
103
+ for line in text.splitlines()[:6]:
104
+ line = line.strip()
105
  if line.startswith("SOURCE:"):
106
  source_url = line.replace("SOURCE:", "").strip()
107
  elif line.startswith("TITLE:"):
108
  title = line.replace("TITLE:", "").strip()
109
 
110
+ file_chunks = self._chunk_text(text)
111
+ for chunk in file_chunks:
112
  all_chunks.append(chunk)
113
  all_meta.append({
114
  "source": source_url,
 
116
  "filename": fpath.name,
117
  })
118
 
119
+ logger.info(f" {fpath.name}: {len(file_chunks)} chunks")
120
+
121
  except Exception as e:
122
  logger.warning(f"Failed to load {fpath.name}: {e}")
123
 
124
  return all_chunks, all_meta
125
 
126
+ def _chunk_text(self, text: str) -> List[str]:
127
  """
128
+ Split on TOPIC: headers (matching the structure of your .txt files),
129
+ then hard-split oversized sections with overlap.
130
  """
131
+ chunks: List[str] = []
132
 
133
+ # Split on lines that look like section headers
134
+ # Matches: "TOPIC: ...", "BORROWING AND LOANS:", "STUDY ROOMS:", etc.
135
+ sections = re.split(
136
+ r"\n(?=(?:TOPIC:|[A-Z][A-Z\s,/&()\-]+:)\s)",
137
+ text
138
+ )
139
 
140
  for section in sections:
141
  section = section.strip()
142
+ if not section or len(section) < 30: # skip empty/trivial sections
143
  continue
144
 
145
+ if len(section) <= CHUNK_MAX_CHARS:
146
  chunks.append(section)
147
  else:
148
+ # Slide window with overlap for large sections
 
149
  start = 0
150
+ while start < len(section):
151
+ end = start + CHUNK_MAX_CHARS
152
+ chunk = section[start:end].strip()
153
  if chunk:
154
  chunks.append(chunk)
155
+ if end >= len(section):
156
+ break
157
+ start += CHUNK_MAX_CHARS - CHUNK_OVERLAP_CHARS
158
 
159
  return chunks
160
 
 
164
  self,
165
  query: str,
166
  top_k: int = 5,
167
+ alpha: float = 0.6, # 0 = pure BM25, 1 = pure FAISS dense
168
  ) -> List[dict]:
169
  """
170
  Reciprocal Rank Fusion of FAISS (dense) + BM25 (sparse).
 
175
  return []
176
 
177
  try:
178
+ fetch_k = top_k * 3 # over-fetch then re-rank
179
+
180
+ # ── Dense retrieval (FAISS) ────────────────────────────────────
181
  dense_results = await self.vectorstore.asimilarity_search_with_score(
182
+ query, k=fetch_k
183
  )
184
 
185
+ # ── Sparse retrieval (BM25) ───────────────────────────────────
186
  tokenized_query = query.lower().split()
187
  bm25_scores = self.bm25.get_scores(tokenized_query)
188
+ top_bm25_idx = np.argsort(bm25_scores)[::-1][:fetch_k].tolist()
189
 
190
+ # ── Reciprocal Rank Fusion ────────────────────────────────────
191
+ K = 60 # RRF constant β€” standard value
192
+ rrf: dict[int, float] = {}
193
 
194
+ for rank, (doc, _score) in enumerate(dense_results):
 
 
195
  idx = self._find_corpus_index(doc.page_content)
196
  if idx >= 0:
197
+ rrf[idx] = rrf.get(idx, 0.0) + alpha * (1.0 / (K + rank + 1))
198
 
 
199
  for rank, idx in enumerate(top_bm25_idx):
200
+ rrf[idx] = rrf.get(idx, 0.0) + (1 - alpha) * (1.0 / (K + rank + 1))
201
 
202
+ # ── Return top_k sorted by RRF score ─────────────────────────
203
+ sorted_idx = sorted(rrf, key=lambda i: rrf[i], reverse=True)[:top_k]
204
 
205
  results = []
206
  for idx in sorted_idx:
207
  if idx < len(self.bm25_corpus):
208
+ meta = self.bm25_meta[idx]
209
  results.append({
210
  "content": self.bm25_corpus[idx],
211
  "source": meta.get("source", ""),
212
  "title": meta.get("title", ""),
213
  "filename": meta.get("filename", ""),
214
+ "score": round(rrf[idx], 6),
215
  })
216
 
217
  return results
 
221
  return []
222
 
223
  def _find_corpus_index(self, content: str) -> int:
224
+ """Find index in bm25_corpus by exact content match."""
225
  content_strip = content.strip()
226
  for i, c in enumerate(self.bm25_corpus):
227
  if c.strip() == content_strip:
 
232
  return {
233
  "ready": self._ready,
234
  "chunk_count": len(self.bm25_corpus),
235
+ "knowledge_dir": str(KNOWLEDGE_DIR),
236
  "vectorstore_loaded": self.vectorstore is not None,
237
  "bm25_loaded": self.bm25 is not None,
238
+ }