subramaniansrc commited on
Commit
ca0a53e
Β·
verified Β·
1 Parent(s): 5c2e981

Create rag_engine.py

Browse files
Files changed (1) hide show
  1. rag_engine.py +222 -0
rag_engine.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG Engine β€” document ingestion, chunking, embedding, FAISS indexing, retrieval.
3
+ """
4
+
5
+ import os
6
+ import pickle
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from langchain.docstore.document import Document
11
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
12
+ from langchain_community.document_loaders import (
13
+ PyPDFLoader,
14
+ Docx2txtLoader,
15
+ TextLoader,
16
+ )
17
+ from langchain_community.vectorstores import FAISS
18
+ from langchain_huggingface import HuggingFaceEmbeddings
19
+
20
+ from config import cfg
21
+ from logging_config import get_logger, setup_logging
22
+ from utils import validate_file, file_checksum, ensure_dir, Timer
23
+
24
+ setup_logging(log_dir=cfg.app.log_dir)
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ class RAGEngine:
29
+ """
30
+ Handles the full RAG lifecycle:
31
+ load β†’ chunk β†’ embed β†’ store β†’ retrieve.
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ self.embeddings: Optional[HuggingFaceEmbeddings] = None
36
+ self.vector_store: Optional[FAISS] = None
37
+ self.ingested_checksums: set[str] = set()
38
+ self.all_chunks: list[Document] = []
39
+ self._splitter = RecursiveCharacterTextSplitter(
40
+ chunk_size=cfg.chunking.chunk_size,
41
+ chunk_overlap=cfg.chunking.chunk_overlap,
42
+ separators=cfg.chunking.separators,
43
+ )
44
+ logger.info("RAGEngine initialised.")
45
+
46
+ # ── Embedding model ───────────────────────────────────────────────────────
47
+
48
+ def load_embeddings(self) -> None:
49
+ if self.embeddings is not None:
50
+ return
51
+ logger.info("Loading embedding model: %s", cfg.embedding.model_name)
52
+ with Timer() as t:
53
+ self.embeddings = HuggingFaceEmbeddings(
54
+ model_name=cfg.embedding.model_name,
55
+ encode_kwargs={"normalize_embeddings": cfg.embedding.normalize_embeddings},
56
+ )
57
+ logger.info("Embedding model loaded in %s.", t)
58
+
59
+ # ── Document loaders ──────────────────────────────────────────────────────
60
+
61
+ def _load_single(self, filepath: str) -> list[Document]:
62
+ ext = Path(filepath).suffix.lower()
63
+ loaders = {
64
+ ".pdf": lambda: PyPDFLoader(filepath),
65
+ ".docx": lambda: Docx2txtLoader(filepath),
66
+ ".txt": lambda: TextLoader(filepath, encoding="utf-8"),
67
+ }
68
+ if ext not in loaders:
69
+ logger.warning("Unsupported file type: %s", ext)
70
+ return []
71
+ try:
72
+ loader = loaders[ext]()
73
+ docs = loader.load()
74
+ # Normalise metadata
75
+ for doc in docs:
76
+ doc.metadata["source"] = filepath
77
+ logger.info("Loaded %d page(s) from '%s'.", len(docs), filepath)
78
+ return docs
79
+ except Exception as exc:
80
+ logger.error("Failed to load '%s': %s", filepath, exc)
81
+ return []
82
+
83
+ def load_documents(self, paths: list[str]) -> list[Document]:
84
+ all_docs: list[Document] = []
85
+ for path in paths:
86
+ ok, reason = validate_file(
87
+ path,
88
+ allowed_extensions=cfg.app.allowed_extensions,
89
+ max_size_mb=cfg.app.max_file_size_mb,
90
+ )
91
+ if not ok:
92
+ logger.warning("Skipping '%s': %s", path, reason)
93
+ continue
94
+ chk = file_checksum(path)
95
+ if chk in self.ingested_checksums:
96
+ logger.info("Skipping duplicate file: %s", path)
97
+ continue
98
+ docs = self._load_single(path)
99
+ if docs:
100
+ self.ingested_checksums.add(chk)
101
+ all_docs.extend(docs)
102
+ return all_docs
103
+
104
+ # ── Chunking ──────────────────────────────────────────────────────────────
105
+
106
+ def chunk_documents(self, docs: list[Document]) -> list[Document]:
107
+ if not docs:
108
+ return []
109
+ with Timer() as t:
110
+ chunks = self._splitter.split_documents(docs)
111
+ logger.info(
112
+ "Produced %d chunks from %d documents in %s.", len(chunks), len(docs), t
113
+ )
114
+ return chunks
115
+
116
+ def update_splitter(self, chunk_size: int, chunk_overlap: int) -> None:
117
+ self._splitter = RecursiveCharacterTextSplitter(
118
+ chunk_size=chunk_size,
119
+ chunk_overlap=chunk_overlap,
120
+ separators=cfg.chunking.separators,
121
+ )
122
+
123
+ # ── Vector store ──────────────────────────────────────────────────────────
124
+
125
+ def build_index(self, chunks: list[Document]) -> None:
126
+ if not chunks:
127
+ raise ValueError("Cannot build index: no chunks provided.")
128
+ self.load_embeddings()
129
+ logger.info("Building FAISS index from %d chunks…", len(chunks))
130
+ with Timer() as t:
131
+ self.vector_store = FAISS.from_documents(chunks, self.embeddings)
132
+ self.all_chunks = chunks
133
+ logger.info("FAISS index built in %s.", t)
134
+ self._save_index()
135
+
136
+ def add_documents_to_index(self, chunks: list[Document]) -> None:
137
+ """Incremental update β€” appends to an existing index."""
138
+ if not chunks:
139
+ return
140
+ self.load_embeddings()
141
+ if self.vector_store is None:
142
+ self.build_index(chunks)
143
+ return
144
+ logger.info("Incrementally adding %d chunks to existing index.", len(chunks))
145
+ self.vector_store.add_documents(chunks)
146
+ self.all_chunks.extend(chunks)
147
+ self._save_index()
148
+
149
+ def _save_index(self) -> None:
150
+ ensure_dir(cfg.retrieval.index_path)
151
+ self.vector_store.save_local(cfg.retrieval.index_path)
152
+ meta_path = cfg.retrieval.metadata_file
153
+ with open(meta_path, "wb") as f:
154
+ pickle.dump(
155
+ {
156
+ "checksums": self.ingested_checksums,
157
+ "chunks": self.all_chunks,
158
+ },
159
+ f,
160
+ )
161
+ logger.info("Index saved to '%s'.", cfg.retrieval.index_path)
162
+
163
+ def load_index(self) -> bool:
164
+ index_file = cfg.retrieval.index_file
165
+ meta_file = cfg.retrieval.metadata_file
166
+ if not (os.path.exists(index_file) and os.path.exists(meta_file)):
167
+ logger.info("No persisted index found at '%s'.", cfg.retrieval.index_path)
168
+ return False
169
+ self.load_embeddings()
170
+ try:
171
+ self.vector_store = FAISS.load_local(
172
+ cfg.retrieval.index_path,
173
+ self.embeddings,
174
+ allow_dangerous_deserialization=True,
175
+ )
176
+ with open(meta_file, "rb") as f:
177
+ meta = pickle.load(f)
178
+ self.ingested_checksums = meta.get("checksums", set())
179
+ self.all_chunks = meta.get("chunks", [])
180
+ logger.info(
181
+ "Index loaded: %d chunks, %d source files.",
182
+ len(self.all_chunks),
183
+ len(self.ingested_checksums),
184
+ )
185
+ return True
186
+ except Exception as exc:
187
+ logger.error("Failed to load index: %s", exc)
188
+ return False
189
+
190
+ # ── Retrieval ─────────────────────────────────────────────────────────────
191
+
192
+ def retrieve(
193
+ self,
194
+ query: str,
195
+ top_k: Optional[int] = None,
196
+ ) -> list[Document]:
197
+ if self.vector_store is None:
198
+ raise RuntimeError("Vector store is not initialised. Build or load an index first.")
199
+ k = top_k or cfg.retrieval.top_k
200
+ with Timer() as t:
201
+ results = self.vector_store.similarity_search(query, k=k)
202
+ logger.info(
203
+ "Retrieved %d chunks for query '%s…' in %s.",
204
+ len(results),
205
+ query[:60],
206
+ t,
207
+ )
208
+ return results
209
+
210
+ # ── Status ────────────────────────────────────────────────────────────────
211
+
212
+ @property
213
+ def is_ready(self) -> bool:
214
+ return self.vector_store is not None
215
+
216
+ @property
217
+ def doc_count(self) -> int:
218
+ return len(self.ingested_checksums)
219
+
220
+ @property
221
+ def chunk_count(self) -> int:
222
+ return len(self.all_chunks)