File size: 13,528 Bytes
7699638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1fb4407
 
7699638
 
 
 
0f25e7d
 
7699638
 
 
0f25e7d
7699638
 
1fb4407
7699638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc3142c
 
 
 
 
 
7699638
 
 
 
 
 
 
 
dc3142c
7699638
dc3142c
9b44a6c
 
dc3142c
9b44a6c
 
 
 
4adb461
9b44a6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4adb461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b44a6c
 
4adb461
9b44a6c
 
4adb461
9b44a6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7699638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8095235
 
 
 
 
 
 
 
 
7699638
 
8095235
 
 
 
 
 
 
 
 
 
 
 
 
7699638
8095235
7699638
 
 
 
 
 
799cd4d
 
 
7699638
327c805
7699638
 
 
 
799cd4d
7699638
1fb4407
 
 
 
 
ab274be
7699638
 
 
 
 
1fb4407
7699638
1fb4407
 
 
7699638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1fb4407
 
7699638
 
 
 
 
 
 
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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
"""
Basic RAG β€” Baseline Implementation
Vector similarity search β†’ top-K chunks β†’ LLM prompt

This is the BASELINE we're beating. No graph. No structure.
Just cosine similarity over document chunks.
"""

import os
import time
import logging
from pathlib import Path
from dataclasses import dataclass
import numpy as np
import faiss
from dotenv import load_dotenv

from ..llm.gemini_client import gemini_generate

# Load .env from project root
load_dotenv(Path(__file__).parent.parent.parent / ".env", override=True)
logger = logging.getLogger(__name__)

