Gilgarmesh commited on
Commit
8e09a83
Β·
verified Β·
1 Parent(s): a05ca32

Upload 2 files

Browse files
Files changed (2) hide show
  1. rag_app.py +845 -0
  2. requirements.txt +12 -0
rag_app.py ADDED
@@ -0,0 +1,845 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ embedding = self.embed_single(query)
384
+ results = self.collection.query(
385
+ query_embeddings=[embedding],
386
+ n_results=min(k, self.collection.count()),
387
+ include=["documents", "metadatas", "distances"],
388
+ )
389
+ hits = []
390
+ for doc, meta, dist in zip(
391
+ results["documents"][0],
392
+ results["metadatas"][0],
393
+ results["distances"][0],
394
+ ):
395
+ hits.append({
396
+ "text": doc,
397
+ "metadata": meta,
398
+ "score": 1 - dist, # cosine distance -> similarity
399
+ })
400
+ return hits
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # 4. BM25 Keyword Search (for Hybrid Retrieval)
404
+ # ---------------------------------------------------------------------------
405
+
406
+ class BM25Index:
407
+ """Maintains a BM25 index over all chunk texts."""
408
+
409
+ def __init__(self):
410
+ self.corpus: list[str] = []
411
+ self.metadata: list[dict] = []
412
+ self.bm25: Optional[BM25Okapi] = None
413
+
414
+ def build(self, chunks: list[Chunk]):
415
+ """Build BM25 index from chunks."""
416
+ self.corpus = [c.text for c in chunks]
417
+ self.metadata = [c.metadata for c in chunks]
418
+ tokenized = [self._tokenize(t) for t in self.corpus]
419
+ self.bm25 = BM25Okapi(tokenized)
420
+ log.info(f"BM25 index built over {len(self.corpus)} chunks")
421
+
422
+ @staticmethod
423
+ def _tokenize(text: str) -> list[str]:
424
+ return re.findall(r"\w+", text.lower())
425
+
426
+ def search(self, query: str, k: int = 20) -> list[dict]:
427
+ """Return top-k BM25 results."""
428
+ if self.bm25 is None:
429
+ return []
430
+ tokens = self._tokenize(query)
431
+ scores = self.bm25.get_scores(tokens)
432
+ top_idx = np.argsort(scores)[::-1][:k]
433
+ results = []
434
+ for idx in top_idx:
435
+ if scores[idx] > 0:
436
+ results.append({
437
+ "text": self.corpus[idx],
438
+ "metadata": self.metadata[idx],
439
+ "score": float(scores[idx]),
440
+ })
441
+ return results
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # 5. Hybrid Search: Merge Semantic + BM25 with RRF
445
+ # ---------------------------------------------------------------------------
446
+
447
+ def reciprocal_rank_fusion(
448
+ semantic_hits: list[dict],
449
+ bm25_hits: list[dict],
450
+ semantic_weight: float = 0.6,
451
+ k_constant: int = 60,
452
+ top_k: int = 5,
453
+ ) -> list[dict]:
454
+ """
455
+ Reciprocal Rank Fusion (RRF) to merge two ranked lists.
456
+ score(doc) = w_s / (k + rank_semantic) + w_b / (k + rank_bm25)
457
+ """
458
+ scores: dict[str, float] = defaultdict(float)
459
+ doc_map: dict[str, dict] = {}
460
+
461
+ bm25_weight = 1.0 - semantic_weight
462
+
463
+ for rank, hit in enumerate(semantic_hits, start=1):
464
+ key = hit["text"][:200] # use text prefix as dedup key
465
+ scores[key] += semantic_weight / (k_constant + rank)
466
+ doc_map[key] = hit
467
+
468
+ for rank, hit in enumerate(bm25_hits, start=1):
469
+ key = hit["text"][:200]
470
+ scores[key] += bm25_weight / (k_constant + rank)
471
+ if key not in doc_map:
472
+ doc_map[key] = hit
473
+
474
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
475
+ results = []
476
+ for key, score in ranked:
477
+ entry = doc_map[key].copy()
478
+ entry["hybrid_score"] = score
479
+ results.append(entry)
480
+ return results
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # 6. Conversation Memory
484
+ # ---------------------------------------------------------------------------
485
+
486
+ class ConversationMemory:
487
+ """Tracks the last N exchanges for multi-turn support."""
488
+
489
+ def __init__(self, max_turns: int = 10):
490
+ self.max_turns = max_turns
491
+ self.history: list[dict] = [] # [{"role": "user"/"assistant", "content": ...}]
492
+
493
+ def add_user(self, message: str):
494
+ self.history.append({"role": "user", "content": message})
495
+ self._trim()
496
+
497
+ def add_assistant(self, message: str):
498
+ self.history.append({"role": "assistant", "content": message})
499
+ self._trim()
500
+
501
+ def _trim(self):
502
+ # Keep last N *exchanges* (each exchange = 2 messages)
503
+ max_messages = self.max_turns * 2
504
+ if len(self.history) > max_messages:
505
+ self.history = self.history[-max_messages:]
506
+
507
+ def get_messages(self) -> list[dict]:
508
+ return list(self.history)
509
+
510
+ def get_context_summary(self) -> str:
511
+ """Produce a short summary for query rewriting."""
512
+ if not self.history:
513
+ return ""
514
+ recent = self.history[-6:] # last 3 exchanges
515
+ lines = []
516
+ for msg in recent:
517
+ role = "User" if msg["role"] == "user" else "Assistant"
518
+ # Truncate long assistant replies
519
+ content = msg["content"][:300]
520
+ lines.append(f"{role}: {content}")
521
+ return "\n".join(lines)
522
+
523
+ def clear(self):
524
+ self.history.clear()
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # 7. RAG Pipeline (Query β†’ Retrieve β†’ Generate)
528
+ # ---------------------------------------------------------------------------
529
+
530
+ class RAGPipeline:
531
+ """Orchestrates the full RAG pipeline."""
532
+
533
+ def __init__(self, config: Config):
534
+ self.config = config
535
+ self.vector_store = VectorStore(config)
536
+ self.bm25_index = BM25Index()
537
+ self.memory = ConversationMemory(max_turns=config.memory_length)
538
+ self.openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY", ""))
539
+
540
+ # -- Indexing ----------------------------------------------------------
541
+
542
+ def index_documents(self, docs_dir: Optional[str] = None):
543
+ """Load, chunk, and index all documents."""
544
+ directory = docs_dir or self.config.documents_dir
545
+ raw_docs = load_documents(directory)
546
+ if not raw_docs:
547
+ log.warning("No documents found. Please add files to the documents folder.")
548
+ return
549
+
550
+ chunks = chunk_all_documents(raw_docs)
551
+ self.vector_store.add_chunks(chunks)
552
+ self.bm25_index.build(chunks)
553
+ log.info("Indexing complete.")
554
+
555
+ # -- Query rewriting for follow-ups ------------------------------------
556
+
557
+ def _rewrite_query(self, user_query: str) -> str:
558
+ """Use conversation context to make follow-up queries self-contained."""
559
+ context = self.memory.get_context_summary()
560
+ if not context:
561
+ return user_query
562
+
563
+ try:
564
+ response = self.openai_client.chat.completions.create(
565
+ model=self.config.openai_model,
566
+ temperature=0,
567
+ max_tokens=200,
568
+ messages=[
569
+ {
570
+ "role": "system",
571
+ "content": (
572
+ "Rewrite the user's latest question so it is self-contained, "
573
+ "incorporating any necessary context from the conversation. "
574
+ "Output ONLY the rewritten question, nothing else."
575
+ ),
576
+ },
577
+ {
578
+ "role": "user",
579
+ "content": f"Conversation:\n{context}\n\nLatest question: {user_query}",
580
+ },
581
+ ],
582
+ )
583
+ rewritten = response.choices[0].message.content.strip()
584
+ if rewritten:
585
+ log.info(f"Rewritten query: {rewritten}")
586
+ return rewritten
587
+ except Exception as e:
588
+ log.warning(f"Query rewriting failed: {e}")
589
+ return user_query
590
+
591
+ # -- Retrieval ---------------------------------------------------------
592
+
593
+ def retrieve(self, query: str) -> list[dict]:
594
+ """Hybrid retrieval: semantic + BM25 merged via RRF."""
595
+ semantic_hits = self.vector_store.semantic_search(
596
+ query, k=self.config.top_k_semantic
597
+ )
598
+ bm25_hits = self.bm25_index.search(query, k=self.config.top_k_bm25)
599
+
600
+ merged = reciprocal_rank_fusion(
601
+ semantic_hits,
602
+ bm25_hits,
603
+ semantic_weight=self.config.semantic_weight,
604
+ top_k=self.config.top_k_final,
605
+ )
606
+ return merged
607
+
608
+ # -- Generation --------------------------------------------------------
609
+
610
+ def generate(self, user_query: str, retrieved_chunks: list[dict]) -> str:
611
+ """Call the LLM with retrieved context and conversation history."""
612
+
613
+ # Build context block with source labels
614
+ context_parts = []
615
+ for i, chunk in enumerate(retrieved_chunks, 1):
616
+ source = chunk["metadata"].get("source", "unknown")
617
+ title = chunk["metadata"].get("title", "")
618
+ label = f"[Source {i}: {source}"
619
+ if title:
620
+ label += f" β€” {title}"
621
+ label += "]"
622
+ context_parts.append(f"{label}\n{chunk['text']}")
623
+
624
+ context_block = "\n\n---\n\n".join(context_parts)
625
+
626
+ # Assemble messages
627
+ messages = [{"role": "system", "content": self.config.system_prompt}]
628
+
629
+ # Add conversation history
630
+ messages.extend(self.memory.get_messages())
631
+
632
+ # Add current turn with context
633
+ user_content = (
634
+ f"Context passages:\n\n{context_block}\n\n"
635
+ f"---\n\nQuestion: {user_query}"
636
+ )
637
+ messages.append({"role": "user", "content": user_content})
638
+
639
+ try:
640
+ response = self.openai_client.chat.completions.create(
641
+ model=self.config.openai_model,
642
+ temperature=self.config.temperature,
643
+ max_tokens=1500,
644
+ messages=messages,
645
+ )
646
+ return response.choices[0].message.content
647
+ except Exception as e:
648
+ return f"LLM generation error: {e}"
649
+
650
+ # -- Full pipeline -----------------------------------------------------
651
+
652
+ def query(self, user_input: str) -> tuple[str, list[dict]]:
653
+ """
654
+ Full RAG pipeline:
655
+ 1. Rewrite query using conversation context
656
+ 2. Hybrid retrieve top-K chunks
657
+ 3. Generate answer with citations
658
+ 4. Update memory
659
+ Returns (answer_text, retrieved_sources)
660
+ """
661
+ # Step 1: Rewrite for follow-ups
662
+ search_query = self._rewrite_query(user_input)
663
+
664
+ # Step 2: Retrieve
665
+ chunks = self.retrieve(search_query)
666
+ if not chunks:
667
+ answer = (
668
+ "I couldn't find any relevant information in the document collection "
669
+ "to answer your question. Could you rephrase or ask about a different topic?"
670
+ )
671
+ self.memory.add_user(user_input)
672
+ self.memory.add_assistant(answer)
673
+ return answer, []
674
+
675
+ # Step 3: Generate
676
+ answer = self.generate(user_input, chunks)
677
+
678
+ # Step 4: Update memory
679
+ self.memory.add_user(user_input)
680
+ self.memory.add_assistant(answer)
681
+
682
+ return answer, chunks
683
+
684
+ def reset_conversation(self):
685
+ """Clear conversation history."""
686
+ self.memory.clear()
687
+ return "Conversation history cleared."
688
+
689
+ # ---------------------------------------------------------------------------
690
+ # 8. Gradio Conversational UI
691
+ # ---------------------------------------------------------------------------
692
+
693
+ def build_ui(pipeline: RAGPipeline) -> gr.Blocks:
694
+ """Create the Gradio chat interface with source citations."""
695
+
696
+ CUSTOM_CSS = """
697
+ .gradio-container {
698
+ max-width: 960px !important;
699
+ margin: auto !important;
700
+ font-family: 'Segoe UI', system-ui, sans-serif !important;
701
+ }
702
+ .source-card {
703
+ background: #f8f9fa;
704
+ border-left: 3px solid #4a90d9;
705
+ padding: 10px 14px;
706
+ margin: 6px 0;
707
+ border-radius: 4px;
708
+ font-size: 0.88em;
709
+ line-height: 1.5;
710
+ }
711
+ .source-card strong { color: #2c5282; }
712
+ .status-bar {
713
+ text-align: center;
714
+ padding: 6px;
715
+ font-size: 0.85em;
716
+ color: #718096;
717
+ }
718
+ """
719
+
720
+ with gr.Blocks(css=CUSTOM_CSS, title="RAG Assistant", theme=gr.themes.Soft()) as demo:
721
+ gr.Markdown(
722
+ "# πŸ“š RAG Document Assistant\n"
723
+ "Ask questions about the indexed document collection. "
724
+ "Sources are cited inline and shown below each answer."
725
+ )
726
+
727
+ chatbot = gr.Chatbot(
728
+ label="Conversation",
729
+ height=520,
730
+ show_copy_button=True,
731
+ bubble_full_width=False,
732
+ avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/robot_1f916.png"),
733
+ )
734
+
735
+ sources_display = gr.HTML(
736
+ value='<div class="status-bar">Sources will appear here after each answer.</div>',
737
+ label="Retrieved Sources",
738
+ )
739
+
740
+ with gr.Row():
741
+ msg_input = gr.Textbox(
742
+ placeholder="Ask a question about your documents...",
743
+ show_label=False,
744
+ scale=9,
745
+ container=False,
746
+ )
747
+ send_btn = gr.Button("Send", variant="primary", scale=1)
748
+
749
+ with gr.Row():
750
+ clear_btn = gr.Button("πŸ—‘ Clear Chat", size="sm")
751
+ status = gr.Markdown(
752
+ f"*{pipeline.vector_store.collection.count()} chunks indexed "
753
+ f"| Hybrid search (semantic + BM25) "
754
+ f"| Memory: last {pipeline.config.memory_length} exchanges*"
755
+ )
756
+
757
+ # -- Event handlers ------------------------------------------------
758
+
759
+ def respond(user_message: str, chat_history: list):
760
+ if not user_message.strip():
761
+ return "", chat_history, ""
762
+
763
+ answer, sources = pipeline.query(user_message)
764
+ chat_history = chat_history + [[user_message, answer]]
765
+
766
+ # Format sources as HTML cards
767
+ if sources:
768
+ cards = []
769
+ for i, s in enumerate(sources, 1):
770
+ src = s["metadata"].get("source", "unknown")
771
+ title = s["metadata"].get("title", "")
772
+ score = s.get("hybrid_score", s.get("score", 0))
773
+ preview = s["text"][:250].replace("\n", " ") + "..."
774
+ card = (
775
+ f'<div class="source-card">'
776
+ f"<strong>[Source {i}]</strong> {src}"
777
+ f"{f' β€” <em>{title}</em>' if title else ''}"
778
+ f" (score: {score:.4f})<br>"
779
+ f"<span style='color:#555'>{preview}</span>"
780
+ f"</div>"
781
+ )
782
+ cards.append(card)
783
+ sources_html = "".join(cards)
784
+ else:
785
+ sources_html = '<div class="status-bar">No relevant sources found.</div>'
786
+
787
+ return "", chat_history, sources_html
788
+
789
+ def clear_chat():
790
+ pipeline.reset_conversation()
791
+ return [], '<div class="status-bar">Conversation cleared. Sources will appear here.</div>'
792
+
793
+ # Wire events
794
+ msg_input.submit(respond, [msg_input, chatbot], [msg_input, chatbot, sources_display])
795
+ send_btn.click(respond, [msg_input, chatbot], [msg_input, chatbot, sources_display])
796
+ clear_btn.click(clear_chat, outputs=[chatbot, sources_display])
797
+
798
+ return demo
799
+
800
+ # ---------------------------------------------------------------------------
801
+ # 9. Main Entry Point
802
+ # ---------------------------------------------------------------------------
803
+
804
+ def main():
805
+ """Initialize the pipeline, index documents, and launch the UI."""
806
+ log.info("=" * 60)
807
+ log.info("RAG Application Starting")
808
+ log.info("=" * 60)
809
+
810
+ # Ensure NLTK data is available
811
+ try:
812
+ sent_tokenize("Hello world.")
813
+ except LookupError:
814
+ nltk.download("punkt_tab", quiet=True)
815
+
816
+ # Validate API key
817
+ api_key = os.getenv("OPENAI_API_KEY", "")
818
+ if not api_key:
819
+ log.warning(
820
+ "OPENAI_API_KEY not set. LLM generation will fail. "
821
+ "Set it with: export OPENAI_API_KEY='sk-...'"
822
+ )
823
+
824
+ # Create documents directory if needed
825
+ os.makedirs(CFG.documents_dir, exist_ok=True)
826
+
827
+ # Initialize pipeline
828
+ pipeline = RAGPipeline(CFG)
829
+
830
+ # Index documents (idempotent β€” skips already-indexed chunks)
831
+ pipeline.index_documents()
832
+
833
+ # Build and launch UI
834
+ demo = build_ui(pipeline)
835
+ log.info(f"Launching Gradio on port {CFG.server_port} (share={CFG.share})")
836
+ demo.launch(
837
+ server_name="0.0.0.0",
838
+ server_port=CFG.server_port,
839
+ share=CFG.share,
840
+ show_error=True,
841
+ )
842
+
843
+
844
+ if __name__ == "__main__":
845
+ main()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ chromadb
2
+ sentence-transformers
3
+ gradio
4
+ openai
5
+ pymupdf
6
+ python-docx
7
+ beautifulsoup4
8
+ rank_bm25
9
+ nltk
10
+ tiktoken
11
+ numpy
12
+