Gilgarmesh commited on
Commit
02d9fc3
Β·
verified Β·
1 Parent(s): bd6e80f

Upload rag_app.py

Browse files
Files changed (1) hide show
  1. rag_app.py +859 -0
rag_app.py ADDED
@@ -0,0 +1,859 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG (Retrieval-Augmented Generation) Application
3
+ ==================================================
4
+ A full-featured RAG system with:
5
+ - Document processing (PDF, HTML, DOCX, TXT, MD)
6
+ - Vector database (ChromaDB with persistent storage)
7
+ - Hybrid search (semantic + BM25 keyword search)
8
+ - Conversation memory (last 10 exchanges)
9
+ - Streaming LLM responses with source citations
10
+ - Gradio-based conversational UI
11
+
12
+ Requirements (install via pip):
13
+ pip install chromadb sentence-transformers gradio openai pymupdf python-docx \
14
+ beautifulsoup4 rank_bm25 nltk tiktoken numpy
15
+
16
+ Usage:
17
+ 1. Place 50+ documents in a ./documents/ folder (PDF, HTML, DOCX, TXT, MD)
18
+ 2. Set your OpenAI API key: export OPENAI_API_KEY="sk-..."
19
+ 3. Run: python rag_app.py
20
+ 4. Open the Gradio URL in your browser
21
+ """
22
+
23
+ import os
24
+ import re
25
+ import json
26
+ import hashlib
27
+ import logging
28
+ import textwrap
29
+ from pathlib import Path
30
+ from typing import Optional
31
+ from dataclasses import dataclass, field
32
+ from collections import defaultdict
33
+
34
+ import numpy as np
35
+
36
+ # -- Document parsing --
37
+ import fitz # PyMuPDF
38
+ from docx import Document as DocxDocument
39
+ from bs4 import BeautifulSoup
40
+
41
+ # -- NLP / chunking --
42
+ import nltk
43
+ from nltk.tokenize import sent_tokenize
44
+
45
+ # -- Embeddings & vector DB --
46
+ from sentence_transformers import SentenceTransformer
47
+ import chromadb
48
+ from chromadb.config import Settings
49
+
50
+ # -- BM25 keyword search --
51
+ from rank_bm25 import BM25Okapi
52
+
53
+ # -- LLM --
54
+ import openai
55
+
56
+ # -- UI --
57
+ import gradio as gr
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Configuration
61
+ # ---------------------------------------------------------------------------
62
+
63
+ @dataclass
64
+ class Config:
65
+ """Central configuration for the RAG pipeline."""
66
+
67
+ # Paths
68
+ documents_dir: str = "./documents"
69
+ chroma_persist_dir: str = "./chroma_db"
70
+
71
+ # Chunking
72
+ chunk_size: int = 512 # target tokens per chunk (sentence-based)
73
+ chunk_overlap: int = 64 # overlap tokens between consecutive chunks
74
+ min_chunk_length: int = 40 # discard chunks shorter than this (chars)
75
+
76
+ # Embedding model (runs locally via sentence-transformers)
77
+ embedding_model: str = "all-MiniLM-L6-v2"
78
+ chroma_collection: str = "rag_docs"
79
+
80
+ # Retrieval
81
+ top_k_semantic: int = 20 # initial semantic retrieval
82
+ top_k_bm25: int = 20 # initial BM25 retrieval
83
+ top_k_final: int = 5 # after hybrid merge / re-rank
84
+
85
+ # Hybrid search weight (0 = pure BM25, 1 = pure semantic)
86
+ semantic_weight: float = 0.6
87
+
88
+ # LLM
89
+ openai_model: str = "gpt-4o-mini"
90
+ temperature: float = 0.2
91
+ max_context_tokens: int = 6000
92
+ system_prompt: str = textwrap.dedent("""\
93
+ You are a knowledgeable assistant. Answer the user's question using ONLY
94
+ the provided context passages. If the context does not contain enough
95
+ information, say so honestly.
96
+
97
+ Rules:
98
+ - Cite sources using [Source N] notation after each claim.
99
+ - Be concise but thorough.
100
+ - If multiple sources agree, prefer the most specific one.
101
+ - For follow-up questions, use conversation history for context.
102
+ """)
103
+
104
+ # Conversation memory
105
+ memory_length: int = 10 # number of past exchanges to keep
106
+
107
+ # Server
108
+ server_port: int = 7860
109
+ share: bool = True # set True for public URL via Gradio
110
+
111
+
112
+ CFG = Config()
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Logging
116
+ # ---------------------------------------------------------------------------
117
+
118
+ logging.basicConfig(
119
+ level=logging.INFO,
120
+ format="%(asctime)s | %(levelname)-7s | %(message)s",
121
+ datefmt="%H:%M:%S",
122
+ )
123
+ log = logging.getLogger("rag")
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # 1. Document Processing
127
+ # ---------------------------------------------------------------------------
128
+
129
+ @dataclass
130
+ class RawDocument:
131
+ """A single extracted document before chunking."""
132
+ text: str
133
+ metadata: dict = field(default_factory=dict)
134
+
135
+
136
+ def extract_pdf(path: str) -> RawDocument:
137
+ """Extract text and metadata from a PDF using PyMuPDF."""
138
+ doc = fitz.open(path)
139
+ pages = []
140
+ for page in doc:
141
+ pages.append(page.get_text("text"))
142
+ meta = doc.metadata or {}
143
+ return RawDocument(
144
+ text="\n\n".join(pages),
145
+ metadata={
146
+ "source": os.path.basename(path),
147
+ "path": path,
148
+ "type": "pdf",
149
+ "title": meta.get("title", ""),
150
+ "author": meta.get("author", ""),
151
+ "pages": len(doc),
152
+ },
153
+ )
154
+
155
+
156
+ def extract_docx(path: str) -> RawDocument:
157
+ """Extract text from a DOCX file."""
158
+ doc = DocxDocument(path)
159
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
160
+ core = doc.core_properties
161
+ return RawDocument(
162
+ text="\n\n".join(paragraphs),
163
+ metadata={
164
+ "source": os.path.basename(path),
165
+ "path": path,
166
+ "type": "docx",
167
+ "title": core.title or "",
168
+ "author": core.author or "",
169
+ },
170
+ )
171
+
172
+
173
+ def extract_html(path: str) -> RawDocument:
174
+ """Extract text from an HTML file."""
175
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
176
+ soup = BeautifulSoup(f.read(), "html.parser")
177
+ # Remove script and style elements
178
+ for tag in soup(["script", "style", "nav", "footer", "header"]):
179
+ tag.decompose()
180
+ title = soup.title.string if soup.title else ""
181
+ text = soup.get_text(separator="\n", strip=True)
182
+ return RawDocument(
183
+ text=text,
184
+ metadata={
185
+ "source": os.path.basename(path),
186
+ "path": path,
187
+ "type": "html",
188
+ "title": title,
189
+ },
190
+ )
191
+
192
+
193
+ def extract_text(path: str) -> RawDocument:
194
+ """Extract text from a plain text or markdown file."""
195
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
196
+ text = f.read()
197
+ return RawDocument(
198
+ text=text,
199
+ metadata={
200
+ "source": os.path.basename(path),
201
+ "path": path,
202
+ "type": "text",
203
+ },
204
+ )
205
+
206
+
207
+ EXTRACTORS = {
208
+ ".pdf": extract_pdf,
209
+ ".docx": extract_docx,
210
+ ".html": extract_html,
211
+ ".htm": extract_html,
212
+ ".txt": extract_text,
213
+ ".md": extract_text,
214
+ }
215
+
216
+
217
+ def load_documents(directory: str) -> list[RawDocument]:
218
+ """Recursively load all supported documents from a directory."""
219
+ docs = []
220
+ directory = Path(directory)
221
+ if not directory.exists():
222
+ log.warning(f"Documents directory not found: {directory}")
223
+ return docs
224
+
225
+ for fpath in sorted(directory.rglob("*")):
226
+ ext = fpath.suffix.lower()
227
+ if ext in EXTRACTORS:
228
+ try:
229
+ doc = EXTRACTORS[ext](str(fpath))
230
+ if len(doc.text.strip()) > 50:
231
+ docs.append(doc)
232
+ log.info(f" Loaded: {fpath.name} ({len(doc.text):,} chars)")
233
+ except Exception as e:
234
+ log.error(f" Failed: {fpath.name} -> {e}")
235
+ log.info(f"Total documents loaded: {len(docs)}")
236
+ return docs
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # 2. Smart Chunking (Sentence-Based with Overlap)
240
+ # ---------------------------------------------------------------------------
241
+
242
+ @dataclass
243
+ class Chunk:
244
+ """A text chunk ready for embedding."""
245
+ text: str
246
+ metadata: dict
247
+ chunk_id: str
248
+
249
+
250
+ def _approx_token_count(text: str) -> int:
251
+ """Rough token count (β‰ˆ 4 chars per token for English)."""
252
+ return len(text) // 4
253
+
254
+
255
+ def sentence_chunk(doc: RawDocument, chunk_size: int = 512, overlap: int = 64) -> list[Chunk]:
256
+ """
257
+ Sentence-based chunking strategy:
258
+ - Split text into sentences.
259
+ - Accumulate sentences until chunk_size tokens is reached.
260
+ - Overlap by re-including trailing sentences from previous chunk.
261
+ """
262
+ try:
263
+ sentences = sent_tokenize(doc.text)
264
+ except Exception:
265
+ nltk.download("punkt_tab", quiet=True)
266
+ sentences = sent_tokenize(doc.text)
267
+
268
+ if not sentences:
269
+ return []
270
+
271
+ chunks: list[Chunk] = []
272
+ current_sentences: list[str] = []
273
+ current_tokens = 0
274
+
275
+ def _flush(sents: list[str], idx: int):
276
+ text = " ".join(sents).strip()
277
+ if len(text) < CFG.min_chunk_length:
278
+ return
279
+ chunk_id = hashlib.md5(
280
+ f"{doc.metadata.get('source', '')}:{idx}:{text[:80]}".encode()
281
+ ).hexdigest()[:12]
282
+ chunks.append(Chunk(
283
+ text=text,
284
+ metadata={**doc.metadata, "chunk_index": idx},
285
+ chunk_id=chunk_id,
286
+ ))
287
+
288
+ chunk_idx = 0
289
+ for sent in sentences:
290
+ sent_tokens = _approx_token_count(sent)
291
+ if current_tokens + sent_tokens > chunk_size and current_sentences:
292
+ _flush(current_sentences, chunk_idx)
293
+ chunk_idx += 1
294
+ # Keep overlap sentences from the tail
295
+ overlap_sents: list[str] = []
296
+ overlap_tok = 0
297
+ for s in reversed(current_sentences):
298
+ t = _approx_token_count(s)
299
+ if overlap_tok + t > overlap:
300
+ break
301
+ overlap_sents.insert(0, s)
302
+ overlap_tok += t
303
+ current_sentences = overlap_sents
304
+ current_tokens = overlap_tok
305
+ current_sentences.append(sent)
306
+ current_tokens += sent_tokens
307
+
308
+ if current_sentences:
309
+ _flush(current_sentences, chunk_idx)
310
+
311
+ return chunks
312
+
313
+
314
+ def chunk_all_documents(docs: list[RawDocument]) -> list[Chunk]:
315
+ """Chunk every loaded document."""
316
+ all_chunks = []
317
+ for doc in docs:
318
+ doc_chunks = sentence_chunk(doc, CFG.chunk_size, CFG.chunk_overlap)
319
+ all_chunks.extend(doc_chunks)
320
+ log.info(f"Total chunks created: {len(all_chunks)}")
321
+ return all_chunks
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # 3. Vector Database (ChromaDB with Persistent Storage)
325
+ # ---------------------------------------------------------------------------
326
+
327
+ class VectorStore:
328
+ """Manages ChromaDB collection and embedding model."""
329
+
330
+ def __init__(self, config: Config):
331
+ self.config = config
332
+ log.info(f"Loading embedding model: {config.embedding_model}")
333
+ self.embedder = SentenceTransformer(config.embedding_model)
334
+
335
+ self.client = chromadb.Client(Settings(
336
+ persist_directory=config.chroma_persist_dir,
337
+ anonymized_telemetry=False,
338
+ is_persistent=True,
339
+ ))
340
+ self.collection = self.client.get_or_create_collection(
341
+ name=config.chroma_collection,
342
+ metadata={"hnsw:space": "cosine"},
343
+ )
344
+ log.info(
345
+ f"ChromaDB collection '{config.chroma_collection}' "
346
+ f"has {self.collection.count()} vectors"
347
+ )
348
+
349
+ def embed_text(self, texts: list[str]) -> list[list[float]]:
350
+ """Generate embeddings for a list of texts."""
351
+ return self.embedder.encode(texts, show_progress_bar=False).tolist()
352
+
353
+ def embed_single(self, text: str) -> list[float]:
354
+ """Embed a single query string."""
355
+ return self.embedder.encode(text).tolist()
356
+
357
+ def add_chunks(self, chunks: list[Chunk], batch_size: int = 256):
358
+ """Insert chunks into ChromaDB (skip duplicates by ID)."""
359
+ existing = set(self.collection.get()["ids"]) if self.collection.count() > 0 else set()
360
+ new_chunks = [c for c in chunks if c.chunk_id not in existing]
361
+ if not new_chunks:
362
+ log.info("No new chunks to add (all already indexed).")
363
+ return
364
+
365
+ for i in range(0, len(new_chunks), batch_size):
366
+ batch = new_chunks[i : i + batch_size]
367
+ ids = [c.chunk_id for c in batch]
368
+ texts = [c.text for c in batch]
369
+ metas = [c.metadata for c in batch]
370
+ embeddings = self.embed_text(texts)
371
+ self.collection.add(
372
+ ids=ids,
373
+ documents=texts,
374
+ metadatas=metas,
375
+ embeddings=embeddings,
376
+ )
377
+ log.info(f" Indexed batch {i // batch_size + 1} ({len(batch)} chunks)")
378
+
379
+ log.info(f"Total vectors in DB: {self.collection.count()}")
380
+
381
+ def semantic_search(self, query: str, k: int = 20) -> list[dict]:
382
+ """Return top-k results by cosine similarity."""
383
+ count = self.collection.count()
384
+ if count == 0:
385
+ return []
386
+ embedding = self.embed_single(query)
387
+ results = self.collection.query(
388
+ query_embeddings=[embedding],
389
+ n_results=min(k, count),
390
+ include=["documents", "metadatas", "distances"],
391
+ )
392
+ hits = []
393
+ if results["documents"] and results["documents"][0]:
394
+ for doc, meta, dist in zip(
395
+ results["documents"][0],
396
+ results["metadatas"][0],
397
+ results["distances"][0],
398
+ ):
399
+ hits.append({
400
+ "text": doc,
401
+ "metadata": meta,
402
+ "score": 1 - dist, # cosine distance -> similarity
403
+ })
404
+ return hits
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # 4. BM25 Keyword Search (for Hybrid Retrieval)
408
+ # ---------------------------------------------------------------------------
409
+
410
+ class BM25Index:
411
+ """Maintains a BM25 index over all chunk texts."""
412
+
413
+ def __init__(self):
414
+ self.corpus: list[str] = []
415
+ self.metadata: list[dict] = []
416
+ self.bm25: Optional[BM25Okapi] = None
417
+
418
+ def build(self, chunks: list[Chunk]):
419
+ """Build BM25 index from chunks."""
420
+ self.corpus = [c.text for c in chunks]
421
+ self.metadata = [c.metadata for c in chunks]
422
+ tokenized = [self._tokenize(t) for t in self.corpus]
423
+ self.bm25 = BM25Okapi(tokenized)
424
+ log.info(f"BM25 index built over {len(self.corpus)} chunks")
425
+
426
+ @staticmethod
427
+ def _tokenize(text: str) -> list[str]:
428
+ return re.findall(r"\w+", text.lower())
429
+
430
+ def search(self, query: str, k: int = 20) -> list[dict]:
431
+ """Return top-k BM25 results."""
432
+ if self.bm25 is None:
433
+ return []
434
+ tokens = self._tokenize(query)
435
+ scores = self.bm25.get_scores(tokens)
436
+ top_idx = np.argsort(scores)[::-1][:k]
437
+ results = []
438
+ for idx in top_idx:
439
+ if scores[idx] > 0:
440
+ results.append({
441
+ "text": self.corpus[idx],
442
+ "metadata": self.metadata[idx],
443
+ "score": float(scores[idx]),
444
+ })
445
+ return results
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # 5. Hybrid Search: Merge Semantic + BM25 with RRF
449
+ # ---------------------------------------------------------------------------
450
+
451
+ def reciprocal_rank_fusion(
452
+ semantic_hits: list[dict],
453
+ bm25_hits: list[dict],
454
+ semantic_weight: float = 0.6,
455
+ k_constant: int = 60,
456
+ top_k: int = 5,
457
+ ) -> list[dict]:
458
+ """
459
+ Reciprocal Rank Fusion (RRF) to merge two ranked lists.
460
+ score(doc) = w_s / (k + rank_semantic) + w_b / (k + rank_bm25)
461
+ """
462
+ scores: dict[str, float] = defaultdict(float)
463
+ doc_map: dict[str, dict] = {}
464
+
465
+ bm25_weight = 1.0 - semantic_weight
466
+
467
+ for rank, hit in enumerate(semantic_hits, start=1):
468
+ key = hit["text"][:200] # use text prefix as dedup key
469
+ scores[key] += semantic_weight / (k_constant + rank)
470
+ doc_map[key] = hit
471
+
472
+ for rank, hit in enumerate(bm25_hits, start=1):
473
+ key = hit["text"][:200]
474
+ scores[key] += bm25_weight / (k_constant + rank)
475
+ if key not in doc_map:
476
+ doc_map[key] = hit
477
+
478
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
479
+ results = []
480
+ for key, score in ranked:
481
+ entry = doc_map[key].copy()
482
+ entry["hybrid_score"] = score
483
+ results.append(entry)
484
+ return results
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # 6. Conversation Memory
488
+ # ---------------------------------------------------------------------------
489
+
490
+ class ConversationMemory:
491
+ """Tracks the last N exchanges for multi-turn support."""
492
+
493
+ def __init__(self, max_turns: int = 10):
494
+ self.max_turns = max_turns
495
+ self.history: list[dict] = [] # [{"role": "user"/"assistant", "content": ...}]
496
+
497
+ def add_user(self, message: str):
498
+ self.history.append({"role": "user", "content": message})
499
+ self._trim()
500
+
501
+ def add_assistant(self, message: str):
502
+ self.history.append({"role": "assistant", "content": message})
503
+ self._trim()
504
+
505
+ def _trim(self):
506
+ # Keep last N *exchanges* (each exchange = 2 messages)
507
+ max_messages = self.max_turns * 2
508
+ if len(self.history) > max_messages:
509
+ self.history = self.history[-max_messages:]
510
+
511
+ def get_messages(self) -> list[dict]:
512
+ return list(self.history)
513
+
514
+ def get_context_summary(self) -> str:
515
+ """Produce a short summary for query rewriting."""
516
+ if not self.history:
517
+ return ""
518
+ recent = self.history[-6:] # last 3 exchanges
519
+ lines = []
520
+ for msg in recent:
521
+ role = "User" if msg["role"] == "user" else "Assistant"
522
+ # Truncate long assistant replies
523
+ content = msg["content"][:300]
524
+ lines.append(f"{role}: {content}")
525
+ return "\n".join(lines)
526
+
527
+ def clear(self):
528
+ self.history.clear()
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # 7. RAG Pipeline (Query β†’ Retrieve β†’ Generate)
532
+ # ---------------------------------------------------------------------------
533
+
534
+ class RAGPipeline:
535
+ """Orchestrates the full RAG pipeline."""
536
+
537
+ def __init__(self, config: Config):
538
+ self.config = config
539
+ self.vector_store = VectorStore(config)
540
+ self.bm25_index = BM25Index()
541
+ self.memory = ConversationMemory(max_turns=config.memory_length)
542
+ self.openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY", ""))
543
+
544
+ # -- Indexing ----------------------------------------------------------
545
+
546
+ def index_documents(self, docs_dir: Optional[str] = None):
547
+ """Load, chunk, and index all documents."""
548
+ directory = docs_dir or self.config.documents_dir
549
+ raw_docs = load_documents(directory)
550
+ if not raw_docs:
551
+ log.warning("No documents found. Please add files to the documents folder.")
552
+ return
553
+
554
+ chunks = chunk_all_documents(raw_docs)
555
+ self.vector_store.add_chunks(chunks)
556
+ self.bm25_index.build(chunks)
557
+ log.info("Indexing complete.")
558
+
559
+ # -- Query rewriting for follow-ups ------------------------------------
560
+
561
+ def _rewrite_query(self, user_query: str) -> str:
562
+ """Use conversation context to make follow-up queries self-contained."""
563
+ context = self.memory.get_context_summary()
564
+ if not context:
565
+ return user_query
566
+
567
+ try:
568
+ response = self.openai_client.chat.completions.create(
569
+ model=self.config.openai_model,
570
+ temperature=0,
571
+ max_tokens=200,
572
+ messages=[
573
+ {
574
+ "role": "system",
575
+ "content": (
576
+ "Rewrite the user's latest question so it is self-contained, "
577
+ "incorporating any necessary context from the conversation. "
578
+ "Output ONLY the rewritten question, nothing else."
579
+ ),
580
+ },
581
+ {
582
+ "role": "user",
583
+ "content": f"Conversation:\n{context}\n\nLatest question: {user_query}",
584
+ },
585
+ ],
586
+ )
587
+ rewritten = response.choices[0].message.content.strip()
588
+ if rewritten:
589
+ log.info(f"Rewritten query: {rewritten}")
590
+ return rewritten
591
+ except Exception as e:
592
+ log.warning(f"Query rewriting failed: {e}")
593
+ return user_query
594
+
595
+ # -- Retrieval ---------------------------------------------------------
596
+
597
+ def retrieve(self, query: str) -> list[dict]:
598
+ """Hybrid retrieval: semantic + BM25 merged via RRF."""
599
+ semantic_hits = self.vector_store.semantic_search(
600
+ query, k=self.config.top_k_semantic
601
+ )
602
+ bm25_hits = self.bm25_index.search(query, k=self.config.top_k_bm25)
603
+
604
+ merged = reciprocal_rank_fusion(
605
+ semantic_hits,
606
+ bm25_hits,
607
+ semantic_weight=self.config.semantic_weight,
608
+ top_k=self.config.top_k_final,
609
+ )
610
+ return merged
611
+
612
+ # -- Generation --------------------------------------------------------
613
+
614
+ def generate(self, user_query: str, retrieved_chunks: list[dict]) -> str:
615
+ """Call the LLM with retrieved context and conversation history."""
616
+
617
+ # Build context block with source labels
618
+ context_parts = []
619
+ for i, chunk in enumerate(retrieved_chunks, 1):
620
+ source = chunk["metadata"].get("source", "unknown")
621
+ title = chunk["metadata"].get("title", "")
622
+ label = f"[Source {i}: {source}"
623
+ if title:
624
+ label += f" β€” {title}"
625
+ label += "]"
626
+ context_parts.append(f"{label}\n{chunk['text']}")
627
+
628
+ context_block = "\n\n---\n\n".join(context_parts)
629
+
630
+ # Assemble messages
631
+ messages = [{"role": "system", "content": self.config.system_prompt}]
632
+
633
+ # Add conversation history
634
+ messages.extend(self.memory.get_messages())
635
+
636
+ # Add current turn with context
637
+ user_content = (
638
+ f"Context passages:\n\n{context_block}\n\n"
639
+ f"---\n\nQuestion: {user_query}"
640
+ )
641
+ messages.append({"role": "user", "content": user_content})
642
+
643
+ try:
644
+ response = self.openai_client.chat.completions.create(
645
+ model=self.config.openai_model,
646
+ temperature=self.config.temperature,
647
+ max_tokens=1500,
648
+ messages=messages,
649
+ )
650
+ return response.choices[0].message.content
651
+ except Exception as e:
652
+ return f"LLM generation error: {e}"
653
+
654
+ # -- Full pipeline -----------------------------------------------------
655
+
656
+ def query(self, user_input: str) -> tuple[str, list[dict]]:
657
+ """
658
+ Full RAG pipeline:
659
+ 1. Rewrite query using conversation context
660
+ 2. Hybrid retrieve top-K chunks
661
+ 3. Generate answer with citations
662
+ 4. Update memory
663
+ Returns (answer_text, retrieved_sources)
664
+ """
665
+ # Step 1: Rewrite for follow-ups
666
+ search_query = self._rewrite_query(user_input)
667
+
668
+ # Step 2: Retrieve
669
+ chunks = self.retrieve(search_query)
670
+ if not chunks:
671
+ answer = (
672
+ "I couldn't find any relevant information in the document collection "
673
+ "to answer your question. Could you rephrase or ask about a different topic?"
674
+ )
675
+ self.memory.add_user(user_input)
676
+ self.memory.add_assistant(answer)
677
+ return answer, []
678
+
679
+ # Step 3: Generate
680
+ answer = self.generate(user_input, chunks)
681
+
682
+ # Step 4: Update memory
683
+ self.memory.add_user(user_input)
684
+ self.memory.add_assistant(answer)
685
+
686
+ return answer, chunks
687
+
688
+ def reset_conversation(self):
689
+ """Clear conversation history."""
690
+ self.memory.clear()
691
+ return "Conversation history cleared."
692
+
693
+ # ---------------------------------------------------------------------------
694
+ # 8. Gradio Conversational UI
695
+ # ---------------------------------------------------------------------------
696
+
697
+ def build_ui(pipeline: RAGPipeline) -> gr.Blocks:
698
+ """Create the Gradio chat interface with source citations."""
699
+
700
+ CUSTOM_CSS = """
701
+ .gradio-container {
702
+ max-width: 960px !important;
703
+ margin: auto !important;
704
+ font-family: 'Segoe UI', system-ui, sans-serif !important;
705
+ }
706
+ .source-card {
707
+ background: #f8f9fa;
708
+ border-left: 3px solid #4a90d9;
709
+ padding: 10px 14px;
710
+ margin: 6px 0;
711
+ border-radius: 4px;
712
+ font-size: 0.88em;
713
+ line-height: 1.5;
714
+ }
715
+ .source-card strong { color: #2c5282; }
716
+ .status-bar {
717
+ text-align: center;
718
+ padding: 6px;
719
+ font-size: 0.85em;
720
+ color: #718096;
721
+ }
722
+ """
723
+
724
+ with gr.Blocks(css=CUSTOM_CSS, title="RAG Assistant", theme=gr.themes.Soft()) as demo:
725
+ gr.Markdown(
726
+ "# πŸ“š RAG Document Assistant\n"
727
+ "Ask questions about the indexed document collection. "
728
+ "Sources are cited inline and shown below each answer."
729
+ )
730
+
731
+ chatbot = gr.Chatbot(
732
+ label="Conversation",
733
+ height=520,
734
+ show_copy_button=True,
735
+ bubble_full_width=False,
736
+ avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/robot_1f916.png"),
737
+ )
738
+
739
+ sources_display = gr.HTML(
740
+ value='<div class="status-bar">Sources will appear here after each answer.</div>',
741
+ label="Retrieved Sources",
742
+ )
743
+
744
+ with gr.Row():
745
+ msg_input = gr.Textbox(
746
+ placeholder="Ask a question about your documents...",
747
+ show_label=False,
748
+ scale=9,
749
+ container=False,
750
+ )
751
+ send_btn = gr.Button("Send", variant="primary", scale=1)
752
+
753
+ with gr.Row():
754
+ clear_btn = gr.Button("πŸ—‘ Clear Chat", size="sm")
755
+ status = gr.Markdown(
756
+ f"*{pipeline.vector_store.collection.count()} chunks indexed "
757
+ f"| Hybrid search (semantic + BM25) "
758
+ f"| Memory: last {pipeline.config.memory_length} exchanges*"
759
+ )
760
+
761
+ # -- Event handlers ------------------------------------------------
762
+
763
+ def respond(user_message: str, chat_history: list):
764
+ if not user_message.strip():
765
+ return "", chat_history, ""
766
+
767
+ # Guard: check if any documents are indexed
768
+ if pipeline.vector_store.collection.count() == 0:
769
+ answer = (
770
+ "⚠️ No documents have been indexed yet. Please add at least 50 documents "
771
+ "(PDF, DOCX, HTML, TXT, or MD files) to the `./documents/` folder and "
772
+ "restart the application."
773
+ )
774
+ chat_history = chat_history + [[user_message, answer]]
775
+ return "", chat_history, '<div class="status-bar">No documents indexed.</div>'
776
+
777
+ answer, sources = pipeline.query(user_message)
778
+ chat_history = chat_history + [[user_message, answer]]
779
+
780
+ # Format sources as HTML cards
781
+ if sources:
782
+ cards = []
783
+ for i, s in enumerate(sources, 1):
784
+ src = s["metadata"].get("source", "unknown")
785
+ title = s["metadata"].get("title", "")
786
+ score = s.get("hybrid_score", s.get("score", 0))
787
+ preview = s["text"][:250].replace("\n", " ") + "..."
788
+ card = (
789
+ f'<div class="source-card">'
790
+ f"<strong>[Source {i}]</strong> {src}"
791
+ f"{f' β€” <em>{title}</em>' if title else ''}"
792
+ f" (score: {score:.4f})<br>"
793
+ f"<span style='color:#555'>{preview}</span>"
794
+ f"</div>"
795
+ )
796
+ cards.append(card)
797
+ sources_html = "".join(cards)
798
+ else:
799
+ sources_html = '<div class="status-bar">No relevant sources found.</div>'
800
+
801
+ return "", chat_history, sources_html
802
+
803
+ def clear_chat():
804
+ pipeline.reset_conversation()
805
+ return [], '<div class="status-bar">Conversation cleared. Sources will appear here.</div>'
806
+
807
+ # Wire events
808
+ msg_input.submit(respond, [msg_input, chatbot], [msg_input, chatbot, sources_display])
809
+ send_btn.click(respond, [msg_input, chatbot], [msg_input, chatbot, sources_display])
810
+ clear_btn.click(clear_chat, outputs=[chatbot, sources_display])
811
+
812
+ return demo
813
+
814
+ # ---------------------------------------------------------------------------
815
+ # 9. Main Entry Point
816
+ # ---------------------------------------------------------------------------
817
+
818
+ def main():
819
+ """Initialize the pipeline, index documents, and launch the UI."""
820
+ log.info("=" * 60)
821
+ log.info("RAG Application Starting")
822
+ log.info("=" * 60)
823
+
824
+ # Ensure NLTK data is available
825
+ try:
826
+ sent_tokenize("Hello world.")
827
+ except LookupError:
828
+ nltk.download("punkt_tab", quiet=True)
829
+
830
+ # Validate API key
831
+ api_key = os.getenv("OPENAI_API_KEY", "")
832
+ if not api_key:
833
+ log.warning(
834
+ "OPENAI_API_KEY not set. LLM generation will fail. "
835
+ "Set it with: export OPENAI_API_KEY='sk-...'"
836
+ )
837
+
838
+ # Create documents directory if needed
839
+ os.makedirs(CFG.documents_dir, exist_ok=True)
840
+
841
+ # Initialize pipeline
842
+ pipeline = RAGPipeline(CFG)
843
+
844
+ # Index documents (idempotent β€” skips already-indexed chunks)
845
+ pipeline.index_documents()
846
+
847
+ # Build and launch UI
848
+ demo = build_ui(pipeline)
849
+ log.info(f"Launching Gradio on port {CFG.server_port} (share={CFG.share})")
850
+ demo.launch(
851
+ server_name="0.0.0.0",
852
+ server_port=CFG.server_port,
853
+ share=CFG.share,
854
+ show_error=True,
855
+ )
856
+
857
+
858
+ if __name__ == "__main__":
859
+ main()