TOP_K = int((os.getenv("TOP_K_BASIC_RAG", "5") or "5").strip())
EMBEDDING_MODEL = (os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") or "").strip()
EMBEDDING_DIM = 384  # sentence-transformers all-MiniLM-L6-v2

_sentence_transformer = None
USE_SENTENCE_TRANSFORMERS = (os.getenv("USE_SENTENCE_TRANSFORMERS", "true") or "true").lower().strip() == "true"


# Gemini calls go through shared gemini_client.gemini_generate()


def _get_sentence_transformer():
    """Lazily load sentence-transformer for local embeddings."""
    global _sentence_transformer
    if _sentence_transformer is None:
        try:
            from sentence_transformers import SentenceTransformer
            _sentence_transformer = SentenceTransformer("all-MiniLM-L6-v2")
            logger.info("βœ… Loaded sentence-transformers for local embeddings (384 dims)")
        except ImportError:
            logger.warning("sentence-transformers not installed. Install with: pip install sentence-transformers")
            _sentence_transformer = False
    return _sentence_transformer if _sentence_transformer else None


@dataclass
class RAGResult:
    answer: str
    retrieved_chunks: list[str]
    prompt_tokens: int
    completion_tokens: int
    total_tokens: int
    latency_ms: float
    method: str = "basic_rag"


class BasicRAG:
    """
    Standard RAG pipeline:
    1. Embed query
    2. Cosine similarity search over FAISS index
    3. Concatenate top-K chunks
    4. Send to LLM
    
    This is the BASELINE. Expensive. Indiscriminate. 
    No structural understanding of relationships.
    """

    def __init__(self):
        self.index = faiss.IndexFlatIP(EMBEDDING_DIM)  # Inner product = cosine similarity
        self.chunks: list[str] = []
        self.chunk_metadata: list[dict] = []
        
        # Try to load pre-built FAISS index from disk
        # Use absolute path: work up from this file β†’ backend β†’ graphrag-hackathon β†’ data
        faiss_path = Path(__file__).resolve().parent.parent.parent / "data" / "faiss_index.pkl"
        
        logger.info(f"Attempting to load FAISS index from: {faiss_path}")
        logger.info(f"Path exists: {faiss_path.exists()}")
        
        if faiss_path.exists():
            try:
                import pickle
                with open(faiss_path, "rb") as f:
                    data = pickle.load(f)
                    self.index = data["index"]
                    self.chunks = data["chunks"]
                    self.chunk_metadata = data.get("metadata", [])
                logger.info(f"βœ… Loaded FAISS index from {faiss_path} ({len(self.chunks)} chunks, {self.index.ntotal} vectors)")
            except Exception as e:
                logger.error(f"❌ Failed to load FAISS index: {e}", exc_info=True)
                # Fall through to build from sample docs
                self._build_index_from_sample_docs()
        else:
            logger.warning(f"⚠️  FAISS index not found at {faiss_path}. Building from sample documents...")
            self._build_index_from_sample_docs()

    def _build_index_from_sample_docs(self):
        """Dynamically build FAISS index from sample documents with semantic chunking"""
        sample_docs_dir = Path(__file__).resolve().parent.parent.parent / "data" / "sample_docs"
        
        if not sample_docs_dir.exists():
            logger.warning(f"Sample docs directory not found at {sample_docs_dir}")
            return
        
        # Load all .md and .txt files
        doc_files = list(sample_docs_dir.glob("*.md")) + list(sample_docs_dir.glob("*.txt"))
        if not doc_files:
            logger.warning(f"No documents found in {sample_docs_dir}")
            return
        
        logger.info(f"Loading {len(doc_files)} documents from {sample_docs_dir}...")
        
        for doc_file in doc_files:
            try:
                with open(doc_file, "r", encoding="utf-8", errors="ignore") as f:
                    content = f.read()
                
                # Semantic chunking: split by sections and paragraphs
                sections = content.split("##")
                for section_idx, section in enumerate(sections):
                    # Split each section into paragraphs
                    paragraphs = section.split("\n\n")
                    
                    # Group smaller paragraphs together, keep larger ones separate
                    current_chunk = ""
                    for para in paragraphs:
                        para = para.strip()
                        if not para:
                            continue
                        
                        if len(current_chunk) + len(para) < 1000:  # Combine paragraphs up to 1000 chars
                            if current_chunk:
                                current_chunk += "\n\n"
                            current_chunk += para
                        else:
                            if current_chunk:
                                self.chunks.append(current_chunk)
                                self.chunk_metadata.append({
                                    "source": doc_file.name,
                                    "section": section_idx,
                                })
                            current_chunk = para
                    
                    if current_chunk:
                        self.chunks.append(current_chunk)
                        self.chunk_metadata.append({
                            "source": doc_file.name,
                            "section": section_idx,
                        })
                
                logger.info(f"  βœ… Loaded {doc_file.name} ({len(content)} chars β†’ {len([c for c in self.chunks if c.split('###')[0].startswith(doc_file.name.split('.')[0][:20])])} semantic chunks)")
            except Exception as e:
                logger.warning(f"Failed to load {doc_file.name}: {e}")
        
        # Embed all chunks
        if self.chunks:
            try:
                logger.info(f"Embedding {len(self.chunks)} chunks...")
                embeddings = self._embed_batch(self.chunks)
                vectors = np.array(embeddings, dtype="float32")
                faiss.normalize_L2(vectors)
                self.index.add(vectors)
                logger.info(f"βœ… Built FAISS index with {self.index.ntotal} vectors from {len(self.chunks)} chunks")
            except Exception as e:
                logger.error(f"Failed to embed chunks: {e}")
                self.chunks = []  # Clear on failure
        else:
            logger.warning("No chunks extracted from documents")

    def add_documents(self, chunks: list[str], metadata: list[dict] = None):
        """Add document chunks to the FAISS index."""
        if not chunks:
            return
        logger.info(f"Embedding {len(chunks)} chunks for FAISS index...")
        embeddings = self._embed_batch(chunks)
        vectors = np.array(embeddings, dtype="float32")
        # Normalize for cosine similarity
        faiss.normalize_L2(vectors)
        self.index.add(vectors)
        self.chunks.extend(chunks)
        self.chunk_metadata.extend(metadata or [{} for _ in chunks])
        logger.info(f"FAISS index now has {self.index.ntotal} vectors")

    def query(self, question: str, top_k: int = TOP_K) -> RAGResult:
        """
        Full RAG pipeline for a single question.
        Returns answer + full token accounting.
        """
        t0 = time.time()

        # 1. Embed the query
        try:
            query_embedding = self._embed(question)
            if not query_embedding or len(query_embedding) == 0:
                raise ValueError("Empty embedding returned")
            query_vec = np.array([query_embedding], dtype="float32")
            faiss.normalize_L2(query_vec)
        except Exception as e:
            logger.error(f"Embedding failed: {e}. Using fallback retrieval.")
            query_vec = None

        # 2. Retrieve top-K similar chunks
        retrieved = []
        if query_vec is not None and self.index.ntotal > 0:
            try:
                distances, indices = self.index.search(query_vec, min(top_k, self.index.ntotal))
                retrieved = [self.chunks[i] for i in indices[0] if i < len(self.chunks)]
                logger.info(f"βœ… Retrieved {len(retrieved)} chunks from FAISS (ntotal: {self.index.ntotal})")
            except Exception as e:
                logger.error(f"FAISS search failed: {e}. Falling back to first chunks.")
                retrieved = self.chunks[:top_k]
        elif self.chunks:
            # Fallback: return first K chunks if embedding/search fails
            logger.warning(f"⚠️ Embedding or index unavailable. Using first {top_k} chunks as fallback.")
            retrieved = self.chunks[:top_k]
        else:
            retrieved = ["[No documents available. Please ingest documents first.]"]

        # 3. Build context (this is where tokens pile up)
        context = "\n\n---\n\n".join(retrieved)

        # 4. Build prompt
        system_prompt = (
            "You are an expert assistant. Use the provided context when available. "
            "If context is insufficient, leverage your knowledge to provide a helpful answer. "
            "Always prioritize context when it's available."
        )
        user_prompt = f"""Context from documents:
{context}

Question: {question}

Provide a comprehensive answer using the context if available, or general knowledge:"""

        # 5. Call Gemini via shared client (accurate token counts)
        result = gemini_generate(
            system_prompt=system_prompt,
            user_prompt=user_prompt,
            temperature=0.1,
            max_tokens=1000,
        )

        latency_ms = (time.time() - t0) * 1000

        return RAGResult(
            answer=result["answer"],
            retrieved_chunks=retrieved,
            prompt_tokens=result["prompt_tokens"],
            completion_tokens=result["completion_tokens"],
            total_tokens=result["total_tokens"],
            latency_ms=latency_ms,
            method="basic_rag",
        )

    def _embed(self, text: str) -> list[float]:
        """Embed a single text string using OpenAI, then sentence-transformers, then zeros."""
        # Try OpenAI first
        try:
            from openai import OpenAI
            openai_client = OpenAI()
            response = openai_client.embeddings.create(
                model=EMBEDDING_MODEL,
                input=text[:8191],
            )
            return response.data[0].embedding
        except Exception as e:
            logger.debug(f"OpenAI embedding failed: {e}")
        
        # Try sentence-transformers
        if USE_SENTENCE_TRANSFORMERS:
            try:
                model = _get_sentence_transformer()
                if model:
                    embedding = model.encode(text[:8191])
                    return embedding.tolist()
            except Exception as e:
                logger.debug(f"Sentence-transformers embedding failed: {e}")
        
        # Fallback to zeros
        logger.warning("Falling back to zero embeddings")
        return [0.0] * 384

    def _embed_batch(self, texts: list[str]) -> list[list[float]]:
        """Embed multiple texts efficiently using OpenAI, then sentence-transformers, then zeros."""
        # Try OpenAI first (batch of 100)
        try:
            from openai import OpenAI
            openai_client = OpenAI()
            all_embeddings = []
            batch_size = 100
            for i in range(0, len(texts), batch_size):
                batch = texts[i:i + batch_size]
                response = openai_client.embeddings.create(
                    model=EMBEDDING_MODEL,
                    input=[t[:8191] for t in batch],
                )
                all_embeddings.extend([item.embedding for item in response.data])
            return all_embeddings
        except Exception as e:
            logger.debug(f"OpenAI batch embedding failed: {e}")
        
        # Try sentence-transformers (local, no API call)
        if USE_SENTENCE_TRANSFORMERS:
            try:
                model = _get_sentence_transformer()
                if model:
                    embeddings = model.encode([t[:8191] for t in texts])
                    return embeddings.tolist()
            except Exception as e:
                logger.debug(f"Sentence-transformers batch embedding failed: {e}")
        
        # Fallback to zeros
        logger.warning(f"Falling back to zero embeddings for {len(texts)} texts")
        return [[0.0] * 384 for _ in texts]

    def count_tokens(self, text: str) -> int:
        from ..llm.gemini_client import count_tokens_gemini
        return count_tokens_gemini(text)

    def get_index_stats(self) -> dict:
        return {
            "total_chunks": len(self.chunks),
            "index_size": self.index.ntotal,
            "embedding_dim": EMBEDDING_DIM,
        }