Girish Jeswani commited on
Commit
b27fb46
·
1 Parent(s): 613b423

rag for documents

Browse files
multi_llm_chatbot_backend/app/core/rag_manager.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chromadb
2
+ from chromadb.config import Settings
3
+ from sentence_transformers import SentenceTransformer
4
+ import nltk
5
+ import tiktoken
6
+ from typing import List, Dict, Any, Optional
7
+ import uuid
8
+ import logging
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Download required NLTK data
16
+ try:
17
+ nltk.data.find('tokenizers/punkt')
18
+ except LookupError:
19
+ try:
20
+ nltk.download('punkt')
21
+ except Exception as e:
22
+ logger.warning(f"Could not download NLTK punkt: {e}")
23
+
24
+ class DocumentChunker:
25
+ """Intelligent document chunking with overlaps and semantic boundaries"""
26
+
27
+ def __init__(self, chunk_size: int = 500, overlap: int = 50):
28
+ self.chunk_size = chunk_size
29
+ self.overlap = overlap
30
+ try:
31
+ self.encoding = tiktoken.get_encoding("cl100k_base") # GPT-4 encoding
32
+ except Exception as e:
33
+ logger.warning(f"Could not load tiktoken encoding: {e}")
34
+ self.encoding = None
35
+
36
+ def _count_tokens(self, text: str) -> int:
37
+ """Count tokens in text"""
38
+ if self.encoding:
39
+ return len(self.encoding.encode(text))
40
+ else:
41
+ # Fallback: approximate 4 chars per token
42
+ return len(text) // 4
43
+
44
+ def _encode_text(self, text: str) -> List[int]:
45
+ """Encode text to tokens"""
46
+ if self.encoding:
47
+ return self.encoding.encode(text)
48
+ else:
49
+ # Fallback: return character indices
50
+ return list(range(len(text)))
51
+
52
+ def _decode_tokens(self, tokens: List[int]) -> str:
53
+ """Decode tokens back to text"""
54
+ if self.encoding:
55
+ return self.encoding.decode(tokens)
56
+ else:
57
+ # Fallback: can't properly decode without tiktoken
58
+ return ""
59
+
60
+ def chunk_text(self, text: str, metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
61
+ """
62
+ Chunk text intelligently with semantic boundaries
63
+ """
64
+ # Clean and normalize text
65
+ text = self._clean_text(text)
66
+
67
+ # Simple sentence splitting fallback if NLTK isn't available
68
+ try:
69
+ sentences = nltk.sent_tokenize(text)
70
+ except Exception:
71
+ # Fallback sentence splitting
72
+ sentences = re.split(r'[.!?]+', text)
73
+ sentences = [s.strip() for s in sentences if s.strip()]
74
+
75
+ chunks = []
76
+ current_chunk = ""
77
+ current_tokens = 0
78
+ chunk_index = 0
79
+
80
+ for sentence in sentences:
81
+ sentence = sentence.strip()
82
+ if not sentence:
83
+ continue
84
+
85
+ sentence_tokens = self._count_tokens(sentence)
86
+
87
+ # If adding this sentence would exceed chunk size, save current chunk
88
+ if current_tokens + sentence_tokens > self.chunk_size and current_chunk:
89
+ chunk_data = self._create_chunk_metadata(
90
+ current_chunk.strip(),
91
+ metadata,
92
+ chunk_index
93
+ )
94
+ chunks.append(chunk_data)
95
+
96
+ # Start new chunk with overlap
97
+ overlap_text = self._get_overlap_text(current_chunk)
98
+ current_chunk = overlap_text + " " + sentence
99
+ current_tokens = self._count_tokens(current_chunk)
100
+ chunk_index += 1
101
+ else:
102
+ current_chunk += " " + sentence
103
+ current_tokens += sentence_tokens
104
+
105
+ # Add final chunk if it has content
106
+ if current_chunk.strip():
107
+ chunk_data = self._create_chunk_metadata(
108
+ current_chunk.strip(),
109
+ metadata,
110
+ chunk_index
111
+ )
112
+ chunks.append(chunk_data)
113
+
114
+ logger.info(f"Created {len(chunks)} chunks from document: {metadata.get('filename', 'unknown')}")
115
+ return chunks
116
+
117
+ def _clean_text(self, text: str) -> str:
118
+ """Clean and normalize text"""
119
+ # Remove excessive whitespace and newlines
120
+ text = re.sub(r'\n+', '\n', text)
121
+ text = re.sub(r'\s+', ' ', text)
122
+ return text.strip()
123
+
124
+ def _get_overlap_text(self, text: str) -> str:
125
+ """Get the last N tokens for overlap"""
126
+ if self.encoding:
127
+ tokens = self._encode_text(text)
128
+ if len(tokens) <= self.overlap:
129
+ return text
130
+
131
+ overlap_tokens = tokens[-self.overlap:]
132
+ return self._decode_tokens(overlap_tokens)
133
+ else:
134
+ # Fallback: use character-based overlap
135
+ words = text.split()
136
+ overlap_words = words[-self.overlap:] if len(words) > self.overlap else words
137
+ return " ".join(overlap_words)
138
+
139
+ def _create_chunk_metadata(self, chunk_text: str, base_metadata: Dict[str, Any], chunk_index: int) -> Dict[str, Any]:
140
+ """Create comprehensive metadata for a chunk"""
141
+ return {
142
+ "text": chunk_text,
143
+ "chunk_id": str(uuid.uuid4()),
144
+ "chunk_index": chunk_index,
145
+ "token_count": self._count_tokens(chunk_text),
146
+ "filename": base_metadata.get("filename", "unknown"),
147
+ "file_type": base_metadata.get("file_type", "unknown"),
148
+ "session_id": base_metadata.get("session_id"),
149
+ "upload_timestamp": base_metadata.get("upload_timestamp"),
150
+ "file_size": base_metadata.get("file_size", 0)
151
+ }
152
+
153
+ class SimpleEmbeddingFunction:
154
+ """Simple embedding function wrapper for ChromaDB - FIXED for new interface"""
155
+
156
+ def __init__(self, model):
157
+ self.model = model
158
+
159
+ def __call__(self, input):
160
+ """Generate embeddings for input texts - Updated signature for ChromaDB 0.4.16+"""
161
+ try:
162
+ # Handle both single string and list of strings
163
+ if isinstance(input, str):
164
+ input = [input]
165
+
166
+ embeddings = self.model.encode(input, convert_to_tensor=False)
167
+ return embeddings.tolist() if hasattr(embeddings, 'tolist') else embeddings
168
+ except Exception as e:
169
+ logger.error(f"Error generating embeddings: {e}")
170
+ # Return dummy embeddings as fallback
171
+ return [[0.0] * 384 for _ in input] # 384 is dimension for all-MiniLM-L6-v2
172
+
173
+ class RAGManager:
174
+ """
175
+ Retrieval-Augmented Generation Manager - FIXED VERSION
176
+
177
+ Handles document storage, embedding, and retrieval using ChromaDB
178
+ """
179
+
180
+ def __init__(self, embedding_model: str = "all-MiniLM-L6-v2", persist_directory: str = "./chroma_db"):
181
+ self.embedding_model_name = embedding_model
182
+ self.persist_directory = Path(persist_directory)
183
+ self.persist_directory.mkdir(exist_ok=True)
184
+
185
+ # Initialize embedding model
186
+ logger.info(f"Loading embedding model: {embedding_model}")
187
+ try:
188
+ self.embedding_model = SentenceTransformer(embedding_model)
189
+ logger.info("Embedding model loaded successfully")
190
+ except Exception as e:
191
+ logger.error(f"Failed to load embedding model: {e}")
192
+ raise
193
+
194
+ # Initialize ChromaDB client
195
+ try:
196
+ self.client = chromadb.PersistentClient(
197
+ path=str(self.persist_directory),
198
+ settings=Settings(
199
+ anonymized_telemetry=False,
200
+ allow_reset=True
201
+ )
202
+ )
203
+ logger.info("ChromaDB client initialized")
204
+ except Exception as e:
205
+ logger.error(f"Failed to initialize ChromaDB client: {e}")
206
+ raise
207
+
208
+ # Initialize collection
209
+ self.collection_name = "phd_advisor_documents"
210
+ self.collection = self._get_or_create_collection()
211
+
212
+ # Initialize chunker
213
+ self.chunker = DocumentChunker()
214
+
215
+ def _get_or_create_collection(self):
216
+ """Get or create the ChromaDB collection"""
217
+ try:
218
+ # First, try to get existing collection
219
+ try:
220
+ collection = self.client.get_collection(
221
+ name=self.collection_name
222
+ )
223
+ logger.info(f"Found existing collection: {self.collection_name}")
224
+ return collection
225
+ except ValueError:
226
+ # Collection doesn't exist, create it
227
+ logger.info(f"Creating new collection: {self.collection_name}")
228
+ collection = self.client.create_collection(
229
+ name=self.collection_name,
230
+ embedding_function=SimpleEmbeddingFunction(self.embedding_model),
231
+ metadata={"description": "PhD Advisor document storage"}
232
+ )
233
+ logger.info(f"Created collection: {self.collection_name}")
234
+ return collection
235
+
236
+ except Exception as e:
237
+ logger.error(f"Error with collection management: {e}")
238
+ # Try to reset and recreate
239
+ try:
240
+ logger.info("Attempting to reset and recreate collection...")
241
+ self.client.reset()
242
+ collection = self.client.create_collection(
243
+ name=self.collection_name,
244
+ embedding_function=SimpleEmbeddingFunction(self.embedding_model),
245
+ metadata={"description": "PhD Advisor document storage"}
246
+ )
247
+ logger.info("Successfully recreated collection")
248
+ return collection
249
+ except Exception as e2:
250
+ logger.error(f"Failed to recreate collection: {e2}")
251
+ raise
252
+
253
+ def add_document(self, content: str, filename: str, session_id: str, file_type: str = "unknown") -> Dict[str, Any]:
254
+ """
255
+ Add a document to the vector database
256
+ """
257
+ try:
258
+ # Create base metadata
259
+ base_metadata = {
260
+ "filename": filename,
261
+ "file_type": file_type,
262
+ "session_id": session_id,
263
+ "upload_timestamp": str(uuid.uuid4()), # Using UUID as timestamp for simplicity
264
+ "file_size": len(content)
265
+ }
266
+
267
+ # Chunk the document
268
+ chunks = self.chunker.chunk_text(content, base_metadata)
269
+
270
+ if not chunks:
271
+ raise ValueError("No chunks created from document")
272
+
273
+ # Prepare data for ChromaDB
274
+ chunk_ids = [chunk["chunk_id"] for chunk in chunks]
275
+ chunk_texts = [chunk["text"] for chunk in chunks]
276
+ chunk_metadatas = [
277
+ {k: v for k, v in chunk.items() if k != "text" and k != "chunk_id"}
278
+ for chunk in chunks
279
+ ]
280
+
281
+ # Add to ChromaDB with error handling
282
+ try:
283
+ self.collection.add(
284
+ ids=chunk_ids,
285
+ documents=chunk_texts,
286
+ metadatas=chunk_metadatas
287
+ )
288
+ logger.info(f"Successfully added {len(chunks)} chunks for document: {filename}")
289
+ except Exception as e:
290
+ logger.error(f"Error adding chunks to ChromaDB: {e}")
291
+ # Try to recreate collection and try again
292
+ self.collection = self._get_or_create_collection()
293
+ self.collection.add(
294
+ ids=chunk_ids,
295
+ documents=chunk_texts,
296
+ metadatas=chunk_metadatas
297
+ )
298
+ logger.info(f"Successfully added {len(chunks)} chunks after collection reset")
299
+
300
+ return {
301
+ "success": True,
302
+ "filename": filename,
303
+ "chunks_created": len(chunks),
304
+ "total_tokens": sum(chunk["token_count"] for chunk in chunks),
305
+ "chunk_ids": chunk_ids
306
+ }
307
+
308
+ except Exception as e:
309
+ logger.error(f"Error adding document {filename}: {str(e)}")
310
+ return {
311
+ "success": False,
312
+ "filename": filename,
313
+ "error": str(e)
314
+ }
315
+
316
+ def search_documents(self, query: str, session_id: str, persona_context: str = "", n_results: int = 5) -> List[Dict[str, Any]]:
317
+ """
318
+ Search for relevant document chunks
319
+ """
320
+ try:
321
+ # Enhance query with persona context for better retrieval
322
+ enhanced_query = f"{query} {persona_context}".strip()
323
+
324
+ # Search the collection
325
+ results = self.collection.query(
326
+ query_texts=[enhanced_query],
327
+ n_results=n_results,
328
+ where={"session_id": session_id} # Filter by session
329
+ )
330
+
331
+ # Format results
332
+ retrieved_chunks = []
333
+ if results['documents'] and results['documents'][0]:
334
+ for i, (doc, metadata, distance) in enumerate(zip(
335
+ results['documents'][0],
336
+ results['metadatas'][0],
337
+ results['distances'][0]
338
+ )):
339
+ # ChromaDB returns squared euclidean distance, convert to similarity
340
+ # For better similarity scores, we'll use: similarity = 1 / (1 + distance)
341
+ similarity_score = 1 / (1 + abs(distance)) if distance is not None else 0.5
342
+
343
+ chunk_data = {
344
+ "text": doc,
345
+ "metadata": metadata,
346
+ "relevance_score": similarity_score,
347
+ "distance": distance, # Keep original for debugging
348
+ "rank": i + 1
349
+ }
350
+ retrieved_chunks.append(chunk_data)
351
+
352
+ logger.info(f"Retrieved {len(retrieved_chunks)} chunks for query: {query[:50]}...")
353
+ return retrieved_chunks
354
+
355
+ except Exception as e:
356
+ logger.error(f"Error searching documents: {str(e)}")
357
+ return []
358
+
359
+ def get_document_stats(self, session_id: str) -> Dict[str, Any]:
360
+ """Get statistics about documents in a session"""
361
+ try:
362
+ # Get all chunks for this session
363
+ results = self.collection.get(
364
+ where={"session_id": session_id}
365
+ )
366
+
367
+ if not results or not results.get('metadatas'):
368
+ return {
369
+ "total_chunks": 0,
370
+ "total_documents": 0,
371
+ "documents": []
372
+ }
373
+
374
+ # Analyze metadata
375
+ metadatas = results['metadatas']
376
+ documents = {}
377
+
378
+ for metadata in metadatas:
379
+ filename = metadata.get('filename', 'unknown')
380
+ if filename not in documents:
381
+ documents[filename] = {
382
+ "filename": filename,
383
+ "file_type": metadata.get('file_type', 'unknown'),
384
+ "chunk_count": 0,
385
+ "total_tokens": 0
386
+ }
387
+
388
+ documents[filename]["chunk_count"] += 1
389
+ documents[filename]["total_tokens"] += metadata.get('token_count', 0)
390
+
391
+ return {
392
+ "total_chunks": len(metadatas),
393
+ "total_documents": len(documents),
394
+ "documents": list(documents.values())
395
+ }
396
+
397
+ except Exception as e:
398
+ logger.error(f"Error getting document stats: {str(e)}")
399
+ return {"total_chunks": 0, "total_documents": 0, "documents": []}
400
+
401
+ def delete_session_documents(self, session_id: str) -> bool:
402
+ """Delete all documents for a session"""
403
+ try:
404
+ # Get all document IDs for this session
405
+ results = self.collection.get(
406
+ where={"session_id": session_id}
407
+ )
408
+
409
+ if results and results.get('ids'):
410
+ chunk_ids = results['ids']
411
+ self.collection.delete(ids=chunk_ids)
412
+ logger.info(f"Deleted {len(chunk_ids)} chunks for session: {session_id}")
413
+ return True
414
+
415
+ return True # No documents to delete is also success
416
+
417
+ except Exception as e:
418
+ logger.error(f"Error deleting session documents: {str(e)}")
419
+ return False
420
+
421
+ def health_check(self) -> Dict[str, Any]:
422
+ """Check if RAG system is working properly"""
423
+ try:
424
+ # Test basic operations
425
+ test_doc = "This is a test document for health checking."
426
+ test_session = "health_check_session"
427
+
428
+ # Try adding a test document
429
+ result = self.add_document(test_doc, "test.txt", test_session, "txt")
430
+ if not result["success"]:
431
+ return {"status": "error", "message": "Failed to add test document"}
432
+
433
+ # Try searching
434
+ search_results = self.search_documents("test", test_session)
435
+
436
+ # Try getting stats
437
+ stats = self.get_document_stats(test_session)
438
+
439
+ # Cleanup
440
+ self.delete_session_documents(test_session)
441
+
442
+ return {
443
+ "status": "healthy",
444
+ "embedding_model": self.embedding_model_name,
445
+ "collection_name": self.collection_name,
446
+ "test_results": {
447
+ "add_document": result["success"],
448
+ "search_documents": len(search_results) > 0,
449
+ "get_stats": stats["total_chunks"] > 0
450
+ }
451
+ }
452
+
453
+ except Exception as e:
454
+ return {"status": "error", "message": str(e)}
455
+
456
+ # Global RAG manager instance
457
+ _rag_manager = None
458
+
459
+ def get_rag_manager() -> RAGManager:
460
+ """Get the global RAG manager instance"""
461
+ global _rag_manager
462
+ if _rag_manager is None:
463
+ try:
464
+ _rag_manager = RAGManager()
465
+ logger.info("RAG Manager initialized successfully")
466
+ except Exception as e:
467
+ logger.error(f"Failed to initialize RAG Manager: {e}")
468
+ raise
469
+ return _rag_manager