Gilgarmesh commited on
Commit
433a16e
·
verified ·
1 Parent(s): 02d9fc3

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -845
app.py DELETED
@@ -1,845 +0,0 @@
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()