Sumavasterd111 commited on
Commit
1fecdd9
Β·
verified Β·
1 Parent(s): 8cbfc96

Upload rag_engine.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. rag_engine.py +441 -0
rag_engine.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ import math
5
+ from collections import Counter
6
+ import numpy as np
7
+
8
+ try:
9
+ from pypdf import PdfReader
10
+ HAS_PYPDF = True
11
+ except ImportError:
12
+ HAS_PYPDF = False
13
+
14
+ try:
15
+ import docx
16
+ HAS_DOCX = True
17
+ except ImportError:
18
+ HAS_DOCX = False
19
+
20
+ try:
21
+ import faiss
22
+ HAS_FAISS = True
23
+ except ImportError:
24
+ HAS_FAISS = False
25
+
26
+ try:
27
+ from sentence_transformers import SentenceTransformer
28
+ HAS_SENTENCE_TRANSFORMERS = True
29
+ except ImportError:
30
+ HAS_SENTENCE_TRANSFORMERS = False
31
+
32
+ try:
33
+ from groq import Groq
34
+ HAS_GROQ = True
35
+ except ImportError:
36
+ HAS_GROQ = False
37
+
38
+ try:
39
+ import httpx
40
+ HAS_HTTPX = True
41
+ except ImportError:
42
+ HAS_HTTPX = False
43
+
44
+ try:
45
+ from dotenv import load_dotenv
46
+ load_dotenv()
47
+ except ImportError:
48
+ pass
49
+
50
+
51
+ class FastFallbackEmbedder:
52
+ """Fast TF-IDF / character-word n-gram fallback embedder when SentenceTransformers is not yet loaded."""
53
+ def __init__(self, dim=384):
54
+ self.dim = dim
55
+
56
+ def encode(self, texts, **kwargs):
57
+ if isinstance(texts, str):
58
+ texts = [texts]
59
+ embeddings = []
60
+ for text in texts:
61
+ words = re.findall(r"\w+", text.lower())
62
+ vec = np.zeros(self.dim, dtype=np.float32)
63
+ if not words:
64
+ embeddings.append(vec)
65
+ continue
66
+ for i, w in enumerate(words):
67
+ h = hash(w) % self.dim
68
+ vec[h] += 1.0 / (1.0 + math.log(1 + i))
69
+ norm = np.linalg.norm(vec)
70
+ if norm > 0:
71
+ vec /= norm
72
+ embeddings.append(vec)
73
+ return np.array(embeddings, dtype=np.float32)
74
+
75
+
76
+ class RagEngine:
77
+ def __init__(self):
78
+ self.chunks = []
79
+ self.embeddings = None
80
+ self.faiss_index = None
81
+ self.embedding_dim = 384
82
+ self.embed_model = None
83
+ self.audit_log = []
84
+
85
+ # Load SentenceTransformer model
86
+ if HAS_SENTENCE_TRANSFORMERS:
87
+ try:
88
+ print("[RagEngine] Loading embedding model 'all-MiniLM-L6-v2'...")
89
+ self.embed_model = SentenceTransformer("all-MiniLM-L6-v2")
90
+ self.embedding_dim = 384
91
+ except Exception as e:
92
+ print(f"[RagEngine] Could not load SentenceTransformer: {e}. Using fallback embedder.")
93
+ self.embed_model = FastFallbackEmbedder(self.embedding_dim)
94
+ else:
95
+ print("[RagEngine] sentence-transformers not installed. Using fallback embedder.")
96
+ self.embed_model = FastFallbackEmbedder(self.embedding_dim)
97
+
98
+ self._init_vector_store()
99
+
100
+ # Groq client
101
+ self.groq_api_key = os.environ.get("GROQ_API_KEY", "").strip()
102
+ self.groq_client = None
103
+ if self.groq_api_key and HAS_GROQ:
104
+ try:
105
+ self.groq_client = Groq(api_key=self.groq_api_key)
106
+ except Exception as e:
107
+ print(f"[RagEngine] Groq client init failed: {e}")
108
+
109
+ def _init_vector_store(self):
110
+ if HAS_FAISS:
111
+ self.faiss_index = faiss.IndexFlatL2(self.embedding_dim)
112
+ else:
113
+ self.faiss_index = None
114
+
115
+ def _extract_text_and_pages(self, filepath):
116
+ """Extract text with page numbers from PDF, DOCX, or TXT."""
117
+ pages = []
118
+ ext = os.path.splitext(filepath)[1].lower()
119
+
120
+ if ext == ".pdf" and HAS_PYPDF:
121
+ reader = PdfReader(filepath)
122
+ for i, page in enumerate(reader.pages):
123
+ text = page.extract_text() or ""
124
+ if text.strip():
125
+ pages.append((i + 1, text))
126
+ elif ext == ".docx" and HAS_DOCX:
127
+ doc = docx.Document(filepath)
128
+ full_text = "\n".join([p.text for p in doc.paragraphs if p.text.strip()])
129
+ pages.append((1, full_text))
130
+ elif ext == ".csv":
131
+ try:
132
+ import pandas as pd
133
+ df = pd.read_csv(filepath)
134
+ cols = list(df.columns)
135
+ rows_text = []
136
+ for idx, row in df.iterrows():
137
+ row_desc = ", ".join([f"{col}: {row[col]}" for col in cols if pd.notna(row[col])])
138
+ rows_text.append(f"Row {idx+1} -> {row_desc}")
139
+
140
+ chunk_size = 15
141
+ if not rows_text:
142
+ pages.append((1, f"CSV File with Columns: {', '.join(map(str, cols))} (No data rows)"))
143
+ else:
144
+ for page_idx, i in enumerate(range(0, len(rows_text), chunk_size), 1):
145
+ batch = "\n".join(rows_text[i : i + chunk_size])
146
+ pages.append((page_idx, f"CSV File Columns: {', '.join(map(str, cols))}\n\nData:\n{batch}"))
147
+ except Exception as e:
148
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
149
+ pages.append((1, f.read()))
150
+
151
+ elif ext in [".xlsx", ".xls"]:
152
+ try:
153
+ import pandas as pd
154
+ excel_file = pd.ExcelFile(filepath)
155
+ for sheet_idx, sheet_name in enumerate(excel_file.sheet_names, 1):
156
+ df = pd.read_excel(filepath, sheet_name=sheet_name)
157
+ cols = list(df.columns)
158
+ rows_text = []
159
+ for idx, row in df.iterrows():
160
+ row_desc = ", ".join([f"{col}: {row[col]}" for col in cols if pd.notna(row[col])])
161
+ rows_text.append(f"Row {idx+1} -> {row_desc}")
162
+
163
+ chunk_size = 15
164
+ if not rows_text:
165
+ pages.append((sheet_idx, f"Excel Sheet: '{sheet_name}' | Columns: {', '.join(map(str, cols))} (Empty Sheet)"))
166
+ else:
167
+ for sub_idx, i in enumerate(range(0, len(rows_text), chunk_size), 1):
168
+ batch = "\n".join(rows_text[i : i + chunk_size])
169
+ pages.append((sheet_idx, f"Excel Sheet: '{sheet_name}' | Columns: {', '.join(map(str, cols))}\n\nData:\n{batch}"))
170
+ except Exception as e:
171
+ pages.append((1, f"Excel file: {os.path.basename(filepath)}"))
172
+
173
+ else:
174
+ encodings = ["utf-8", "latin-1", "cp1252"]
175
+ content = ""
176
+ for enc in encodings:
177
+ try:
178
+ with open(filepath, "r", encoding=enc) as f:
179
+ content = f.read()
180
+ break
181
+ except Exception:
182
+ continue
183
+ pages.append((1, content))
184
+
185
+ return pages
186
+
187
+ def _chunk_text(self, text, max_words=120, overlap_words=25):
188
+ """Split text into overlapping chunks."""
189
+ words = text.split()
190
+ if not words:
191
+ return []
192
+ chunks = []
193
+ i = 0
194
+ while i < len(words):
195
+ chunk = " ".join(words[i : i + max_words])
196
+ chunks.append(chunk)
197
+ i += max_words - overlap_words
198
+ return chunks
199
+
200
+ def ingest_document(self, filepath, doc_role="public"):
201
+ """Ingest document, chunk text, generate embeddings, and update index."""
202
+ if not os.path.exists(filepath):
203
+ raise FileNotFoundError(f"File not found: {filepath}")
204
+
205
+ filename = os.path.basename(filepath)
206
+ pages = self._extract_text_and_pages(filepath)
207
+ if not pages:
208
+ raise ValueError(f"Could not extract any text from {filename}")
209
+
210
+ new_chunks = []
211
+ new_texts = []
212
+ is_stale_file = "stale" in filename.lower() or "archive" in filename.lower()
213
+ chunk_timestamp = (time.time() - 200 * 86400) if is_stale_file else time.time()
214
+
215
+ for page_num, page_text in pages:
216
+ text_chunks = self._chunk_text(page_text)
217
+ for t_chunk in text_chunks:
218
+ chunk_obj = {
219
+ "text": t_chunk,
220
+ "source": filename,
221
+ "page": page_num,
222
+ "doc_role": doc_role,
223
+ "timestamp": chunk_timestamp,
224
+ "images": [],
225
+ }
226
+ new_chunks.append(chunk_obj)
227
+ new_texts.append(t_chunk)
228
+
229
+ if not new_texts:
230
+ raise ValueError(f"No valid text chunks generated from {filename}")
231
+
232
+ # Compute embeddings
233
+ embs = self.embed_model.encode(new_texts)
234
+ if isinstance(embs, list):
235
+ embs = np.array(embs, dtype=np.float32)
236
+ elif not isinstance(embs, np.ndarray):
237
+ embs = embs.cpu().numpy().astype(np.float32)
238
+ else:
239
+ embs = embs.astype(np.float32)
240
+
241
+ # Update Vector Index
242
+ if HAS_FAISS and self.faiss_index is not None:
243
+ self.faiss_index.add(embs)
244
+
245
+ if self.embeddings is None or len(self.embeddings) == 0:
246
+ self.embeddings = embs
247
+ else:
248
+ self.embeddings = np.vstack([self.embeddings, embs])
249
+
250
+ self.chunks.extend(new_chunks)
251
+
252
+ def get_stats(self):
253
+ sources = sorted(list(set(c["source"] for c in self.chunks)))
254
+ return {
255
+ "total_chunks": len(self.chunks),
256
+ "unique_sources": len(sources),
257
+ "sources": sources,
258
+ }
259
+
260
+ def _keyword_search(self, query_tokens, candidate_indices):
261
+ """Source-aware keyword search returning ranked candidate indices."""
262
+ scores = {}
263
+ for idx in candidate_indices:
264
+ chunk = self.chunks[idx]
265
+ text = chunk["text"].lower()
266
+ source = chunk["source"].lower()
267
+ score = 0
268
+ for token in query_tokens:
269
+ if token in source:
270
+ score += 3.0
271
+ if token in text:
272
+ score += 1.0
273
+ if score > 0:
274
+ scores[idx] = score
275
+
276
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
277
+ return [idx for idx, _ in ranked]
278
+
279
+ def query(self, message, top_k=8, user_role=None):
280
+ """Hybrid search combining semantic search & keyword search with Reciprocal Rank Fusion (RRF)."""
281
+ if not self.chunks:
282
+ return []
283
+
284
+ candidate_indices = list(range(len(self.chunks)))
285
+
286
+ # 1. Semantic Search
287
+ q_emb = self.embed_model.encode([message])
288
+ if isinstance(q_emb, list):
289
+ q_emb = np.array(q_emb, dtype=np.float32)
290
+ elif not isinstance(q_emb, np.ndarray):
291
+ q_emb = q_emb.cpu().numpy().astype(np.float32)
292
+ else:
293
+ q_emb = q_emb.astype(np.float32)
294
+
295
+ cand_embs = self.embeddings[candidate_indices]
296
+ dists = np.linalg.norm(cand_embs - q_emb, axis=1)
297
+ sem_ranked_order = np.argsort(dists)
298
+ sem_ranked_indices = [candidate_indices[i] for i in sem_ranked_order]
299
+
300
+ # 2. Keyword Search
301
+ query_tokens = [
302
+ t.lower()
303
+ for t in re.findall(r"\w+", message)
304
+ if len(t) > 2 and t.lower() not in {"what", "how", "why", "when", "where", "the", "and", "for"}
305
+ ]
306
+ kw_ranked_indices = self._keyword_search(query_tokens, candidate_indices)
307
+
308
+ # 3. Reciprocal Rank Fusion (RRF, k=60)
309
+ rrf_scores = Counter()
310
+ for rank, idx in enumerate(sem_ranked_indices):
311
+ rrf_scores[idx] += 1.0 / (60.0 + rank + 1)
312
+
313
+ for rank, idx in enumerate(kw_ranked_indices):
314
+ rrf_scores[idx] += 1.0 / (60.0 + rank + 1)
315
+
316
+ top_candidates = rrf_scores.most_common(top_k)
317
+
318
+ results = []
319
+ retrieved_sources = set()
320
+ for idx, score in top_candidates:
321
+ chunk = self.chunks[idx]
322
+ retrieved_sources.add(chunk["source"])
323
+ results.append({
324
+ "source": chunk["source"],
325
+ "page": chunk.get("page", 1),
326
+ "score": float(score),
327
+ "doc_role": chunk.get("doc_role", "public"),
328
+ "images": chunk.get("images", []),
329
+ "text": chunk["text"],
330
+ })
331
+
332
+ # Record in audit log
333
+ self.audit_log.append({
334
+ "timestamp": time.time(),
335
+ "question": message,
336
+ "sources_retrieved": sorted(list(retrieved_sources)),
337
+ })
338
+
339
+ return results
340
+
341
+ def generate_answer(self, message, chunks):
342
+ """Generate direct, beautifully formatted answer with citations using Groq Qwen 3.6 27B."""
343
+ if not chunks:
344
+ return "No matching context found."
345
+
346
+ # Prepare context blocks with source numbering
347
+ context_parts = []
348
+ for i, c in enumerate(chunks, 1):
349
+ context_parts.append(
350
+ f"[Source {i}: {c['source']} | Page {c['page']}]\n{c['text']}"
351
+ )
352
+ context_str = "\n\n".join(context_parts)
353
+
354
+ api_key = os.environ.get("GROQ_API_KEY", self.groq_api_key or "").strip()
355
+ model_name = os.environ.get("GROQ_MODEL", "qwen/qwen3.8-27b").strip()
356
+
357
+ if api_key:
358
+ candidate_models = [model_name, "qwen/qwen3.8-27b", "openai/gpt-oss-120b", "openai/gpt-oss-20b", "groq/compound"]
359
+ # remove duplicates while preserving order
360
+ unique_models = []
361
+ for m in candidate_models:
362
+ if m not in unique_models:
363
+ unique_models.append(m)
364
+
365
+ for target_model in unique_models:
366
+ try:
367
+ system_prompt = (
368
+ "You are the Universal RAG AI Knowledge Assistant. "
369
+ "Your goal is to provide rich, comprehensive, beautifully structured answers strictly grounded in the provided context.\n\n"
370
+ "FORMATTING RULES:\n"
371
+ "1. When answering about resumes/candidates: Clearly separate each candidate using headers like 'πŸ‘€ Candidate: [Name]'. "
372
+ "Organize their details into 'πŸ“š Technical Skills & Core Tools [Source X, Page Y]' (grouped by Languages, Databases, Cloud, Frameworks) "
373
+ "and 'πŸ“ Main Projects & Associated Technologies' or 'πŸ”Ή Project X: [Title]' with 'Technologies Used' and 'Key Contributions & Details'.\n"
374
+ "2. When answering about tabular CSV/Excel files: Extract specific rows, values, metrics, and comparisons in clean Markdown tables and bullet points.\n"
375
+ "3. When answering about research papers or technical reports: Clearly break down methodology, pipeline, dataset, and key benchmarks/metrics.\n"
376
+ "4. When referencing information, always cite the source (e.g., '[Source 1, Page 1]' or 'Source: Source 1, Page 1').\n"
377
+ "5. Maintain high fidelity to the text (exact libraries, versions, metrics, algorithms).\n"
378
+ "6. End with a polite, helpful follow-up note (e.g., 'Let me know if you need a comparison with other candidates or deeper technical breakdowns!')."
379
+ )
380
+ user_prompt = (
381
+ f"CONTEXT INFORMATION:\n{context_str}\n\n"
382
+ f"USER QUESTION: {message}\n\n"
383
+ f"Provide a structured, complete answer following the formatting rules."
384
+ )
385
+
386
+ if HAS_GROQ:
387
+ client = self.groq_client or Groq(api_key=api_key)
388
+ try:
389
+ completion = client.chat.completions.create(
390
+ model=target_model,
391
+ messages=[
392
+ {"role": "system", "content": system_prompt},
393
+ {"role": "user", "content": user_prompt},
394
+ ],
395
+ temperature=0.2,
396
+ max_tokens=850,
397
+ reasoning_format="hidden",
398
+ )
399
+ except Exception:
400
+ completion = client.chat.completions.create(
401
+ model=target_model,
402
+ messages=[
403
+ {"role": "system", "content": system_prompt},
404
+ {"role": "user", "content": user_prompt},
405
+ ],
406
+ temperature=0.2,
407
+ max_tokens=850,
408
+ )
409
+ raw_content = completion.choices[0].message.content or ""
410
+ clean_content = re.sub(r"<think>.*?</think>", "", raw_content, flags=re.DOTALL).strip()
411
+ if clean_content or raw_content:
412
+ return clean_content or raw_content
413
+
414
+ except Exception as e:
415
+ print(f"[RagEngine] Model {target_model} error: {e}")
416
+ continue
417
+
418
+ # Fallback local synthesis
419
+ synthesis = "Based on the relevant documents indexed in the knowledge base:\n\n"
420
+ for i, c in enumerate(chunks[:3], 1):
421
+ synthesis += f"> **[{c['source']} - Page {c['page']}]**\n> \"{c['text'][:280]}...\"\n\n"
422
+ return synthesis
423
+
424
+ def get_audit_summary(self):
425
+ """Governance summary: chunks by role/department, staleness, and query audit log."""
426
+ chunks_by_role = Counter([c.get("doc_role", "public") for c in self.chunks])
427
+ now = time.time()
428
+ stale_count = sum(1 for c in self.chunks if (now - c.get("timestamp", now)) > (180 * 86400))
429
+ return {
430
+ "chunks_by_role": dict(chunks_by_role),
431
+ "total_queries": len(self.audit_log),
432
+ "stale_chunk_count": stale_count,
433
+ "recent_log": self.audit_log[-15:],
434
+ }
435
+
436
+ def clear_index(self):
437
+ """Clear all stored chunks and reset vector index."""
438
+ self.chunks = []
439
+ self.embeddings = None
440
+ self._init_vector_store()
441
+ print("[RagEngine] Vector index and chunks cleared.")