| """ |
| RAG Engine for Sakhi. |
| Fetches top chunks from FAISS, deduplicates, and merges contiguous text blocks. |
| """ |
| import os |
| import pickle |
| import logging |
|
|
| from config import EMBED_MODEL, TOP_K_FETCH, TOP_K_MERGED, FAISS_INDEX_PATH, CHUNKS_PATH |
|
|
| logger = logging.getLogger(__name__) |
|
|
| class RAGEngine: |
| def __init__(self): |
| self.model = None |
| self.index = None |
| self.chunks = [] |
| self._initialized = False |
|
|
| |
| def initialize(self): |
|
|
| if self._initialized: |
| return |
|
|
| from build_index import ensure_index_exists |
|
|
| ensure_index_exists() |
|
|
| try: |
|
|
| import faiss |
| import torch |
|
|
| from sentence_transformers import SentenceTransformer |
|
|
| device = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| self.model = SentenceTransformer( |
| EMBED_MODEL, |
| device=device, |
| ) |
|
|
| self.index = faiss.read_index(FAISS_INDEX_PATH) |
|
|
| with open(CHUNKS_PATH, "rb") as f: |
| self.chunks = pickle.load(f) |
|
|
| logger.info( |
| f"Loaded {len(self.chunks)} chunks." |
| ) |
|
|
| self._initialized = True |
|
|
| except Exception as e: |
|
|
| logger.exception(e) |
|
|
| def retrieve(self, query: str) -> list[dict]: |
| self.initialize() |
| if not self.chunks or self.index is None or self.model is None: |
| return [] |
|
|
| try: |
| import faiss |
| |
| query_embedding = self.model.encode([query], convert_to_numpy=True, normalize_embeddings=True) |
| scores, indices = self.index.search(query_embedding, min(TOP_K_FETCH, len(self.chunks))) |
|
|
| retrieved = [] |
| for score, idx in zip(scores[0], indices[0]): |
| if 0 <= idx < len(self.chunks): |
| chunk = self.chunks[idx].copy() |
| chunk["score"] = float(score) |
| retrieved.append(chunk) |
|
|
| |
| |
| unique_chunks = {c['chunk_idx']: c for c in retrieved}.values() |
| sorted_chunks = sorted(unique_chunks, key=lambda x: (x.get('source', ''), x.get('chunk_idx', 0))) |
|
|
| merged_results = [] |
| curr = None |
|
|
| for c in sorted_chunks: |
| if not curr: |
| curr = c.copy() |
| else: |
| |
| if curr['source'] == c['source'] and (c['chunk_idx'] - curr['chunk_idx']) <= 2: |
| curr['text'] += "\n" + c['text'] |
| curr['chunk_idx'] = c['chunk_idx'] |
| else: |
| merged_results.append(curr) |
| curr = c.copy() |
| if curr: |
| merged_results.append(curr) |
|
|
| |
| return sorted(merged_results, key=lambda x: x.get('score', 0), reverse=True)[:TOP_K_MERGED] |
|
|
| except Exception as e: |
| logger.error(f"FAISS retrieval error: {e}") |
| return [] |
|
|
| def format_context(self, chunks: list[dict]) -> str: |
| if not chunks: |
| return "No specific PDF context found. Please use general knowledge to explain." |
| context_parts = [] |
| for i, chunk in enumerate(chunks, 1): |
| source = chunk.get("source", "Unknown") |
| context_parts.append(f"--- Document: {source} ---\n{chunk.get('text', '')}\n") |
| return "\n".join(context_parts) |