File size: 8,674 Bytes
ca0a53e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""
RAG Engine β€” document ingestion, chunking, embedding, FAISS indexing, retrieval.
"""

import os
import pickle
from pathlib import Path
from typing import Optional

from langchain.docstore.document import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import (
    PyPDFLoader,
    Docx2txtLoader,
    TextLoader,
)
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings

from config import cfg
from logging_config import get_logger, setup_logging
from utils import validate_file, file_checksum, ensure_dir, Timer

setup_logging(log_dir=cfg.app.log_dir)
logger = get_logger(__name__)


class RAGEngine:
    """
    Handles the full RAG lifecycle:
    load β†’ chunk β†’ embed β†’ store β†’ retrieve.
    """

    def __init__(self) -> None:
        self.embeddings: Optional[HuggingFaceEmbeddings] = None
        self.vector_store: Optional[FAISS] = None
        self.ingested_checksums: set[str] = set()
        self.all_chunks: list[Document] = []
        self._splitter = RecursiveCharacterTextSplitter(
            chunk_size=cfg.chunking.chunk_size,
            chunk_overlap=cfg.chunking.chunk_overlap,
            separators=cfg.chunking.separators,
        )
        logger.info("RAGEngine initialised.")

    # ── Embedding model ───────────────────────────────────────────────────────

    def load_embeddings(self) -> None:
        if self.embeddings is not None:
            return
        logger.info("Loading embedding model: %s", cfg.embedding.model_name)
        with Timer() as t:
            self.embeddings = HuggingFaceEmbeddings(
                model_name=cfg.embedding.model_name,
                encode_kwargs={"normalize_embeddings": cfg.embedding.normalize_embeddings},
            )
        logger.info("Embedding model loaded in %s.", t)

    # ── Document loaders ──────────────────────────────────────────────────────

    def _load_single(self, filepath: str) -> list[Document]:
        ext = Path(filepath).suffix.lower()
        loaders = {
            ".pdf":  lambda: PyPDFLoader(filepath),
            ".docx": lambda: Docx2txtLoader(filepath),
            ".txt":  lambda: TextLoader(filepath, encoding="utf-8"),
        }
        if ext not in loaders:
            logger.warning("Unsupported file type: %s", ext)
            return []
        try:
            loader = loaders[ext]()
            docs = loader.load()
            # Normalise metadata
            for doc in docs:
                doc.metadata["source"] = filepath
            logger.info("Loaded %d page(s) from '%s'.", len(docs), filepath)
            return docs
        except Exception as exc:
            logger.error("Failed to load '%s': %s", filepath, exc)
            return []

    def load_documents(self, paths: list[str]) -> list[Document]:
        all_docs: list[Document] = []
        for path in paths:
            ok, reason = validate_file(
                path,
                allowed_extensions=cfg.app.allowed_extensions,
                max_size_mb=cfg.app.max_file_size_mb,
            )
            if not ok:
                logger.warning("Skipping '%s': %s", path, reason)
                continue
            chk = file_checksum(path)
            if chk in self.ingested_checksums:
                logger.info("Skipping duplicate file: %s", path)
                continue
            docs = self._load_single(path)
            if docs:
                self.ingested_checksums.add(chk)
                all_docs.extend(docs)
        return all_docs

    # ── Chunking ──────────────────────────────────────────────────────────────

    def chunk_documents(self, docs: list[Document]) -> list[Document]:
        if not docs:
            return []
        with Timer() as t:
            chunks = self._splitter.split_documents(docs)
        logger.info(
            "Produced %d chunks from %d documents in %s.", len(chunks), len(docs), t
        )
        return chunks

    def update_splitter(self, chunk_size: int, chunk_overlap: int) -> None:
        self._splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=cfg.chunking.separators,
        )

    # ── Vector store ──────────────────────────────────────────────────────────

    def build_index(self, chunks: list[Document]) -> None:
        if not chunks:
            raise ValueError("Cannot build index: no chunks provided.")
        self.load_embeddings()
        logger.info("Building FAISS index from %d chunks…", len(chunks))
        with Timer() as t:
            self.vector_store = FAISS.from_documents(chunks, self.embeddings)
        self.all_chunks = chunks
        logger.info("FAISS index built in %s.", t)
        self._save_index()

    def add_documents_to_index(self, chunks: list[Document]) -> None:
        """Incremental update β€” appends to an existing index."""
        if not chunks:
            return
        self.load_embeddings()
        if self.vector_store is None:
            self.build_index(chunks)
            return
        logger.info("Incrementally adding %d chunks to existing index.", len(chunks))
        self.vector_store.add_documents(chunks)
        self.all_chunks.extend(chunks)
        self._save_index()

    def _save_index(self) -> None:
        ensure_dir(cfg.retrieval.index_path)
        self.vector_store.save_local(cfg.retrieval.index_path)
        meta_path = cfg.retrieval.metadata_file
        with open(meta_path, "wb") as f:
            pickle.dump(
                {
                    "checksums": self.ingested_checksums,
                    "chunks": self.all_chunks,
                },
                f,
            )
        logger.info("Index saved to '%s'.", cfg.retrieval.index_path)

    def load_index(self) -> bool:
        index_file = cfg.retrieval.index_file
        meta_file = cfg.retrieval.metadata_file
        if not (os.path.exists(index_file) and os.path.exists(meta_file)):
            logger.info("No persisted index found at '%s'.", cfg.retrieval.index_path)
            return False
        self.load_embeddings()
        try:
            self.vector_store = FAISS.load_local(
                cfg.retrieval.index_path,
                self.embeddings,
                allow_dangerous_deserialization=True,
            )
            with open(meta_file, "rb") as f:
                meta = pickle.load(f)
            self.ingested_checksums = meta.get("checksums", set())
            self.all_chunks = meta.get("chunks", [])
            logger.info(
                "Index loaded: %d chunks, %d source files.",
                len(self.all_chunks),
                len(self.ingested_checksums),
            )
            return True
        except Exception as exc:
            logger.error("Failed to load index: %s", exc)
            return False

    # ── Retrieval ─────────────────────────────────────────────────────────────

    def retrieve(
        self,
        query: str,
        top_k: Optional[int] = None,
    ) -> list[Document]:
        if self.vector_store is None:
            raise RuntimeError("Vector store is not initialised. Build or load an index first.")
        k = top_k or cfg.retrieval.top_k
        with Timer() as t:
            results = self.vector_store.similarity_search(query, k=k)
        logger.info(
            "Retrieved %d chunks for query '%s…' in %s.",
            len(results),
            query[:60],
            t,
        )
        return results

    # ── Status ────────────────────────────────────────────────────────────────

    @property
    def is_ready(self) -> bool:
        return self.vector_store is not None

    @property
    def doc_count(self) -> int:
        return len(self.ingested_checksums)

    @property
    def chunk_count(self) -> int:
        return len(self.all_chunks)