stackConsult commited on
Commit
536d6c7
·
verified ·
1 Parent(s): 91ad377

Add RAG pipeline for context-aware agent operations with document retrieval and citation support

Browse files
Files changed (1) hide show
  1. smolagents/rag_pipeline.py +361 -0
smolagents/rag_pipeline.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG Pipeline for Context-Aware Agent Operations
2
+
3
+ Retrieval-Augmented Generation pipeline that enriches agent responses
4
+ with relevant context from docs/assets/test data corpus.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ from pathlib import Path
10
+ from typing import List, Dict, Any, Optional, Tuple
11
+ import numpy as np
12
+ from dataclasses import dataclass
13
+
14
+ try:
15
+ from sentence_transformers import SentenceTransformer
16
+ EMBEDDINGS_AVAILABLE = True
17
+ except ImportError:
18
+ EMBEDDINGS_AVAILABLE = False
19
+ print("Warning: sentence-transformers not available. Install with: pip install sentence-transformers")
20
+
21
+
22
+ @dataclass
23
+ class Document:
24
+ """Document container with metadata"""
25
+ content: str
26
+ source: str
27
+ metadata: Dict[str, Any]
28
+ embedding: Optional[np.ndarray] = None
29
+
30
+
31
+ @dataclass
32
+ class RetrievalResult:
33
+ """Result from RAG retrieval with context and citations"""
34
+ documents: List[Document]
35
+ context: str
36
+ citations: List[str]
37
+ scores: List[float]
38
+
39
+
40
+ class RAGPipeline:
41
+ """Retrieval-Augmented Generation Pipeline
42
+
43
+ Provides context-aware enrichment for agent operations by:
44
+ - Indexing documents from docs/assets/test data
45
+ - Retrieving relevant context for queries
46
+ - Generating citations and evidence for responses
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ corpus_dir: str = "./docs",
52
+ model_name: str = "all-MiniLM-L6-v2",
53
+ top_k: int = 5,
54
+ cache_dir: str = "./rag_cache"
55
+ ):
56
+ """Initialize RAG pipeline
57
+
58
+ Args:
59
+ corpus_dir: Directory containing documents to index
60
+ model_name: Sentence transformer model for embeddings
61
+ top_k: Number of top documents to retrieve
62
+ cache_dir: Directory for caching embeddings
63
+ """
64
+ self.corpus_dir = Path(corpus_dir)
65
+ self.top_k = top_k
66
+ self.cache_dir = Path(cache_dir)
67
+ self.cache_dir.mkdir(exist_ok=True)
68
+
69
+ # Initialize embedding model
70
+ if EMBEDDINGS_AVAILABLE:
71
+ try:
72
+ self.model = SentenceTransformer(model_name)
73
+ self.embedding_dim = self.model.get_sentence_embedding_dimension()
74
+ except Exception as e:
75
+ print(f"Error loading embedding model: {e}")
76
+ self.model = None
77
+ else:
78
+ self.model = None
79
+
80
+ # Document store
81
+ self.documents: List[Document] = []
82
+ self.embeddings: Optional[np.ndarray] = None
83
+
84
+ # Load and index documents
85
+ self._load_corpus()
86
+
87
+ def _load_corpus(self):
88
+ """Load documents from corpus directory"""
89
+ if not self.corpus_dir.exists():
90
+ print(f"Warning: Corpus directory {self.corpus_dir} does not exist")
91
+ return
92
+
93
+ # Load all text-based files
94
+ extensions = [".txt", ".md", ".json", ".csv", ".py"]
95
+
96
+ for ext in extensions:
97
+ for file_path in self.corpus_dir.rglob(f"*{ext}"):
98
+ try:
99
+ self._load_document(file_path)
100
+ except Exception as e:
101
+ print(f"Error loading {file_path}: {e}")
102
+
103
+ print(f"Loaded {len(self.documents)} documents from corpus")
104
+
105
+ # Generate embeddings
106
+ if self.model and self.documents:
107
+ self._generate_embeddings()
108
+
109
+ def _load_document(self, file_path: Path):
110
+ """Load a single document"""
111
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
112
+ content = f.read()
113
+
114
+ # Skip empty files
115
+ if not content.strip():
116
+ return
117
+
118
+ # Create document
119
+ doc = Document(
120
+ content=content,
121
+ source=str(file_path.relative_to(self.corpus_dir)),
122
+ metadata={
123
+ "file_type": file_path.suffix,
124
+ "file_name": file_path.name,
125
+ "path": str(file_path)
126
+ }
127
+ )
128
+
129
+ self.documents.append(doc)
130
+
131
+ def _generate_embeddings(self):
132
+ """Generate embeddings for all documents"""
133
+ if not self.model:
134
+ return
135
+
136
+ # Check cache
137
+ cache_file = self.cache_dir / "embeddings.npy"
138
+ cache_meta = self.cache_dir / "embeddings_meta.json"
139
+
140
+ if cache_file.exists() and cache_meta.exists():
141
+ try:
142
+ self.embeddings = np.load(cache_file)
143
+ with open(cache_meta, 'r') as f:
144
+ meta = json.load(f)
145
+ if meta.get("num_docs") == len(self.documents):
146
+ print("Loaded embeddings from cache")
147
+ return
148
+ except Exception as e:
149
+ print(f"Error loading cache: {e}")
150
+
151
+ # Generate new embeddings
152
+ print("Generating document embeddings...")
153
+ texts = [doc.content for doc in self.documents]
154
+
155
+ try:
156
+ self.embeddings = self.model.encode(
157
+ texts,
158
+ show_progress_bar=True,
159
+ convert_to_numpy=True
160
+ )
161
+
162
+ # Cache embeddings
163
+ np.save(cache_file, self.embeddings)
164
+ with open(cache_meta, 'w') as f:
165
+ json.dump({"num_docs": len(self.documents)}, f)
166
+
167
+ print("Embeddings generated and cached")
168
+ except Exception as e:
169
+ print(f"Error generating embeddings: {e}")
170
+ self.embeddings = None
171
+
172
+ def retrieve(
173
+ self,
174
+ query: str,
175
+ top_k: Optional[int] = None,
176
+ filter_metadata: Optional[Dict[str, Any]] = None
177
+ ) -> RetrievalResult:
178
+ """Retrieve relevant documents for a query
179
+
180
+ Args:
181
+ query: Search query
182
+ top_k: Number of documents to retrieve (default: self.top_k)
183
+ filter_metadata: Optional metadata filters
184
+
185
+ Returns:
186
+ RetrievalResult with documents, context, and citations
187
+ """
188
+ if top_k is None:
189
+ top_k = self.top_k
190
+
191
+ if not self.documents:
192
+ return RetrievalResult(
193
+ documents=[],
194
+ context="No documents available in corpus.",
195
+ citations=[],
196
+ scores=[]
197
+ )
198
+
199
+ # Use semantic search if embeddings available
200
+ if self.model and self.embeddings is not None:
201
+ return self._semantic_retrieve(query, top_k, filter_metadata)
202
+ else:
203
+ # Fallback to keyword search
204
+ return self._keyword_retrieve(query, top_k, filter_metadata)
205
+
206
+ def _semantic_retrieve(
207
+ self,
208
+ query: str,
209
+ top_k: int,
210
+ filter_metadata: Optional[Dict[str, Any]]
211
+ ) -> RetrievalResult:
212
+ """Semantic retrieval using embeddings"""
213
+ # Encode query
214
+ query_embedding = self.model.encode([query], convert_to_numpy=True)[0]
215
+
216
+ # Compute similarities
217
+ similarities = np.dot(self.embeddings, query_embedding)
218
+
219
+ # Apply metadata filters
220
+ valid_indices = range(len(self.documents))
221
+ if filter_metadata:
222
+ valid_indices = [
223
+ i for i in valid_indices
224
+ if all(
225
+ self.documents[i].metadata.get(k) == v
226
+ for k, v in filter_metadata.items()
227
+ )
228
+ ]
229
+
230
+ # Get top-k indices
231
+ if valid_indices:
232
+ filtered_sims = [(i, similarities[i]) for i in valid_indices]
233
+ top_indices = sorted(filtered_sims, key=lambda x: x[1], reverse=True)[:top_k]
234
+ indices, scores = zip(*top_indices) if top_indices else ([], [])
235
+ else:
236
+ indices, scores = [], []
237
+
238
+ # Build result
239
+ retrieved_docs = [self.documents[i] for i in indices]
240
+
241
+ return self._build_result(retrieved_docs, list(scores))
242
+
243
+ def _keyword_retrieve(
244
+ self,
245
+ query: str,
246
+ top_k: int,
247
+ filter_metadata: Optional[Dict[str, Any]]
248
+ ) -> RetrievalResult:
249
+ """Simple keyword-based retrieval fallback"""
250
+ query_terms = set(query.lower().split())
251
+
252
+ # Score documents by keyword overlap
253
+ scores = []
254
+ for doc in self.documents:
255
+ doc_terms = set(doc.content.lower().split())
256
+ overlap = len(query_terms & doc_terms)
257
+ scores.append(overlap)
258
+
259
+ # Apply metadata filters
260
+ valid_indices = range(len(self.documents))
261
+ if filter_metadata:
262
+ valid_indices = [
263
+ i for i in valid_indices
264
+ if all(
265
+ self.documents[i].metadata.get(k) == v
266
+ for k, v in filter_metadata.items()
267
+ )
268
+ ]
269
+
270
+ # Get top-k
271
+ if valid_indices:
272
+ filtered_scores = [(i, scores[i]) for i in valid_indices]
273
+ top_indices = sorted(filtered_scores, key=lambda x: x[1], reverse=True)[:top_k]
274
+ indices, top_scores = zip(*top_indices) if top_indices else ([], [])
275
+ else:
276
+ indices, top_scores = [], []
277
+
278
+ retrieved_docs = [self.documents[i] for i in indices]
279
+
280
+ return self._build_result(retrieved_docs, list(top_scores))
281
+
282
+ def _build_result(self, documents: List[Document], scores: List[float]) -> RetrievalResult:
283
+ """Build retrieval result with context and citations"""
284
+ # Build context string
285
+ context_parts = []
286
+ citations = []
287
+
288
+ for i, (doc, score) in enumerate(zip(documents, scores), 1):
289
+ # Truncate long documents
290
+ content_preview = doc.content[:500]
291
+ if len(doc.content) > 500:
292
+ content_preview += "..."
293
+
294
+ context_parts.append(
295
+ f"[{i}] {doc.source}\n{content_preview}\n"
296
+ )
297
+ citations.append(f"[{i}] {doc.source} (relevance: {score:.2f})")
298
+
299
+ context = "\n".join(context_parts) if context_parts else "No relevant context found."
300
+
301
+ return RetrievalResult(
302
+ documents=documents,
303
+ context=context,
304
+ citations=citations,
305
+ scores=scores
306
+ )
307
+
308
+ def enrich_query(self, query: str, max_context_length: int = 2000) -> Dict[str, Any]:
309
+ """Enrich a query with retrieved context
310
+
311
+ Args:
312
+ query: User query
313
+ max_context_length: Maximum context length to include
314
+
315
+ Returns:
316
+ Dict with enriched_query, context, and citations
317
+ """
318
+ result = self.retrieve(query)
319
+
320
+ # Truncate context if needed
321
+ context = result.context
322
+ if len(context) > max_context_length:
323
+ context = context[:max_context_length] + "..."
324
+
325
+ enriched_query = f"""{query}
326
+
327
+ Relevant Context:
328
+ {context}
329
+
330
+ Please use the above context to provide an informed response and cite sources where applicable."""
331
+
332
+ return {
333
+ "enriched_query": enriched_query,
334
+ "context": context,
335
+ "citations": result.citations,
336
+ "num_sources": len(result.documents)
337
+ }
338
+
339
+
340
+ # Singleton instance
341
+ _rag_pipeline = None
342
+
343
+ def get_rag_pipeline(
344
+ corpus_dir: str = "./docs",
345
+ force_reload: bool = False
346
+ ) -> RAGPipeline:
347
+ """Get or create RAG pipeline singleton
348
+
349
+ Args:
350
+ corpus_dir: Directory containing documents
351
+ force_reload: Force reload of the pipeline
352
+
353
+ Returns:
354
+ RAGPipeline instance
355
+ """
356
+ global _rag_pipeline
357
+
358
+ if _rag_pipeline is None or force_reload:
359
+ _rag_pipeline = RAGPipeline(corpus_dir=corpus_dir)
360
+
361
+ return _rag_pipeline