FLAFUENTE commited on
Commit
f729bac
·
1 Parent(s): c192a08

Deploy document chunking system with Gradio interface

Browse files
DEPLOYMENT.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Deployment Guide for Gradio Spaces
2
+
3
+ ## Quick Deploy Checklist
4
+
5
+ ### 1. Environment Variables (Required)
6
+ In your Hugging Face Space settings, add these secrets:
7
+
8
+ ```
9
+ OPENAI_API_KEY=your_openai_api_key_here
10
+ QDRANT_URL=https://your-qdrant-cloud-instance.com
11
+ QDRANT_API_KEY=your_qdrant_api_key (if using Qdrant Cloud)
12
+ ```
13
+
14
+ ### 2. Qdrant Setup Options
15
+
16
+ #### Option A: Qdrant Cloud (Recommended)
17
+ 1. Sign up at [cloud.qdrant.io](https://cloud.qdrant.io)
18
+ 2. Create a cluster
19
+ 3. Get your cluster URL and API key
20
+ 4. Set QDRANT_URL and QDRANT_API_KEY in Space secrets
21
+
22
+ #### Option B: Self-hosted Qdrant
23
+ 1. Deploy Qdrant on a cloud provider (DigitalOcean, AWS, etc.)
24
+ 2. Ensure it's publicly accessible
25
+ 3. Set QDRANT_URL in Space secrets
26
+
27
+ ### 3. Push Files to Space
28
+
29
+ The following files are already copied to your Space directory:
30
+ - `app.py` - Main Gradio application
31
+ - `requirements.txt` - Python dependencies
32
+ - `settings.py` - Configuration
33
+ - `document_processor.py` - Document handling
34
+ - `embeddings_qdrant.py` - Embeddings and Qdrant logic
35
+ - `chunking_methods.py` - Chunking strategies
36
+ - `pdf_loader.py` - PDF processing
37
+ - `utils_logging.py` - Logging utilities
38
+
39
+ ### 4. Deploy
40
+
41
+ ```bash
42
+ cd optchunkopt
43
+ git add .
44
+ git commit -m "Deploy chunking system to Gradio Space"
45
+ git push origin main
46
+ ```
47
+
48
+ ### 5. Monitor Deployment
49
+
50
+ Check the Space logs for:
51
+ - ✅ Component initialization success
52
+ - ⚠️ Configuration warnings
53
+ - ❌ Connection errors
54
+
55
+ ## Troubleshooting
56
+
57
+ ### Common Issues:
58
+
59
+ 1. **OPENAI_API_KEY not found**
60
+ - Add your OpenAI API key to Space secrets
61
+
62
+ 2. **Qdrant connection failed**
63
+ - Verify QDRANT_URL is correct and accessible
64
+ - Check if Qdrant instance is running
65
+
66
+ 3. **Import errors**
67
+ - Check if all dependencies are in requirements.txt
68
+ - Verify file paths and imports
69
+
70
+ ### Test Locally First:
71
+
72
+ ```bash
73
+ cd optchunkopt
74
+ python app.py
75
+ ```
76
+
77
+ This will help identify issues before deployment.
78
+
79
+ ## Features Available:
80
+
81
+ - 📤 **Document Upload**: PDF, TXT, DOCX support
82
+ - 🔄 **Multi-Method Chunking**: Fixed, sentence, adaptive strategies
83
+ - 🔍 **Semantic Search**: OpenAI embeddings with similarity search
84
+ - ⚙️ **System Status**: Real-time configuration monitoring
85
+
86
+ ## Performance Notes:
87
+
88
+ - First document processing will be slower (embedding generation)
89
+ - Search is fast once embeddings are stored
90
+ - Consider chunking method based on document type
91
+ - Monitor OpenAI API usage costs
app.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import logging
3
+ import os
4
+ from typing import List, Tuple, Optional
5
+ import pandas as pd
6
+
7
+ # Import your existing modules
8
+ from document_processor import DocumentProcessor
9
+ from embeddings_qdrant import EmbeddingsQdrantPipeline
10
+ import settings
11
+
12
+ # Configure logging
13
+ logging.basicConfig(level=logging.INFO)
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Global variables for components (will be initialized in main)
17
+ document_processor = None
18
+ embeddings_pipeline = None
19
+
20
+ def initialize_components():
21
+ """Initialize the pipeline components with error handling for cloud deployment."""
22
+ global document_processor, embeddings_pipeline
23
+
24
+ try:
25
+ document_processor = DocumentProcessor()
26
+ logger.info("✅ Document processor initialized")
27
+ except Exception as e:
28
+ logger.error(f"❌ Failed to initialize document processor: {str(e)}")
29
+ document_processor = None
30
+
31
+ try:
32
+ embeddings_pipeline = EmbeddingsQdrantPipeline()
33
+ logger.info("✅ Embeddings pipeline initialized")
34
+ except Exception as e:
35
+ logger.error(f"❌ Failed to initialize embeddings pipeline: {str(e)}")
36
+ embeddings_pipeline = None
37
+
38
+ return document_processor is not None and embeddings_pipeline is not None
39
+
40
+ def process_document(file_path: str, chunking_methods: List[str]) -> Tuple[str, pd.DataFrame]:
41
+ """
42
+ Process a document with selected chunking methods.
43
+
44
+ Args:
45
+ file_path: Path to the uploaded file
46
+ chunking_methods: List of selected chunking methods
47
+
48
+ Returns:
49
+ Tuple of (status_message, chunks_dataframe)
50
+ """
51
+ try:
52
+ if document_processor is None:
53
+ return "❌ Error: Document processor not initialized. Check system configuration.", pd.DataFrame()
54
+
55
+ if embeddings_pipeline is None:
56
+ return "❌ Error: Embeddings pipeline not initialized. Check Qdrant connection and OpenAI API key.", pd.DataFrame()
57
+
58
+ if not file_path:
59
+ return "❌ Error: No file uploaded", pd.DataFrame()
60
+
61
+ if not chunking_methods:
62
+ return "❌ Error: Please select at least one chunking method", pd.DataFrame()
63
+
64
+ # Process the document
65
+ logger.info(f"Processing document: {file_path}")
66
+ chunks_by_method = document_processor.process_document(
67
+ file_path=file_path,
68
+ chunking_methods=chunking_methods
69
+ )
70
+
71
+ if not chunks_by_method:
72
+ return "❌ Error: No chunks generated from document", pd.DataFrame()
73
+
74
+ # Store embeddings in Qdrant
75
+ success = embeddings_pipeline.process_and_store_chunks(chunks_by_method)
76
+
77
+ if not success:
78
+ return "❌ Error: Failed to store embeddings in Qdrant", pd.DataFrame()
79
+
80
+ # Create a summary dataframe
81
+ summary_data = []
82
+ total_chunks = 0
83
+ for method, chunks in chunks_by_method.items():
84
+ summary_data.append({
85
+ "Chunking Method": method,
86
+ "Number of Chunks": len(chunks),
87
+ "Average Chunk Length": round(sum(len(chunk) for chunk in chunks) / len(chunks)) if chunks else 0,
88
+ "Sample Chunk": chunks[0][:100] + "..." if chunks else "No chunks"
89
+ })
90
+ total_chunks += len(chunks)
91
+
92
+ summary_df = pd.DataFrame(summary_data)
93
+
94
+ status_msg = f"✅ Success! Document processed with {len(chunking_methods)} methods. Total chunks: {total_chunks}"
95
+
96
+ return status_msg, summary_df
97
+
98
+ except Exception as e:
99
+ logger.error(f"Error processing document: {str(e)}")
100
+ return f"❌ Error: {str(e)}", pd.DataFrame()
101
+
102
+ def search_chunks(query: str, top_k: int = None) -> Tuple[str, pd.DataFrame]:
103
+ """
104
+ Search for similar chunks across all collections.
105
+
106
+ Args:
107
+ query: Search query
108
+ top_k: Number of results to return (uses settings default if None)
109
+
110
+ Returns:
111
+ Tuple of (status_message, results_dataframe)
112
+ """
113
+ try:
114
+ if embeddings_pipeline is None:
115
+ return "❌ Error: Embeddings pipeline not initialized. Check Qdrant connection and OpenAI API key.", pd.DataFrame()
116
+
117
+ if not query.strip():
118
+ return "❌ Error: Please enter a search query", pd.DataFrame()
119
+
120
+ # Use provided top_k or default from settings
121
+ k = top_k if top_k is not None else settings.TOP_K_RESULTS
122
+
123
+ # Perform search
124
+ logger.info(f"Searching for: '{query}' (top_k={k})")
125
+ results = embeddings_pipeline.search_similar_chunks(query=query, top_k=k)
126
+
127
+ if not results:
128
+ return "⚠️ No results found. Try a different query or check if documents are processed.", pd.DataFrame()
129
+
130
+ # Create results dataframe
131
+ results_data = []
132
+ for i, (text, score, method) in enumerate(results, 1):
133
+ results_data.append({
134
+ "Rank": i,
135
+ "Similarity Score": round(score, 4),
136
+ "Chunking Method": method,
137
+ "Text Preview": text[:200] + "..." if len(text) > 200 else text,
138
+ "Full Text": text
139
+ })
140
+
141
+ results_df = pd.DataFrame(results_data)
142
+
143
+ status_msg = f"✅ Found {len(results)} results for query: '{query}'"
144
+
145
+ return status_msg, results_df
146
+
147
+ except Exception as e:
148
+ logger.error(f"Error searching chunks: {str(e)}")
149
+ return f"❌ Error: {str(e)}", pd.DataFrame()
150
+
151
+ def get_system_status() -> Tuple[str, pd.DataFrame]:
152
+ """
153
+ Get current system status including collections info.
154
+
155
+ Returns:
156
+ Tuple of (status_message, status_dataframe)
157
+ """
158
+ try:
159
+ # Get collection statistics
160
+ status_data = []
161
+ total_chunks = 0
162
+
163
+ for method in settings.CHUNKING_METHODS:
164
+ collection_name = f"{settings.QDRANT_COLLECTION_NAME}_{method}"
165
+ try:
166
+ # This would need a method to get collection info
167
+ # For now, we'll show configured methods
168
+ status_data.append({
169
+ "Collection": collection_name,
170
+ "Method": method,
171
+ "Status": "Available",
172
+ "Description": f"Collection for {method} chunking method"
173
+ })
174
+ except Exception:
175
+ status_data.append({
176
+ "Collection": collection_name,
177
+ "Method": method,
178
+ "Status": "Not Available",
179
+ "Description": f"Collection for {method} chunking method (not created)"
180
+ })
181
+
182
+ status_df = pd.DataFrame(status_data)
183
+
184
+ # System configuration info
185
+ config_info = f"""
186
+ 📊 **System Configuration:**
187
+ - Embedding Model: {settings.OPENAI_EMBEDDING_MODEL}
188
+ - Embedding Dimensions: {settings.EMBEDDING_DIMENSIONS}
189
+ - Qdrant URL: {settings.QDRANT_URL}
190
+ - Top K Results: {settings.TOP_K_RESULTS}
191
+ - Min Similarity Score: {settings.MIN_SIMILARITY_SCORE}
192
+ - Available Chunking Methods: {', '.join(settings.CHUNKING_METHODS)}
193
+ """
194
+
195
+ return config_info, status_df
196
+
197
+ except Exception as e:
198
+ logger.error(f"Error getting system status: {str(e)}")
199
+ return f"❌ Error: {str(e)}", pd.DataFrame()
200
+
201
+ # Create Gradio interface
202
+ with gr.Blocks(title="Document Chunking & Search System", theme=gr.themes.Soft()) as demo:
203
+ gr.Markdown("""
204
+ # 📄 Document Chunking & Search System
205
+
206
+ This system processes documents using multiple chunking strategies and enables semantic search using OpenAI embeddings stored in Qdrant.
207
+
208
+ ## Available Chunking Methods:
209
+ - **Fixed**: Fixed-size chunks with overlap
210
+ - **Sentence**: Sentence-based chunking
211
+ - **Adaptive**: Adaptive chunking based on content structure
212
+ """)
213
+
214
+ with gr.Tab("📤 Document Processing"):
215
+ gr.Markdown("### Upload and process documents")
216
+
217
+ with gr.Row():
218
+ with gr.Column():
219
+ file_input = gr.File(
220
+ label="Upload Document",
221
+ file_types=[".pdf", ".txt", ".docx"],
222
+ type="filepath"
223
+ )
224
+
225
+ chunking_methods = gr.CheckboxGroup(
226
+ choices=settings.CHUNKING_METHODS,
227
+ value=settings.CHUNKING_METHODS, # Select all by default
228
+ label="Select Chunking Methods"
229
+ )
230
+
231
+ process_btn = gr.Button("🔄 Process Document", variant="primary")
232
+
233
+ with gr.Column():
234
+ process_status = gr.Textbox(
235
+ label="Processing Status",
236
+ placeholder="Upload a document and click 'Process Document'",
237
+ interactive=False
238
+ )
239
+
240
+ chunks_table = gr.Dataframe(
241
+ label="Chunks Summary",
242
+ headers=["Chunking Method", "Number of Chunks", "Average Chunk Length", "Sample Chunk"],
243
+ interactive=False
244
+ )
245
+
246
+ process_btn.click(
247
+ process_document,
248
+ inputs=[file_input, chunking_methods],
249
+ outputs=[process_status, chunks_table]
250
+ )
251
+
252
+ with gr.Tab("🔍 Search"):
253
+ gr.Markdown("### Search for similar content across all processed documents")
254
+
255
+ with gr.Row():
256
+ with gr.Column():
257
+ search_input = gr.Textbox(
258
+ label="Search Query",
259
+ placeholder="Enter your search query here...",
260
+ lines=2
261
+ )
262
+
263
+ top_k_input = gr.Slider(
264
+ minimum=1,
265
+ maximum=20,
266
+ value=settings.TOP_K_RESULTS,
267
+ step=1,
268
+ label="Number of Results"
269
+ )
270
+
271
+ search_btn = gr.Button("🔍 Search", variant="primary")
272
+
273
+ with gr.Column():
274
+ search_status = gr.Textbox(
275
+ label="Search Status",
276
+ placeholder="Enter a query and click 'Search'",
277
+ interactive=False
278
+ )
279
+
280
+ results_table = gr.Dataframe(
281
+ label="Search Results",
282
+ headers=["Rank", "Similarity Score", "Chunking Method", "Text Preview", "Full Text"],
283
+ interactive=False
284
+ )
285
+
286
+ search_btn.click(
287
+ search_chunks,
288
+ inputs=[search_input, top_k_input],
289
+ outputs=[search_status, results_table]
290
+ )
291
+
292
+ with gr.Tab("⚙️ System Status"):
293
+ gr.Markdown("### Current system configuration and status")
294
+
295
+ status_btn = gr.Button("🔄 Refresh Status", variant="secondary")
296
+
297
+ system_info = gr.Textbox(
298
+ label="System Information",
299
+ lines=10,
300
+ interactive=False
301
+ )
302
+
303
+ collections_table = gr.Dataframe(
304
+ label="Collections Status",
305
+ headers=["Collection", "Method", "Status", "Description"],
306
+ interactive=False
307
+ )
308
+
309
+ # Load initial status
310
+ demo.load(
311
+ get_system_status,
312
+ outputs=[system_info, collections_table]
313
+ )
314
+
315
+ status_btn.click(
316
+ get_system_status,
317
+ outputs=[system_info, collections_table]
318
+ )
319
+
320
+ if __name__ == "__main__":
321
+ # Set up environment for deployment
322
+ port = int(os.environ.get("PORT", 7860))
323
+
324
+ print("🚀 Starting Document Chunking & Search System...")
325
+
326
+ # Initialize components with error handling
327
+ init_success = initialize_components()
328
+
329
+ if not init_success:
330
+ print("⚠️ Warning: Some components failed to initialize. Check configuration.")
331
+ print("� Make sure you have:")
332
+ print(" - OPENAI_API_KEY environment variable set")
333
+ print(" - Qdrant instance running (QDRANT_URL configured)")
334
+ else:
335
+ print("✅ All components initialized successfully")
336
+
337
+ print(f"�📊 Configured for {len(settings.CHUNKING_METHODS)} chunking methods")
338
+ print(f"🔗 Qdrant URL: {settings.QDRANT_URL}")
339
+ print(f"🤖 Embedding Model: {settings.OPENAI_EMBEDDING_MODEL}")
340
+
341
+ demo.launch(
342
+ server_name="0.0.0.0",
343
+ server_port=port,
344
+ share=False # Gradio Spaces will handle public access
345
+ )
chunking_methods.py ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MIT License
3
+ Autor: Fernando Lafuente
4
+ Proyecto: Ejercicio opcional NLP - Chunking y Recuperación
5
+ Descripción: Implementación de múltiples métodos de chunking para segmentación textual
6
+
7
+ Este módulo implementa tres estrategias diferentes de chunking (segmentación textual):
8
+ 1. Chunking fijo: División por tamaño fijo con solapamiento
9
+ 2. Chunking por oraciones: División en oraciones naturales agrupadas
10
+ 3. Chunking adaptativo: División basada en similitud semántica entre oraciones
11
+
12
+ Cada método tiene ventajas específicas según el tipo de documento y la aplicación.
13
+ El chunking adaptativo utiliza embeddings para agrupar oraciones semánticamente
14
+ relacionadas, lo que puede mejorar la coherencia de los chunks resultantes.
15
+ """
16
+
17
+ import logging
18
+ import re
19
+ from typing import List, Tuple
20
+ from abc import ABC, abstractmethod
21
+
22
+ import numpy as np
23
+ from sklearn.metrics.pairwise import cosine_similarity
24
+
25
+ import settings
26
+ from utils_logging import setup_logging, log_operation_time, validate_text_length
27
+
28
+
29
+ # Configurar logger específico para este módulo
30
+ logger = setup_logging()
31
+
32
+
33
+ class ChunkingStrategy(ABC):
34
+ """
35
+ Clase abstracta base para todas las estrategias de chunking.
36
+
37
+ Define la interfaz común que deben implementar todos los métodos de chunking.
38
+ Esto permite intercambiar estrategias fácilmente y mantener código limpio
39
+ siguiendo el patrón Strategy de diseño.
40
+ """
41
+
42
+ @abstractmethod
43
+ def create_chunks(self, text: str) -> List[str]:
44
+ """
45
+ Método abstracto para crear chunks desde un texto.
46
+
47
+ Args:
48
+ text: Texto a segmentar
49
+
50
+ Returns:
51
+ List[str]: Lista de chunks generados
52
+ """
53
+ pass
54
+
55
+ @abstractmethod
56
+ def get_strategy_name(self) -> str:
57
+ """
58
+ Método abstracto para obtener el nombre de la estrategia.
59
+
60
+ Returns:
61
+ str: Nombre identificativo de la estrategia
62
+ """
63
+ pass
64
+
65
+
66
+ class FixedChunking(ChunkingStrategy):
67
+ """
68
+ Implementa chunking por tamaño fijo con solapamiento.
69
+
70
+ Este método divide el texto en chunks de tamaño fijo (en caracteres),
71
+ con un solapamiento configurable entre chunks consecutivos. Es el método
72
+ más simple y predecible, útil cuando se requiere uniformidad en el tamaño.
73
+
74
+ Ventajas:
75
+ - Predecible y rápido
76
+ - Chunks de tamaño uniforme
77
+ - Fácil de implementar y debuggear
78
+
79
+ Desventajas:
80
+ - Puede cortar oraciones o conceptos a la mitad
81
+ - No considera la estructura semántica del texto
82
+ """
83
+
84
+ def __init__(self,
85
+ chunk_size: int = settings.FIXED_CHUNK_SIZE,
86
+ overlap: int = settings.FIXED_CHUNK_OVERLAP):
87
+ """
88
+ Inicializa el chunking fijo con parámetros configurables.
89
+
90
+ Args:
91
+ chunk_size: Tamaño en caracteres de cada chunk
92
+ overlap: Número de caracteres de solapamiento entre chunks
93
+ """
94
+ self.chunk_size = chunk_size
95
+ self.overlap = overlap
96
+ self.logger = logging.getLogger("chunking_system.fixed_chunking")
97
+
98
+ # Validar parámetros
99
+ if self.overlap >= self.chunk_size:
100
+ raise ValueError("El solapamiento debe ser menor que el tamaño del chunk")
101
+
102
+ @log_operation_time
103
+ def create_chunks(self, text: str) -> List[str]:
104
+ """
105
+ Crea chunks de tamaño fijo con solapamiento del texto de entrada.
106
+
107
+ El algoritmo avanza por el texto con pasos de (chunk_size - overlap),
108
+ tomando ventanas de tamaño chunk_size. Esto asegura que la información
109
+ en los límites de chunks se preserve en múltiples chunks.
110
+
111
+ Args:
112
+ text: Texto a segmentar
113
+
114
+ Returns:
115
+ List[str]: Lista de chunks de tamaño aproximadamente uniforme
116
+ """
117
+ if not text or not validate_text_length(text):
118
+ self.logger.warning("Texto vacío o demasiado corto para chunking")
119
+ return []
120
+
121
+ chunks = []
122
+ start = 0
123
+ step = self.chunk_size - self.overlap
124
+
125
+ while start < len(text):
126
+ end = min(start + self.chunk_size, len(text))
127
+ chunk = text[start:end].strip()
128
+
129
+ # Solo agregar chunks que tengan contenido válido
130
+ if validate_text_length(chunk):
131
+ chunks.append(chunk)
132
+ self.logger.debug(f"Chunk creado: posición {start}-{end}, "
133
+ f"longitud {len(chunk)} caracteres")
134
+
135
+ # Salir si hemos llegado al final
136
+ if end == len(text):
137
+ break
138
+
139
+ start += step
140
+
141
+ self.logger.info(f"Chunking fijo completado: {len(chunks)} chunks generados")
142
+ return chunks
143
+
144
+ def get_strategy_name(self) -> str:
145
+ return f"fixed_chunking_{self.chunk_size}_{self.overlap}"
146
+
147
+
148
+ class SentenceChunking(ChunkingStrategy):
149
+ """
150
+ Implementa chunking basado en oraciones y párrafos naturales.
151
+
152
+ Este método respeta la estructura natural del texto, dividiendo en oraciones
153
+ y agrupándolas hasta alcanzar un tamaño objetivo. Preserva la coherencia
154
+ semántica mejor que el chunking fijo al no cortar oraciones.
155
+
156
+ Ventajas:
157
+ - Respeta límites naturales de oraciones
158
+ - Mantiene coherencia semántica local
159
+ - Más legible para humanos
160
+
161
+ Desventajas:
162
+ - Chunks de tamaño variable
163
+ - Puede generar chunks muy largos o muy cortos
164
+ - Depende de la calidad de detección de oraciones
165
+ """
166
+
167
+ def __init__(self,
168
+ min_length: int = settings.SENTENCE_MIN_LENGTH,
169
+ max_chunk_size: int = settings.SENTENCE_MAX_CHUNK_SIZE):
170
+ """
171
+ Inicializa el chunking por oraciones con parámetros configurables.
172
+
173
+ Args:
174
+ min_length: Longitud mínima para considerar una oración válida
175
+ max_chunk_size: Tamaño máximo permitido para un chunk
176
+ """
177
+ self.min_length = min_length
178
+ self.max_chunk_size = max_chunk_size
179
+ self.logger = logging.getLogger("chunking_system.sentence_chunking")
180
+
181
+ # Patrón regex para detectar finales de oración
182
+ # Busca punto, signo de interrogación o exclamación seguido de espacio o final de línea
183
+ self.sentence_pattern = re.compile(r'[.!?]+\s+|[.!?]+$')
184
+
185
+ @log_operation_time
186
+ def create_chunks(self, text: str) -> List[str]:
187
+ """
188
+ Crea chunks agrupando oraciones hasta alcanzar el tamaño objetivo.
189
+
190
+ El algoritmo identifica oraciones usando expresiones regulares,
191
+ las valida y las agrupa secuencialmente hasta alcanzar el tamaño
192
+ máximo permitido. Cada chunk contiene oraciones completas.
193
+
194
+ Args:
195
+ text: Texto a segmentar
196
+
197
+ Returns:
198
+ List[str]: Lista de chunks basados en oraciones
199
+ """
200
+ if not text or not validate_text_length(text):
201
+ self.logger.warning("Texto vacío o demasiado corto para chunking")
202
+ return []
203
+
204
+ # Dividir texto en oraciones
205
+ sentences = self._split_sentences(text)
206
+
207
+ if not sentences:
208
+ self.logger.warning("No se pudieron extraer oraciones válidas")
209
+ return []
210
+
211
+ # Agrupar oraciones en chunks
212
+ chunks = self._group_sentences_into_chunks(sentences)
213
+
214
+ self.logger.info(f"Chunking por oraciones completado: {len(chunks)} chunks "
215
+ f"generados desde {len(sentences)} oraciones")
216
+ return chunks
217
+
218
+ def _split_sentences(self, text: str) -> List[str]:
219
+ """
220
+ Divide el texto en oraciones individuales usando regex.
221
+
222
+ Este método utiliza patrones regex para identificar finales de oración
223
+ y divide el texto en consecuencia. Aplica validaciones para asegurar
224
+ que las oraciones resultantes sean significativas.
225
+
226
+ Args:
227
+ text: Texto a dividir en oraciones
228
+
229
+ Returns:
230
+ List[str]: Lista de oraciones válidas
231
+ """
232
+ # Dividir por patrones de final de oración
233
+ potential_sentences = self.sentence_pattern.split(text)
234
+
235
+ sentences = []
236
+ for sentence in potential_sentences:
237
+ # Limpiar y validar cada oración
238
+ clean_sentence = sentence.strip()
239
+
240
+ if validate_text_length(clean_sentence, self.min_length):
241
+ sentences.append(clean_sentence)
242
+ self.logger.debug(f"Oración válida: {len(clean_sentence)} caracteres")
243
+
244
+ return sentences
245
+
246
+ def _group_sentences_into_chunks(self, sentences: List[str]) -> List[str]:
247
+ """
248
+ Agrupa oraciones secuencialmente en chunks respetando tamaño máximo.
249
+
250
+ El algoritmo va agregando oraciones a un chunk actual hasta que
251
+ agregar la siguiente oración excedería el tamaño máximo. En ese
252
+ punto, finaliza el chunk actual e inicia uno nuevo.
253
+
254
+ Args:
255
+ sentences: Lista de oraciones a agrupar
256
+
257
+ Returns:
258
+ List[str]: Lista de chunks formados por oraciones agrupadas
259
+ """
260
+ chunks = []
261
+ current_chunk = []
262
+ current_length = 0
263
+
264
+ for sentence in sentences:
265
+ sentence_length = len(sentence)
266
+
267
+ # Verificar si agregar esta oración excedería el tamaño máximo
268
+ if (current_length + sentence_length > self.max_chunk_size and
269
+ current_chunk):
270
+
271
+ # Finalizar chunk actual
272
+ chunk_text = ' '.join(current_chunk)
273
+ chunks.append(chunk_text)
274
+
275
+ self.logger.debug(f"Chunk completado: {len(current_chunk)} oraciones, "
276
+ f"{current_length} caracteres")
277
+
278
+ # Iniciar nuevo chunk
279
+ current_chunk = [sentence]
280
+ current_length = sentence_length
281
+ else:
282
+ # Agregar oración al chunk actual
283
+ current_chunk.append(sentence)
284
+ current_length += sentence_length + 1 # +1 por el espacio separador
285
+
286
+ # Agregar último chunk si tiene contenido
287
+ if current_chunk:
288
+ chunk_text = ' '.join(current_chunk)
289
+ chunks.append(chunk_text)
290
+ self.logger.debug(f"Chunk final: {len(current_chunk)} oraciones, "
291
+ f"{current_length} caracteres")
292
+
293
+ return chunks
294
+
295
+ def get_strategy_name(self) -> str:
296
+ return f"sentence_chunking_{self.min_length}_{self.max_chunk_size}"
297
+
298
+
299
+ class AdaptiveChunking(ChunkingStrategy):
300
+ """
301
+ Implementa chunking adaptativo basado en similitud semántica.
302
+
303
+ Este método utiliza embeddings para calcular la similitud entre oraciones
304
+ consecutivas y agrupa aquellas que son semánticamente similares. Requiere
305
+ un modelo de embeddings externo pero puede producir chunks más coherentes
306
+ temáticamente.
307
+
308
+ Ventajas:
309
+ - Chunks semánticamente coherentes
310
+ - Adaptativo al contenido del texto
311
+ - Preserva temas y conceptos relacionados
312
+
313
+ Desventajas:
314
+ - Computacionalmente más costoso
315
+ - Requiere modelo de embeddings
316
+ - Tamaños de chunk muy variables
317
+ """
318
+
319
+ def __init__(self,
320
+ similarity_threshold: float = settings.ADAPTIVE_SIMILARITY_THRESHOLD,
321
+ min_chunk_size: int = settings.ADAPTIVE_MIN_CHUNK_SIZE,
322
+ max_chunk_size: int = settings.ADAPTIVE_MAX_CHUNK_SIZE):
323
+ """
324
+ Inicializa el chunking adaptativo con parámetros configurables.
325
+
326
+ Args:
327
+ similarity_threshold: Umbral de similitud coseno para agrupar oraciones
328
+ min_chunk_size: Tamaño mínimo de chunk en caracteres
329
+ max_chunk_size: Tamaño máximo de chunk en caracteres
330
+ """
331
+ self.similarity_threshold = similarity_threshold
332
+ self.min_chunk_size = min_chunk_size
333
+ self.max_chunk_size = max_chunk_size
334
+ self.logger = logging.getLogger("chunking_system.adaptive_chunking")
335
+
336
+ # Validar umbral de similitud
337
+ if not 0 <= similarity_threshold <= 1:
338
+ raise ValueError("similarity_threshold debe estar entre 0 y 1")
339
+
340
+ @log_operation_time
341
+ def create_chunks(self, text: str, embeddings_generator=None) -> List[str]:
342
+ """
343
+ Crea chunks basados en similitud semántica entre oraciones.
344
+
345
+ Este algoritmo:
346
+ 1. Divide el texto en oraciones
347
+ 2. Genera embeddings para cada oración
348
+ 3. Calcula similitud coseno entre oraciones consecutivas
349
+ 4. Agrupa oraciones con similitud superior al umbral
350
+ 5. Respeta límites de tamaño mínimo y máximo
351
+
352
+ Args:
353
+ text: Texto a segmentar
354
+ embeddings_generator: Función para generar embeddings de oraciones
355
+
356
+ Returns:
357
+ List[str]: Lista de chunks semánticamente coherentes
358
+ """
359
+ if not text or not validate_text_length(text):
360
+ self.logger.warning("Texto vacío o demasiado corto para chunking")
361
+ return []
362
+
363
+ if embeddings_generator is None:
364
+ self.logger.warning("No se proporcionó generador de embeddings. "
365
+ "Fallback a chunking por oraciones")
366
+ fallback = SentenceChunking()
367
+ return fallback.create_chunks(text)
368
+
369
+ # Dividir en oraciones usando la misma lógica que SentenceChunking
370
+ sentence_chunker = SentenceChunking()
371
+ sentences = sentence_chunker._split_sentences(text)
372
+
373
+ if len(sentences) < 2:
374
+ self.logger.info("Pocas oraciones para chunking adaptativo")
375
+ return sentences
376
+
377
+ # Generar embeddings para cada oración
378
+ sentence_embeddings = self._generate_sentence_embeddings(
379
+ sentences, embeddings_generator
380
+ )
381
+
382
+ # Calcular similitudes y agrupar
383
+ chunks = self._group_by_similarity(sentences, sentence_embeddings)
384
+
385
+ self.logger.info(f"Chunking adaptativo completado: {len(chunks)} chunks "
386
+ f"generados desde {len(sentences)} oraciones")
387
+ return chunks
388
+
389
+ def _generate_sentence_embeddings(self,
390
+ sentences: List[str],
391
+ embeddings_generator) -> np.ndarray:
392
+ """
393
+ Genera embeddings para una lista de oraciones.
394
+
395
+ Args:
396
+ sentences: Lista de oraciones
397
+ embeddings_generator: Función que genera embeddings
398
+
399
+ Returns:
400
+ np.ndarray: Array de embeddings de forma (n_sentences, embedding_dim)
401
+ """
402
+ embeddings = []
403
+
404
+ for i, sentence in enumerate(sentences):
405
+ try:
406
+ embedding = embeddings_generator(sentence)
407
+ embeddings.append(embedding)
408
+ self.logger.debug(f"Embedding generado para oración {i+1}")
409
+ except Exception as e:
410
+ self.logger.error(f"Error generando embedding para oración {i+1}: {e}")
411
+ # Usar vector cero como fallback
412
+ embeddings.append(np.zeros(settings.EMBEDDING_DIMENSION))
413
+
414
+ return np.array(embeddings)
415
+
416
+ def _group_by_similarity(self,
417
+ sentences: List[str],
418
+ embeddings: np.ndarray) -> List[str]:
419
+ """
420
+ Agrupa oraciones basándose en similitud de sus embeddings.
421
+
422
+ El algoritmo utiliza similitud coseno para determinar si dos oraciones
423
+ consecutivas deben ser agrupadas en el mismo chunk. La fórmula de
424
+ similitud coseno es: sim(A,B) = (A·B) / (||A|| × ||B||)
425
+
426
+ Args:
427
+ sentences: Lista de oraciones
428
+ embeddings: Embeddings correspondientes a cada oración
429
+
430
+ Returns:
431
+ List[str]: Lista de chunks agrupados por similitud
432
+ """
433
+ if len(sentences) != len(embeddings):
434
+ raise ValueError("Número de oraciones y embeddings no coincide")
435
+
436
+ chunks = []
437
+ current_chunk = [sentences[0]]
438
+ current_length = len(sentences[0])
439
+
440
+ for i in range(1, len(sentences)):
441
+ # Calcular similitud coseno entre oración actual y anterior
442
+ similarity = cosine_similarity(
443
+ embeddings[i-1].reshape(1, -1),
444
+ embeddings[i].reshape(1, -1)
445
+ )[0, 0]
446
+
447
+ sentence_length = len(sentences[i])
448
+ new_length = current_length + sentence_length + 1
449
+
450
+ # Decidir si agregar a chunk actual o crear nuevo chunk
451
+ should_group = (
452
+ similarity >= self.similarity_threshold and
453
+ new_length <= self.max_chunk_size
454
+ )
455
+
456
+ if should_group:
457
+ # Agregar a chunk actual
458
+ current_chunk.append(sentences[i])
459
+ current_length = new_length
460
+ self.logger.debug(f"Oraciones {i} y {i+1} agrupadas "
461
+ f"(similitud: {similarity:.4f})")
462
+ else:
463
+ # Finalizar chunk actual. Se añade siempre para asegurar
464
+ # que el documento quede completamente cubierto, incluso
465
+ # si el chunk es menor que el tamaño mínimo configurado.
466
+ chunk_text = ' '.join(current_chunk)
467
+ chunks.append(chunk_text)
468
+ self.logger.debug(f"Chunk completado: {len(current_chunk)} oraciones, "
469
+ f"{current_length} caracteres")
470
+
471
+ # Iniciar nuevo chunk
472
+ current_chunk = [sentences[i]]
473
+ current_length = sentence_length
474
+
475
+ if similarity < self.similarity_threshold:
476
+ self.logger.debug(f"Nueva sección detectada en oración {i+1} "
477
+ f"(similitud: {similarity:.4f})")
478
+
479
+ # Agregar último chunk (siempre que tenga contenido)
480
+ if current_chunk:
481
+ chunk_text = ' '.join(current_chunk)
482
+ chunks.append(chunk_text)
483
+ self.logger.debug(f"Chunk final: {len(current_chunk)} oraciones, "
484
+ f"{current_length} caracteres")
485
+
486
+ return chunks
487
+
488
+ def get_strategy_name(self) -> str:
489
+ return f"adaptive_chunking_{self.similarity_threshold}_{self.min_chunk_size}_{self.max_chunk_size}"
490
+
491
+
492
+ class ChunkingManager:
493
+ """
494
+ Gestor principal para seleccionar y ejecutar estrategias de chunking.
495
+
496
+ Esta clase actúa como fachada para todos los métodos de chunking,
497
+ permitiendo seleccionar la estrategia desde configuración y proporcionando
498
+ una interfaz unificada para el sistema principal.
499
+ """
500
+
501
+ def __init__(self):
502
+ """
503
+ Inicializa el gestor con la estrategia configurada en settings.
504
+ """
505
+ self.logger = logging.getLogger("chunking_system.manager")
506
+ self.strategy = self._create_strategy()
507
+
508
+ def _create_strategy(self) -> ChunkingStrategy:
509
+ """
510
+ Crea la estrategia de chunking según la configuración.
511
+
512
+ Returns:
513
+ ChunkingStrategy: Instancia de la estrategia configurada
514
+
515
+ Raises:
516
+ ValueError: Si el método configurado no es válido
517
+ """
518
+ method = settings.CHUNKING_METHOD.lower()
519
+
520
+ if method == "fixed":
521
+ return FixedChunking()
522
+ elif method == "sentence":
523
+ return SentenceChunking()
524
+ elif method == "adaptive":
525
+ return AdaptiveChunking()
526
+ else:
527
+ raise ValueError(f"Método de chunking no válido: {method}")
528
+
529
+ @log_operation_time
530
+ def create_chunks(self, text: str, embeddings_generator=None) -> List[str]:
531
+ """
532
+ Crea chunks usando la estrategia configurada.
533
+
534
+ Args:
535
+ text: Texto a segmentar
536
+ embeddings_generator: Generador de embeddings (requerido para adaptativo)
537
+
538
+ Returns:
539
+ List[str]: Lista de chunks generados
540
+ """
541
+ self.logger.info(f"Iniciando chunking con estrategia: {self.strategy.get_strategy_name()}")
542
+
543
+ # Para chunking adaptativo, pasar el generador de embeddings
544
+ if isinstance(self.strategy, AdaptiveChunking):
545
+ chunks = self.strategy.create_chunks(text, embeddings_generator)
546
+ else:
547
+ chunks = self.strategy.create_chunks(text)
548
+
549
+ self.logger.info(f"Chunking completado: {len(chunks)} chunks generados")
550
+
551
+ # Log estadísticas de chunks
552
+ if chunks:
553
+ lengths = [len(chunk) for chunk in chunks]
554
+ self.logger.info(f"Estadísticas de chunks - "
555
+ f"Min: {min(lengths)}, Max: {max(lengths)}, "
556
+ f"Promedio: {sum(lengths)/len(lengths):.1f} caracteres")
557
+
558
+ return chunks
559
+
560
+ def get_current_strategy_name(self) -> str:
561
+ """
562
+ Obtiene el nombre de la estrategia actualmente configurada.
563
+
564
+ Returns:
565
+ str: Nombre de la estrategia actual
566
+ """
567
+ return self.strategy.get_strategy_name()
document_processor.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Document processor module for handling various document types and chunking methods.
3
+ Integrates PDF loading and chunking functionality for the Gradio interface.
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, List, Optional
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # Import existing modules
12
+ from pdf_loader import load_pdf_from_settings
13
+ from chunking_methods import ChunkingManager, FixedChunking, SentenceChunking, AdaptiveChunking
14
+ import settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class DocumentProcessor:
19
+ """
20
+ Handles document loading and processing with multiple chunking methods.
21
+ """
22
+
23
+ def __init__(self):
24
+ """Initialize the document processor with available chunking methods."""
25
+ self.chunking_manager = ChunkingManager()
26
+
27
+ # Register available chunking methods
28
+ self.chunking_strategies = {
29
+ 'fixed': FixedChunking(),
30
+ 'sentence': SentenceChunking(),
31
+ 'adaptive': AdaptiveChunking()
32
+ }
33
+
34
+ logger.info(f"DocumentProcessor initialized with {len(self.chunking_strategies)} chunking strategies")
35
+
36
+ def load_document(self, file_path: str) -> Optional[str]:
37
+ """
38
+ Load document content based on file type.
39
+
40
+ Args:
41
+ file_path: Path to the document file
42
+
43
+ Returns:
44
+ Document text content or None if loading fails
45
+ """
46
+ try:
47
+ if not os.path.exists(file_path):
48
+ logger.error(f"File not found: {file_path}")
49
+ return None
50
+
51
+ file_extension = Path(file_path).suffix.lower()
52
+
53
+ if file_extension == '.pdf':
54
+ # Use existing PDF loader
55
+ # For now, we'll temporarily set the PDF path in settings
56
+ original_pdf_path = getattr(settings, 'PDF_CONTENT_FILE', None)
57
+ settings.PDF_CONTENT_FILE = file_path
58
+
59
+ try:
60
+ content = load_pdf_from_settings()
61
+ return content
62
+ finally:
63
+ # Restore original setting
64
+ if original_pdf_path:
65
+ settings.PDF_CONTENT_FILE = original_pdf_path
66
+
67
+ elif file_extension == '.txt':
68
+ with open(file_path, 'r', encoding='utf-8') as f:
69
+ return f.read()
70
+
71
+ elif file_extension in ['.doc', '.docx']:
72
+ try:
73
+ from docx import Document
74
+ doc = Document(file_path)
75
+ content = []
76
+ for paragraph in doc.paragraphs:
77
+ content.append(paragraph.text)
78
+ return '\n'.join(content)
79
+ except ImportError:
80
+ logger.error("python-docx not installed. Cannot process DOCX files.")
81
+ return None
82
+
83
+ else:
84
+ logger.error(f"Unsupported file type: {file_extension}")
85
+ return None
86
+
87
+ except Exception as e:
88
+ logger.error(f"Error loading document {file_path}: {str(e)}")
89
+ return None
90
+
91
+ def process_document(self, file_path: str, chunking_methods: List[str]) -> Dict[str, List[str]]:
92
+ """
93
+ Process a document with specified chunking methods.
94
+
95
+ Args:
96
+ file_path: Path to the document file
97
+ chunking_methods: List of chunking method names to apply
98
+
99
+ Returns:
100
+ Dictionary mapping method names to lists of chunks
101
+ """
102
+ try:
103
+ # Load document content
104
+ content = self.load_document(file_path)
105
+ if not content:
106
+ logger.error(f"Failed to load content from {file_path}")
107
+ return {}
108
+
109
+ logger.info(f"Loaded document with {len(content)} characters")
110
+
111
+ # Process with each requested chunking method
112
+ chunks_by_method = {}
113
+
114
+ for method_name in chunking_methods:
115
+ if method_name not in self.chunking_strategies:
116
+ logger.warning(f"Unknown chunking method: {method_name}")
117
+ continue
118
+
119
+ try:
120
+ strategy = self.chunking_strategies[method_name]
121
+ chunks = strategy.chunk_text(content)
122
+ chunks_by_method[method_name] = chunks
123
+
124
+ logger.info(f"Generated {len(chunks)} chunks using {method_name} method")
125
+
126
+ except Exception as e:
127
+ logger.error(f"Error processing with {method_name} method: {str(e)}")
128
+ continue
129
+
130
+ return chunks_by_method
131
+
132
+ except Exception as e:
133
+ logger.error(f"Error processing document {file_path}: {str(e)}")
134
+ return {}
135
+
136
+ def get_supported_extensions(self) -> List[str]:
137
+ """
138
+ Get list of supported file extensions.
139
+
140
+ Returns:
141
+ List of supported file extensions
142
+ """
143
+ return ['.pdf', '.txt', '.docx']
144
+
145
+ def validate_file(self, file_path: str) -> bool:
146
+ """
147
+ Validate if a file can be processed.
148
+
149
+ Args:
150
+ file_path: Path to the file to validate
151
+
152
+ Returns:
153
+ True if file can be processed, False otherwise
154
+ """
155
+ if not os.path.exists(file_path):
156
+ return False
157
+
158
+ extension = Path(file_path).suffix.lower()
159
+ return extension in self.get_supported_extensions()
embeddings_qdrant.py ADDED
@@ -0,0 +1,710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MIT License
3
+ Autor: Fernando Lafuente
4
+ Proyecto: Ejercicio opcional NLP - Chunking y Recuperación
5
+ Descripción: Generación de embeddings con Google y gestión de base de datos vectorial Qdrant
6
+
7
+ Este módulo implementa la generación de embeddings utilizando Google Embeddings
8
+ y la gestión completa de la base de datos vectorial Qdrant. Incluye funciones
9
+ para insertar chunks, realizar búsquedas por similitud y administrar colecciones.
10
+
11
+ La similitud se calcula usando la distancia coseno, que es especialmente efectiva
12
+ para embeddings normalizados y representa la similitud semántica entre vectores.
13
+ """
14
+
15
+ import logging
16
+ import time
17
+ from typing import List, Tuple, Optional, Dict, Any
18
+
19
+ import numpy as np
20
+ from qdrant_client import QdrantClient
21
+ from qdrant_client.http import models
22
+ from qdrant_client.http.models import Distance, VectorParams, PointStruct
23
+ from openai import OpenAI
24
+
25
+ import settings
26
+ from utils_logging import setup_logging, log_operation_time, format_similarity_score
27
+
28
+
29
+ # Configurar logger específico para este módulo
30
+ logger = setup_logging()
31
+
32
+
33
+ class OpenAIEmbeddingsGenerator:
34
+ """
35
+ Generador de embeddings utilizando la API de OpenAI.
36
+
37
+ Esta clase encapsula la lógica para generar embeddings vectoriales usando
38
+ los modelos de embeddings de OpenAI. Los embeddings son representaciones
39
+ vectoriales de alta dimensionalidad que capturan el significado semántico
40
+ del texto de entrada.
41
+
42
+ Los embeddings de OpenAI text-embedding-3-small tienen 1536 dimensiones
43
+ y ofrecen una excelente relación calidad-precio para tareas de similitud
44
+ semántica y búsqueda de información.
45
+ """
46
+
47
+ def __init__(self, api_key: str = settings.OPENAI_API_KEY):
48
+ """
49
+ Inicializa el generador con la clave de API de OpenAI.
50
+
51
+ Args:
52
+ api_key: Clave de API de OpenAI
53
+
54
+ Raises:
55
+ ValueError: Si la clave de API no es válida
56
+ """
57
+ if not api_key:
58
+ raise ValueError("Clave de API de OpenAI no configurada")
59
+
60
+ # Configurar cliente de OpenAI
61
+ self.client = OpenAI(api_key=api_key)
62
+
63
+ self.model_name = settings.OPENAI_EMBEDDING_MODEL
64
+ self.logger = logging.getLogger("chunking_system.embeddings_generator")
65
+
66
+ self.logger.info(f"Generador de embeddings inicializado con modelo: {self.model_name}")
67
+
68
+ @log_operation_time
69
+ def generate_embedding(self, text: str) -> np.ndarray:
70
+ """
71
+ Genera un vector de embedding para un texto dado.
72
+
73
+ Este método utiliza la API de OpenAI para convertir texto en un vector
74
+ numérico de alta dimensionalidad. El vector resultante captura las
75
+ características semánticas del texto y puede ser utilizado para
76
+ calcular similitudes con otros textos.
77
+
78
+ Args:
79
+ text: Texto para el cual generar el embedding
80
+
81
+ Returns:
82
+ np.ndarray: Vector de embedding de dimensión 1536
83
+
84
+ Raises:
85
+ ValueError: Si el texto está vacío
86
+ Exception: Si hay errores en la API de OpenAI
87
+
88
+ Example:
89
+ generator = OpenAIEmbeddingsGenerator()
90
+ vector = generator.generate_embedding("Este es un texto de ejemplo")
91
+ # vector.shape = (1536,)
92
+ """
93
+ if not text or not text.strip():
94
+ raise ValueError("El texto no puede estar vacío")
95
+
96
+ try:
97
+ # Llamada a la API de OpenAI para generar embedding
98
+ response = self.client.embeddings.create(
99
+ model=self.model_name,
100
+ input=text
101
+ )
102
+
103
+ # Extraer el vector de embedding
104
+ embedding_vector = np.array(response.data[0].embedding, dtype=np.float32)
105
+
106
+ # Validar dimensionalidad
107
+ if embedding_vector.shape[0] != settings.EMBEDDING_DIMENSION:
108
+ raise ValueError(f"Dimensión inesperada del embedding: {embedding_vector.shape[0]}")
109
+
110
+ self.logger.debug(f"Embedding generado exitosamente para texto de {len(text)} caracteres")
111
+
112
+ return embedding_vector
113
+
114
+ except Exception as e:
115
+ self.logger.error(f"Error generando embedding: {str(e)}")
116
+ raise
117
+
118
+ def generate_query_embedding(self, query: str) -> np.ndarray:
119
+ """
120
+ Genera embedding optimizado para consultas de búsqueda.
121
+
122
+ Para OpenAI embeddings, utilizamos el mismo método que para documentos
123
+ ya que el modelo está optimizado para tareas generales de similitud.
124
+
125
+ Args:
126
+ query: Consulta de búsqueda
127
+
128
+ Returns:
129
+ np.ndarray: Vector de embedding optimizado para consultas
130
+ """
131
+ if not query or not query.strip():
132
+ raise ValueError("La consulta no puede estar vacía")
133
+
134
+ try:
135
+ # Para OpenAI, usamos el mismo método para consultas y documentos
136
+ response = self.client.embeddings.create(
137
+ model=self.model_name,
138
+ input=query
139
+ )
140
+
141
+ embedding_vector = np.array(response.data[0].embedding, dtype=np.float32)
142
+
143
+ self.logger.debug(f"Embedding de consulta generado para: '{query[:50]}...'")
144
+
145
+ return embedding_vector
146
+
147
+ except Exception as e:
148
+ self.logger.error(f"Error generando embedding de consulta: {str(e)}")
149
+ raise
150
+
151
+ @log_operation_time
152
+ def generate_batch_embeddings(self, texts: List[str]) -> List[np.ndarray]:
153
+ """
154
+ Genera embeddings para múltiples textos de forma eficiente.
155
+
156
+ Este método procesa múltiples textos secuencialmente, implementando
157
+ un manejo robusto de errores y rate limiting para evitar problemas
158
+ con la API de Google.
159
+
160
+ Args:
161
+ texts: Lista de textos para procesar
162
+
163
+ Returns:
164
+ List[np.ndarray]: Lista de vectores de embedding
165
+ """
166
+ if not texts:
167
+ return []
168
+
169
+ embeddings = []
170
+ failed_indices = []
171
+
172
+ self.logger.info(f"Generando embeddings para {len(texts)} textos")
173
+
174
+ for i, text in enumerate(texts):
175
+ try:
176
+ # Pequeña pausa para evitar rate limiting
177
+ if i > 0 and i % 10 == 0:
178
+ time.sleep(1)
179
+ self.logger.debug(f"Pausa preventiva después de procesar {i} textos")
180
+
181
+ embedding = self.generate_embedding(text)
182
+ embeddings.append(embedding)
183
+
184
+ # Log progreso cada 50 textos
185
+ if (i + 1) % 50 == 0:
186
+ self.logger.info(f"Progreso: {i + 1}/{len(texts)} embeddings generados")
187
+
188
+ except Exception as e:
189
+ self.logger.warning(f"Error procesando texto {i}: {str(e)}")
190
+ failed_indices.append(i)
191
+ # Agregar vector cero como fallback
192
+ zero_vec = np.zeros(settings.EMBEDDING_DIMENSION, dtype=np.float32)
193
+ embeddings.append(zero_vec)
194
+
195
+ # Log de diagnóstico: mostrar norma del primer embedding y si hay vectores cero
196
+ try:
197
+ nonzero_norms = [float(np.linalg.norm(v)) for v in embeddings if np.any(v)]
198
+ zero_count = len([v for v in embeddings if not np.any(v)])
199
+ if nonzero_norms:
200
+ self.logger.info(f"Embeddings generados: muestras - norma primera={nonzero_norms[0]:.4f}, zeros={zero_count}")
201
+ else:
202
+ self.logger.info(f"Embeddings generados: todos ceros, count={len(embeddings)}")
203
+ except Exception:
204
+ self.logger.debug("No se pudo calcular norma de embeddings para diagnóstico")
205
+
206
+ if failed_indices:
207
+ self.logger.warning(f"Fallos en {len(failed_indices)} textos: {failed_indices}")
208
+
209
+ self.logger.info(f"Generación de embeddings completada: {len(embeddings)} vectores")
210
+
211
+ return embeddings
212
+
213
+
214
+ class QdrantManager:
215
+ """
216
+ Gestor para la base de datos vectorial Qdrant.
217
+
218
+ Esta clase encapsula todas las operaciones con Qdrant, incluyendo
219
+ la creación de colecciones, inserción de vectores, búsqueda por
220
+ similitud y administración general de la base de datos vectorial.
221
+
222
+ Qdrant utiliza el algoritmo HNSW (Hierarchical Navigable Small World)
223
+ para búsqueda aproximada de vecinos más cercanos, lo que permite
224
+ búsquedas eficientes en espacios de alta dimensionalidad.
225
+ """
226
+
227
+ def __init__(self,
228
+ url: str = settings.QDRANT_URL,
229
+ collection_name: str = settings.QDRANT_COLLECTION_NAME):
230
+ """
231
+ Inicializa la conexión con Qdrant.
232
+
233
+ Args:
234
+ url: URL de conexión a Qdrant
235
+ collection_name: Nombre de la colección a utilizar
236
+ """
237
+ self.url = url
238
+ self.collection_name = collection_name
239
+ self.client = QdrantClient(url=url)
240
+ self.logger = logging.getLogger("chunking_system.qdrant_manager")
241
+
242
+ self.logger.info(f"Conectando a Qdrant en {url}")
243
+
244
+ # Verificar conexión
245
+ try:
246
+ health = self.client.get_collections()
247
+ self.logger.info("Conexión con Qdrant establecida correctamente")
248
+ except Exception as e:
249
+ self.logger.error(f"Error conectando con Qdrant: {str(e)}")
250
+ raise
251
+
252
+ @log_operation_time
253
+ def create_collection_if_not_exists(self):
254
+ """
255
+ Crea la colección en Qdrant si no existe.
256
+
257
+ La colección se configura para almacenar vectores de 768 dimensiones
258
+ (compatibles con Google Embeddings) usando distancia coseno para
259
+ calcular similitudes. El índice HNSW se optimiza para búsquedas rápidas.
260
+ """
261
+ try:
262
+ # Verificar si la colección ya existe
263
+ collections = self.client.get_collections().collections
264
+ existing_collections = [col.name for col in collections]
265
+
266
+ if self.collection_name in existing_collections:
267
+ self.logger.info(f"Colección '{self.collection_name}' ya existe")
268
+ return
269
+
270
+ # Crear nueva colección
271
+ self.client.create_collection(
272
+ collection_name=self.collection_name,
273
+ vectors_config=VectorParams(
274
+ size=settings.EMBEDDING_DIMENSION,
275
+ distance=Distance.COSINE # Distancia coseno para similitud semántica
276
+ )
277
+ )
278
+
279
+ self.logger.info(f"Colección '{self.collection_name}' creada exitosamente")
280
+
281
+ except Exception as e:
282
+ self.logger.error(f"Error creando colección: {str(e)}")
283
+ raise
284
+
285
+ @log_operation_time
286
+ def insert_chunks(self, chunks: List[str], embeddings: List[np.ndarray], chunking_method: Optional[str] = None) -> bool:
287
+ """
288
+ Inserta chunks y sus embeddings en la colección de Qdrant.
289
+
290
+ Este método almacena tanto el texto original de cada chunk como su
291
+ representación vectorial, permitiendo recuperar el texto original
292
+ después de realizar búsquedas vectoriales.
293
+
294
+ Args:
295
+ chunks: Lista de textos de chunks
296
+ embeddings: Lista de vectores de embedding correspondientes
297
+
298
+ Returns:
299
+ bool: True si la inserción fue exitosa
300
+
301
+ Raises:
302
+ ValueError: Si las listas no tienen la misma longitud
303
+ Exception: Si hay errores en la inserción
304
+ """
305
+ if len(chunks) != len(embeddings):
306
+ raise ValueError("El número de chunks debe coincidir con el número de embeddings")
307
+
308
+ if not chunks:
309
+ self.logger.warning("No hay chunks para insertar")
310
+ return True
311
+
312
+ try:
313
+ # Crear puntos para insertar en Qdrant
314
+ points = []
315
+
316
+ for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
317
+ payload = {
318
+ "text": chunk, # Texto original del chunk
319
+ "chunk_id": i, # ID del chunk (local al lote)
320
+ "length": len(chunk), # Longitud del chunk
321
+ "timestamp": time.time() # Timestamp de inserción
322
+ }
323
+
324
+ # Añadir metadata del método de chunking si está disponible
325
+ if chunking_method:
326
+ payload["chunking_method"] = chunking_method
327
+
328
+ point = PointStruct(
329
+ id=i, # ID único para cada chunk (local a la colección)
330
+ vector=embedding.tolist(), # Convertir numpy array a lista
331
+ payload=payload
332
+ )
333
+ points.append(point)
334
+
335
+ # Insertar todos los puntos en Qdrant
336
+ operation_info = self.client.upsert(
337
+ collection_name=self.collection_name,
338
+ points=points
339
+ )
340
+
341
+ self.logger.info(f"Insertados {len(chunks)} chunks en Qdrant exitosamente")
342
+ self.logger.debug(f"Información de operación: {operation_info}")
343
+
344
+ # Comprobar estado después de la inserción
345
+ try:
346
+ info = self.get_collection_info()
347
+ self.logger.info(f"Estado colección '{self.collection_name}' tras upsert: puntos={info.get('points_count')}")
348
+ except Exception:
349
+ self.logger.debug("No fue posible obtener info de colección tras upsert")
350
+
351
+ return True
352
+
353
+ except Exception as e:
354
+ self.logger.error(f"Error insertando chunks en Qdrant: {str(e)}")
355
+ raise
356
+
357
+ @log_operation_time
358
+ def search_similar_chunks(self,
359
+ query_embedding: np.ndarray,
360
+ top_k: int = settings.TOP_K_RESULTS,
361
+ min_score: float = settings.MIN_SIMILARITY_SCORE) -> List[Tuple[str, float, Dict[str, Any]]]:
362
+ """
363
+ Busca chunks similares a un embedding de consulta.
364
+
365
+ Esta función implementa búsqueda por similitud vectorial usando
366
+ distancia coseno. Retorna los chunks más similares junto con sus
367
+ puntuaciones de similitud, filtrados por puntuación mínima.
368
+
369
+ La similitud coseno se calcula como: sim(A,B) = (A·B) / (||A|| × ||B||)
370
+ donde A es el vector de consulta y B es cada vector en la base de datos.
371
+
372
+ Args:
373
+ query_embedding: Vector de embedding de la consulta
374
+ top_k: Número máximo de resultados a retornar
375
+ min_score: Puntuación mínima de similitud para incluir un resultado
376
+
377
+ Returns:
378
+ List[Tuple[str, float]]: Lista de tuplas (texto_chunk, puntuación)
379
+ ordenadas por similitud descendente
380
+
381
+ Example:
382
+ results = manager.search_similar_chunks(query_vector, top_k=5)
383
+ for text, score in results:
384
+ print(f"Similitud: {score:.4f} - {text[:100]}...")
385
+ """
386
+ try:
387
+ # Realizar búsqueda vectorial en Qdrant
388
+ search_result = self.client.search(
389
+ collection_name=self.collection_name,
390
+ query_vector=query_embedding.tolist(),
391
+ limit=top_k,
392
+ score_threshold=min_score # Filtro por puntuación mínima
393
+ )
394
+
395
+ # Extraer resultados y formatear
396
+ results = []
397
+ for hit in search_result:
398
+ chunk_text = hit.payload.get("text", "")
399
+ similarity_score = hit.score
400
+ payload = hit.payload or {}
401
+ results.append((chunk_text, similarity_score, payload))
402
+
403
+ self.logger.info(f"Búsqueda completada: {len(results)} chunks encontrados "
404
+ f"(umbral mínimo: {min_score})")
405
+
406
+ # Log estadísticas de puntuaciones
407
+ if results:
408
+ scores = [score for _, score, _ in results]
409
+ self.logger.debug(f"Puntuaciones - Max: {max(scores):.4f}, "
410
+ f"Min: {min(scores):.4f}, "
411
+ f"Promedio: {sum(scores)/len(scores):.4f}")
412
+
413
+ return results
414
+
415
+ except Exception as e:
416
+ self.logger.error(f"Error en búsqueda vectorial: {str(e)}")
417
+ raise
418
+
419
+ def get_collection_info(self) -> Dict[str, Any]:
420
+ """
421
+ Obtiene información estadística de la colección.
422
+
423
+ Returns:
424
+ Dict[str, Any]: Información de la colección (número de puntos, etc.)
425
+ """
426
+ try:
427
+ collection_info = self.client.get_collection(self.collection_name)
428
+
429
+ info = {
430
+ "name": self.collection_name,
431
+ "points_count": collection_info.points_count,
432
+ "vectors_count": collection_info.vectors_count,
433
+ "status": collection_info.status
434
+ }
435
+
436
+ self.logger.debug(f"Información de colección: {info}")
437
+ return info
438
+
439
+ except Exception as e:
440
+ self.logger.error(f"Error obteniendo información de colección: {str(e)}")
441
+ return {}
442
+
443
+ def clear_collection(self):
444
+ """
445
+ Elimina todos los puntos de la colección.
446
+
447
+ Útil para testing o para reinicializar la base de datos.
448
+ """
449
+ try:
450
+ self.client.delete_collection(self.collection_name)
451
+ self.logger.info(f"Colección '{self.collection_name}' eliminada")
452
+
453
+ # Recrear colección vacía
454
+ self.create_collection_if_not_exists()
455
+
456
+ except Exception as e:
457
+ self.logger.error(f"Error limpiando colección: {str(e)}")
458
+ raise
459
+
460
+
461
+ class EmbeddingsQdrantPipeline:
462
+ """
463
+ Pipeline completo para generar embeddings y gestionar Qdrant.
464
+
465
+ Esta clase orquesta el proceso completo: generación de embeddings para
466
+ chunks, inserción en Qdrant y búsqueda de chunks similares a consultas.
467
+ Actúa como una fachada que simplifica el uso del sistema completo.
468
+ """
469
+
470
+ def __init__(self):
471
+ """
472
+ Inicializa el pipeline completo con generador de embeddings y Qdrant.
473
+ """
474
+ self.embeddings_generator = OpenAIEmbeddingsGenerator()
475
+ # Mantener un gestor genérico, pero las colecciones por método se crearán
476
+ # dinámicamente cuando procesemos cada método de chunking.
477
+ self.qdrant_manager = QdrantManager()
478
+ self.logger = logging.getLogger("chunking_system.embeddings_pipeline")
479
+
480
+ # NOTA: La colección genérica ya no se usa, solo las específicas por método
481
+ # self.qdrant_manager.create_collection_if_not_exists()
482
+
483
+ self.logger.info("Pipeline de embeddings y Qdrant inicializado")
484
+
485
+ @log_operation_time
486
+ def process_and_store_chunks(self, chunks_by_method: Dict[str, List[str]]) -> bool:
487
+ """
488
+ Procesa chunks completos: genera embeddings y los almacena en Qdrant.
489
+
490
+ IMPORTANTE: Verifica si ya existen embeddings para evitar costos innecesarios de API.
491
+
492
+ Este método ejecuta el pipeline completo para un conjunto de chunks:
493
+ 1. Verifica si ya existen datos en Qdrant
494
+ 2. Si no existen, genera embeddings para todos los chunks
495
+ 3. Los almacena en Qdrant con sus metadatos
496
+ 4. Verifica que la inserción fue exitosa
497
+
498
+ Args:
499
+ chunks: Lista de chunks de texto a procesar
500
+
501
+ Returns:
502
+ bool: True si el procesamiento fue exitoso
503
+ """
504
+ if not chunks_by_method:
505
+ self.logger.warning("No hay chunks para procesar")
506
+ return True
507
+
508
+ try:
509
+ # Procesar cada método de chunking por separado. Cada método utiliza
510
+ # su propia colección en Qdrant — esto permite evitar colisiones y
511
+ # controlar regeneraciones por método para ahorrar llamadas a OpenAI.
512
+ total_inserted = 0
513
+
514
+ for method, chunks in chunks_by_method.items():
515
+ if not chunks:
516
+ self.logger.info(f"Método '{method}': no hay chunks, se omite")
517
+ continue
518
+
519
+ collection_name = f"{settings.QDRANT_COLLECTION_NAME}_{method}"
520
+ manager = QdrantManager(collection_name=collection_name)
521
+ manager.create_collection_if_not_exists()
522
+
523
+ collection_info = manager.get_collection_info()
524
+ existing_points = collection_info.get("points_count", 0)
525
+
526
+ if existing_points > 0:
527
+ self.logger.info(f"Método '{method}': encontrados {existing_points} puntos en colección '{collection_name}'. Se omite generación para ahorrar costos.")
528
+ continue
529
+
530
+ # Generar embeddings solo para los chunks de este método
531
+ self.logger.info(f"Generando embeddings para método '{method}' ({len(chunks)} chunks)")
532
+ embeddings = self.embeddings_generator.generate_batch_embeddings(chunks)
533
+
534
+ # Insertar en la colección correspondiente incluyendo metadata
535
+ self.logger.info(f"Insertando {len(chunks)} chunks en colección '{collection_name}'")
536
+ success = manager.insert_chunks(chunks, embeddings, chunking_method=method)
537
+
538
+ if not success:
539
+ raise RuntimeError(f"Fallo al insertar chunks para el método {method}")
540
+
541
+ collection_info = manager.get_collection_info()
542
+ total_inserted += collection_info.get("points_count", 0)
543
+
544
+ self.logger.info(f"Procesamiento completado. Total insertados en todas las colecciones: {total_inserted}")
545
+ return True
546
+
547
+ except Exception as e:
548
+ self.logger.error(f"Error en pipeline de procesamiento: {str(e)}")
549
+ raise
550
+
551
+ @log_operation_time
552
+ def search_chunks(self, query: str, top_k: int = settings.TOP_K_RESULTS) -> List[Tuple[str, float, str]]:
553
+ """
554
+ Busca chunks relevantes para una consulta de texto.
555
+
556
+ Este método implementa el flujo completo de búsqueda:
557
+ 1. Genera embedding para la consulta
558
+ 2. Busca chunks similares en Qdrant
559
+ 3. Retorna resultados ordenados por relevancia
560
+
561
+ Args:
562
+ query: Consulta de texto del usuario
563
+ top_k: Número de resultados a retornar
564
+
565
+ Returns:
566
+ List[Tuple[str, float]]: Chunks relevantes con puntuaciones
567
+
568
+ Example:
569
+ pipeline = EmbeddingsQdrantPipeline()
570
+ results = pipeline.search_chunks("¿Qué es el aprendizaje automático?")
571
+ for chunk, score in results:
572
+ print(f"Relevancia: {score:.4f}")
573
+ print(f"Contenido: {chunk}")
574
+ """
575
+ if not query or not query.strip():
576
+ raise ValueError("La consulta no puede estar vacía")
577
+
578
+ try:
579
+ # Generar embedding para la consulta
580
+ self.logger.info(f"Procesando consulta: '{query[:100]}...'")
581
+ query_embedding = self.embeddings_generator.generate_query_embedding(query)
582
+
583
+ # Buscar en todas las colecciones por método y agregar metadata
584
+ aggregated_results: List[Tuple[str, float, str]] = []
585
+
586
+ for method in settings.CHUNKING_METHODS:
587
+ collection_name = f"{settings.QDRANT_COLLECTION_NAME}_{method}"
588
+ try:
589
+ manager = QdrantManager(collection_name=collection_name)
590
+ except Exception:
591
+ # Si no existe la colección o falla la conexión, omitimos
592
+ self.logger.debug(f"Colección '{collection_name}' no disponible, se omite")
593
+ continue
594
+
595
+ # Realizar la búsqueda en la colección del método
596
+ try:
597
+ hits = manager.search_similar_chunks(query_embedding=query_embedding, top_k=top_k)
598
+ except Exception as e:
599
+ self.logger.warning(f"Error buscando en colección '{collection_name}': {str(e)}")
600
+ continue
601
+
602
+ # Si no hay hits (por filtro de score), reintentar sin umbral para diagnóstico
603
+ if not hits:
604
+ self.logger.debug(f"No se encontraron hits en '{collection_name}' con umbral {settings.MIN_SIMILARITY_SCORE}. Reintentando sin umbral para diagnóstico")
605
+ try:
606
+ hits = manager.search_similar_chunks(query_embedding=query_embedding, top_k=top_k, min_score=0.0)
607
+ if hits:
608
+ self.logger.info(f"Hit(s) encontrados en '{collection_name}' solo al relajar el umbral: {len(hits)}")
609
+ except Exception as e:
610
+ self.logger.debug(f"Reintento sin umbral falló en '{collection_name}': {str(e)}")
611
+
612
+ for text, score, payload in hits:
613
+ # Preferir el método almacenado en el payload si existe
614
+ payload_method = payload.get("chunking_method") if isinstance(payload, dict) else None
615
+ origin_method = payload_method or method
616
+ aggregated_results.append((text, score, origin_method))
617
+
618
+ # Ordenar todos los resultados agregados por puntuación descendente
619
+ aggregated_results.sort(key=lambda x: x[1], reverse=True)
620
+
621
+ # Retornar solo los top_k resultados globales
622
+ final_results = aggregated_results[:top_k]
623
+ self.logger.info(f"Búsqueda completada: {len(final_results)} chunks relevantes (agregados)")
624
+ return final_results
625
+
626
+ except Exception as e:
627
+ self.logger.error(f"Error en búsqueda de chunks: {str(e)}")
628
+ raise
629
+
630
+ def get_embeddings_function(self):
631
+ """
632
+ Retorna una función para generar embeddings individuales.
633
+
634
+ Útil para chunking adaptativo que requiere generar embeddings
635
+ de oraciones individuales para calcular similitudes.
636
+
637
+ Returns:
638
+ callable: Función que genera embeddings
639
+ """
640
+ return self.embeddings_generator.generate_embedding
641
+
642
+ def get_system_status(self) -> Dict[str, Any]:
643
+ """
644
+ Obtiene el estado general del sistema de embeddings y Qdrant.
645
+
646
+ Returns:
647
+ Dict[str, Any]: Estado del sistema
648
+ """
649
+ try:
650
+ # Agregar estado agregando información de todas las colecciones por método
651
+ total_points = 0
652
+ collections = []
653
+
654
+ for method in settings.CHUNKING_METHODS:
655
+ collection_name = f"{settings.QDRANT_COLLECTION_NAME}_{method}"
656
+ try:
657
+ manager = QdrantManager(collection_name=collection_name)
658
+ info = manager.get_collection_info()
659
+ points = info.get("points_count", 0)
660
+ except Exception:
661
+ points = 0
662
+
663
+ total_points += points
664
+ collections.append({"name": collection_name, "points": points})
665
+
666
+ status = {
667
+ "embeddings_model": self.embeddings_generator.model_name,
668
+ "qdrant_url": self.qdrant_manager.url,
669
+ "collections": collections,
670
+ "collection_name": self.qdrant_manager.collection_name,
671
+ "total_chunks": total_points,
672
+ "status": "operational"
673
+ }
674
+
675
+ return status
676
+
677
+ except Exception as e:
678
+ return {
679
+ "status": "error",
680
+ "error": str(e)
681
+ }
682
+
683
+ def clear_embeddings_cache(self):
684
+ """
685
+ Limpia todos los embeddings existentes para forzar regeneración.
686
+
687
+ CUIDADO: Esta operación eliminará todos los embeddings existentes
688
+ y requerirá nuevas llamadas a la API de OpenAI (costo adicional).
689
+
690
+ Returns:
691
+ bool: True si se limpió exitosamente
692
+ """
693
+ try:
694
+ self.logger.warning("LIMPIANDO caché de embeddings. Esto requerirá regenerar todos los embeddings.")
695
+
696
+ for method in settings.CHUNKING_METHODS:
697
+ collection_name = f"{settings.QDRANT_COLLECTION_NAME}_{method}"
698
+ try:
699
+ manager = QdrantManager(collection_name=collection_name)
700
+ manager.clear_collection()
701
+ self.logger.info(f"Colección '{collection_name}' limpiada")
702
+ except Exception as e:
703
+ # Log y continuar con siguientes colecciones
704
+ self.logger.warning(f"No se pudo limpiar la colección '{collection_name}': {str(e)}")
705
+
706
+ self.logger.info("Caché de embeddings limpiado para todas las colecciones de métodos")
707
+ return True
708
+ except Exception as e:
709
+ self.logger.error(f"Error limpiando caché de embeddings: {str(e)}")
710
+ return False
pdf_loader.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MIT License
3
+ Autor: Fernando Lafuente
4
+ Proyecto: Ejercicio opcional NLP - Chunking y Recuperación
5
+ Descripción: Extracción y limpieza de texto desde archivos PDF
6
+
7
+ Este módulo se encarga de extraer el contenido textual de documentos PDF
8
+ y aplicar técnicas de limpieza para preparar el texto para el chunking.
9
+ Utiliza PyMuPDF (fitz) para la extracción y aplica filtros pedagógicos
10
+ que mejoran la calidad del texto para procesamiento posterior.
11
+ """
12
+
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import fitz # PyMuPDF
18
+
19
+ import settings
20
+ from utils_logging import setup_logging, log_operation_time, clean_text_basic, validate_text_length
21
+
22
+
23
+ # Configurar logger específico para este módulo
24
+ logger = setup_logging()
25
+
26
+
27
+ class PDFLoader:
28
+ """
29
+ Clase para cargar y procesar documentos PDF.
30
+
31
+ Esta clase encapsula la lógica de extracción de texto desde archivos PDF,
32
+ aplicando técnicas de limpieza y validación que aseguran que el texto
33
+ resultante sea apropiado para chunking y generación de embeddings.
34
+
35
+ La extracción se realiza página por página para permitir un mejor control
36
+ sobre la calidad del texto y facilitar el debugging en documentos largos.
37
+ """
38
+
39
+ def __init__(self):
40
+ """
41
+ Inicializa el cargador de PDF con configuración por defecto.
42
+ """
43
+ self.logger = logging.getLogger("chunking_system.pdf_loader")
44
+
45
+ @log_operation_time
46
+ def load_pdf(self, pdf_path: Path) -> str:
47
+ """
48
+ Carga un archivo PDF y extrae todo su contenido textual.
49
+
50
+ Este método abre el PDF, procesa cada página individualmente,
51
+ extrae el texto y lo concatena en un único string. Aplica
52
+ limpieza básica y validaciones para asegurar calidad del texto.
53
+
54
+ Args:
55
+ pdf_path: Ruta al archivo PDF a procesar
56
+
57
+ Returns:
58
+ str: Texto completo extraído del PDF
59
+
60
+ Raises:
61
+ FileNotFoundError: Si el archivo PDF no existe
62
+ ValueError: Si el PDF está corrupto o no contiene texto
63
+
64
+ Example:
65
+ loader = PDFLoader()
66
+ texto = loader.load_pdf(Path("documento.pdf"))
67
+ """
68
+ # Validar que el archivo existe
69
+ if not pdf_path.exists():
70
+ raise FileNotFoundError(f"Archivo PDF no encontrado: {pdf_path}")
71
+
72
+ self.logger.info(f"Iniciando carga de PDF: {pdf_path.name}")
73
+
74
+ try:
75
+ # Abrir documento PDF con PyMuPDF
76
+ pdf_document = fitz.open(str(pdf_path))
77
+ total_pages = pdf_document.page_count
78
+
79
+ self.logger.info(f"PDF abierto correctamente. Páginas totales: {total_pages}")
80
+
81
+ # Extraer texto de todas las páginas
82
+ all_text = []
83
+
84
+ for page_num in range(total_pages):
85
+ page_text = self._extract_page_text(pdf_document, page_num)
86
+ if page_text: # Solo agregar páginas con contenido
87
+ all_text.append(page_text)
88
+ self.logger.debug(f"Página {page_num + 1}: {len(page_text)} caracteres extraídos")
89
+
90
+ # Cerrar documento
91
+ pdf_document.close()
92
+
93
+ # Concatenar todo el texto
94
+ full_text = "\n\n".join(all_text)
95
+
96
+ # Aplicar limpieza final
97
+ clean_text = self._clean_extracted_text(full_text)
98
+
99
+ # Validar que se extrajo contenido útil
100
+ if not validate_text_length(clean_text):
101
+ raise ValueError(f"El PDF no contiene suficiente texto válido: {pdf_path}")
102
+
103
+ self.logger.info(f"Extracción completada. Texto final: {len(clean_text)} caracteres")
104
+
105
+ return clean_text
106
+
107
+ except Exception as e:
108
+ self.logger.error(f"Error procesando PDF {pdf_path}: {str(e)}")
109
+ raise
110
+
111
+ def _extract_page_text(self, pdf_document: fitz.Document, page_num: int) -> str:
112
+ """
113
+ Extrae texto de una página específica del PDF.
114
+
115
+ Este método privado se encarga de extraer el texto de una sola página,
116
+ aplicando técnicas específicas de PyMuPDF para obtener la mejor calidad
117
+ de texto posible. Maneja casos especiales como páginas vacías o con
118
+ elementos gráficos únicamente.
119
+
120
+ Args:
121
+ pdf_document: Documento PDF abierto con PyMuPDF
122
+ page_num: Número de página a procesar (base 0)
123
+
124
+ Returns:
125
+ str: Texto extraído de la página, limpio y validado
126
+ """
127
+ try:
128
+ # Obtener la página
129
+ page = pdf_document[page_num]
130
+
131
+ # Extraer texto usando el método estándar de PyMuPDF
132
+ # El método get_text() retorna el texto en el orden de lectura
133
+ page_text = page.get_text()
134
+
135
+ # Aplicar limpieza básica específica para esta página
136
+ clean_page_text = clean_text_basic(page_text)
137
+
138
+ # Validar que la página contiene texto útil
139
+ if not validate_text_length(clean_page_text, min_length=10):
140
+ self.logger.debug(f"Página {page_num + 1} descartada: contenido insuficiente")
141
+ return ""
142
+
143
+ return clean_page_text
144
+
145
+ except Exception as e:
146
+ self.logger.warning(f"Error extrayendo texto de página {page_num + 1}: {str(e)}")
147
+ return ""
148
+
149
+ def _clean_extracted_text(self, text: str) -> str:
150
+ """
151
+ Aplica limpieza específica al texto extraído de PDF.
152
+
153
+ Esta función implementa técnicas de limpieza especializadas para
154
+ texto extraído de PDFs, que suelen contener artefactos específicos
155
+ como headers/footers repetidos, numeración de páginas, etc.
156
+
157
+ Args:
158
+ text: Texto en bruto extraído del PDF
159
+
160
+ Returns:
161
+ str: Texto limpio listo para chunking
162
+ """
163
+ if not text:
164
+ return ""
165
+
166
+ # Aplicar limpieza básica primero
167
+ clean_text = clean_text_basic(text)
168
+
169
+ # Limpieza específica para PDFs
170
+
171
+ # Eliminar líneas que son solo números (numeración de páginas)
172
+ lines = clean_text.split('\n')
173
+ filtered_lines = []
174
+
175
+ for line in lines:
176
+ line_stripped = line.strip()
177
+
178
+ # Descartar líneas vacías
179
+ if not line_stripped:
180
+ continue
181
+
182
+ # Descartar líneas que son solo números (posibles números de página)
183
+ if line_stripped.isdigit() and len(line_stripped) <= 3:
184
+ continue
185
+
186
+ # Descartar líneas muy cortas que probablemente son artefactos
187
+ if len(line_stripped) < 5:
188
+ continue
189
+
190
+ filtered_lines.append(line_stripped)
191
+
192
+ # Reconstruir texto
193
+ final_text = ' '.join(filtered_lines)
194
+
195
+ # Normalizar espacios finales
196
+ final_text = ' '.join(final_text.split())
197
+
198
+ return final_text
199
+
200
+ def get_pdf_metadata(self, pdf_path: Path) -> dict:
201
+ """
202
+ Extrae metadatos del archivo PDF para información adicional.
203
+
204
+ Los metadatos pueden ser útiles para logging, debugging o para
205
+ personalizar el procesamiento según las características del documento.
206
+
207
+ Args:
208
+ pdf_path: Ruta al archivo PDF
209
+
210
+ Returns:
211
+ dict: Diccionario con metadatos del PDF
212
+
213
+ Example:
214
+ metadata = loader.get_pdf_metadata(pdf_path)
215
+ logger.info(f"Procesando: {metadata['title']}")
216
+ """
217
+ try:
218
+ pdf_document = fitz.open(str(pdf_path))
219
+
220
+ metadata = {
221
+ 'title': pdf_document.metadata.get('title', 'Sin título'),
222
+ 'author': pdf_document.metadata.get('author', 'Desconocido'),
223
+ 'subject': pdf_document.metadata.get('subject', ''),
224
+ 'creator': pdf_document.metadata.get('creator', ''),
225
+ 'pages': pdf_document.page_count,
226
+ 'file_size': pdf_path.stat().st_size,
227
+ 'encrypted': pdf_document.needs_pass
228
+ }
229
+
230
+ pdf_document.close()
231
+
232
+ return metadata
233
+
234
+ except Exception as e:
235
+ self.logger.warning(f"Error extrayendo metadatos de {pdf_path}: {str(e)}")
236
+ return {
237
+ 'title': pdf_path.name,
238
+ 'author': 'Desconocido',
239
+ 'pages': 0,
240
+ 'file_size': 0,
241
+ 'encrypted': False
242
+ }
243
+
244
+
245
+ def load_pdf_from_settings() -> str:
246
+ """
247
+ Función de conveniencia que carga el PDF configurado en settings.
248
+
249
+ Esta función utiliza la configuración centralizada para cargar
250
+ automáticamente el primer PDF encontrado en el directorio especificado.
251
+
252
+ Returns:
253
+ str: Texto extraído del PDF
254
+
255
+ Raises:
256
+ FileNotFoundError: Si no se encuentra ningún PDF
257
+ ValueError: Si el PDF no contiene texto válido
258
+
259
+ Example:
260
+ texto_documento = load_pdf_from_settings()
261
+ chunks = chunker.create_chunks(texto_documento)
262
+ """
263
+ # Obtener ruta del PDF desde configuración
264
+ pdf_path = settings.get_pdf_file()
265
+
266
+ # Crear instancia del loader y procesar
267
+ loader = PDFLoader()
268
+
269
+ # Mostrar metadatos para información
270
+ metadata = loader.get_pdf_metadata(pdf_path)
271
+ logger.info(f"Cargando PDF: {metadata['title']} "
272
+ f"({metadata['pages']} páginas, {metadata['file_size']} bytes)")
273
+
274
+ # Cargar y retornar texto
275
+ return loader.load_pdf(pdf_path)
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ langchain
3
+ qdrant-client
4
+ openai
5
+ PyMuPDF
6
+ numpy
7
+ pandas
8
+ scikit-learn
9
+ typer
10
+ python-dotenv
11
+ rich
12
+ python-docx
13
+ nltk
settings.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MIT License
3
+ Autor: Fernando Lafuente
4
+ Proyecto: Ejercicio opcional NLP - Chunking y Recuperación
5
+ Descripción: Configuración centralizada de parámetros del sistema
6
+
7
+ Este módulo centraliza todos los parámetros configurables del sistema de chunking
8
+ y recuperación semántica. Permite modificar el comportamiento sin tocar el código
9
+ principal, siguiendo principios de arquitectura limpia.
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from dotenv import load_dotenv
15
+
16
+ # Cargar variables de entorno desde archivo .env
17
+ load_dotenv()
18
+
19
+ # =============================================================================
20
+ # CONFIGURACIÓN DE RUTAS Y ARCHIVOS
21
+ # =============================================================================
22
+
23
+ # Directorio raíz del proyecto
24
+ PROJECT_ROOT = Path(__file__).parent.absolute()
25
+
26
+ # Directorio donde se almacenan los archivos PDF
27
+ PDF_DIRECTORY = PROJECT_ROOT / "data" / "pdf"
28
+
29
+ # Crear directorio si no existe
30
+ PDF_DIRECTORY.mkdir(parents=True, exist_ok=True)
31
+
32
+ # =============================================================================
33
+ # CONFIGURACIÓN DE OPENAI EMBEDDINGS
34
+ # =============================================================================
35
+
36
+ # Clave de API de OpenAI (debe configurarse como variable de entorno)
37
+ # Ejecutar: export OPENAI_API_KEY="tu_clave_aqui"
38
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
39
+
40
+ # Modelo de embeddings de OpenAI a utilizar
41
+ # Opciones: "text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"
42
+ OPENAI_EMBEDDING_MODEL = "text-embedding-3-large" # Más económico y eficiente
43
+
44
+ # Dimensionalidad de los embeddings de OpenAI (text-embedding-3-small)
45
+ EMBEDDING_DIMENSION = 3072
46
+
47
+ # =============================================================================
48
+ # CONFIGURACIÓN DE QDRANT
49
+ # =============================================================================
50
+
51
+ # URL de conexión a Qdrant local
52
+ QDRANT_URL = "http://localhost:6333"
53
+
54
+ # Nombre de la colección donde se almacenan los vectores
55
+ QDRANT_COLLECTION_NAME = "chunks_collection"
56
+
57
+ # =============================================================================
58
+ # CONFIGURACIÓN DE CHUNKING
59
+ # =============================================================================
60
+
61
+ # Método de chunking a utilizar
62
+ # Opciones: "fixed", "sentence", "adaptive"
63
+ CHUNKING_METHOD = "adaptive"
64
+
65
+ # Parámetros para chunking fijo
66
+ FIXED_CHUNK_SIZE = 500 # caracteres por chunk
67
+ FIXED_CHUNK_OVERLAP = 50 # caracteres de solapamiento
68
+
69
+ # Parámetros para chunking por oraciones
70
+ SENTENCE_MIN_LENGTH = 50 # longitud mínima de oración en caracteres
71
+ SENTENCE_MAX_CHUNK_SIZE = 800 # tamaño máximo de chunk en caracteres
72
+
73
+ # Parámetros para chunking adaptativo
74
+ ADAPTIVE_SIMILARITY_THRESHOLD = 0.7 # umbral de similitud coseno (0-1)
75
+ ADAPTIVE_MIN_CHUNK_SIZE = 100 # tamaño mínimo de chunk en caracteres
76
+ ADAPTIVE_MAX_CHUNK_SIZE = 1000 # tamaño máximo de chunk en caracteres
77
+
78
+ # Lista de métodos de chunking soportados por el sistema
79
+ CHUNKING_METHODS = ["fixed", "sentence", "adaptive"]
80
+
81
+ # =============================================================================
82
+ # CONFIGURACIÓN DE BÚSQUEDA Y RETRIEVAL
83
+ # =============================================================================
84
+
85
+ # Número de chunks más relevantes a retornar
86
+ TOP_K_RESULTS = 10
87
+
88
+ # Puntuación mínima de similitud para considerar un resultado relevante
89
+ MIN_SIMILARITY_SCORE = 0.5
90
+
91
+ # =============================================================================
92
+ # CONFIGURACIÓN DE LOGGING
93
+ # =============================================================================
94
+
95
+ # Nivel de logging
96
+ # Opciones: "DEBUG", "INFO", "WARNING", "ERROR"
97
+ LOG_LEVEL = "DEBUG"
98
+
99
+ # Formato de los mensajes de log
100
+ LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
101
+
102
+ # =============================================================================
103
+ # CONFIGURACIÓN DE PROCESAMIENTO DE TEXTO
104
+ # =============================================================================
105
+
106
+ # Caracteres a eliminar durante la limpieza de texto
107
+ CHARS_TO_REMOVE = ["\n\n", "\t", "\r"]
108
+
109
+ # Caracteres a reemplazar por espacios
110
+ CHARS_TO_REPLACE = {"\n": " ", " ": " "}
111
+
112
+ # Longitud mínima de texto válido después de limpieza
113
+ MIN_TEXT_LENGTH = 10
114
+
115
+ # =============================================================================
116
+ # VALIDACIÓN DE CONFIGURACIÓN
117
+ # =============================================================================
118
+
119
+ def validate_settings():
120
+ """
121
+ Valida que la configuración sea correcta antes de ejecutar el sistema.
122
+
123
+ Raises:
124
+ ValueError: Si algún parámetro es inválido
125
+ FileNotFoundError: Si no se encuentra el directorio de PDFs
126
+ """
127
+ # Validar que existe el directorio de PDFs
128
+ if not PDF_DIRECTORY.exists():
129
+ raise FileNotFoundError(f"Directorio de PDFs no encontrado: {PDF_DIRECTORY}")
130
+
131
+ # Validar que hay al menos un PDF en el directorio
132
+ pdf_files = list(PDF_DIRECTORY.glob("*.pdf"))
133
+ if not pdf_files:
134
+ raise FileNotFoundError(f"No se encontraron archivos PDF en: {PDF_DIRECTORY}")
135
+
136
+ # Validar clave de API de OpenAI
137
+ if not OPENAI_API_KEY:
138
+ raise ValueError("Variable de entorno OPENAI_API_KEY no configurada")
139
+
140
+ # Validar método de chunking
141
+ valid_methods = ["fixed", "sentence", "adaptive"]
142
+ if CHUNKING_METHOD not in valid_methods:
143
+ raise ValueError(f"Método de chunking inválido: {CHUNKING_METHOD}. "
144
+ f"Opciones válidas: {valid_methods}")
145
+
146
+ # Validar parámetros numéricos
147
+ if TOP_K_RESULTS <= 0:
148
+ raise ValueError("TOP_K_RESULTS debe ser mayor que 0")
149
+
150
+ if MIN_SIMILARITY_SCORE < 0 or MIN_SIMILARITY_SCORE > 1:
151
+ raise ValueError("MIN_SIMILARITY_SCORE debe estar entre 0 y 1")
152
+
153
+ # Validar umbrales de chunking adaptativo
154
+ if ADAPTIVE_SIMILARITY_THRESHOLD < 0 or ADAPTIVE_SIMILARITY_THRESHOLD > 1:
155
+ raise ValueError("ADAPTIVE_SIMILARITY_THRESHOLD debe estar entre 0 y 1")
156
+
157
+ def get_pdf_file():
158
+ """
159
+ Obtiene el primer archivo PDF encontrado en el directorio configurado.
160
+
161
+ Returns:
162
+ Path: Ruta al archivo PDF
163
+
164
+ Raises:
165
+ FileNotFoundError: Si no se encuentra ningún PDF
166
+ """
167
+ pdf_files = list(PDF_DIRECTORY.glob("*.pdf"))
168
+ if not pdf_files:
169
+ raise FileNotFoundError(f"No se encontraron archivos PDF en: {PDF_DIRECTORY}")
170
+
171
+ # Retorna el primer PDF encontrado
172
+ return pdf_files[0]
settings_deployment.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # DEPLOYMENT CONFIGURATION FOR GRADIO SPACES
3
+ # =============================================================================
4
+
5
+ import os
6
+ from dotenv import load_dotenv
7
+
8
+ # Load environment variables
9
+ load_dotenv()
10
+
11
+ # =============================================================================
12
+ # OPENAI CONFIGURATION
13
+ # =============================================================================
14
+
15
+ # API Key (required for deployment)
16
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
17
+ if not OPENAI_API_KEY:
18
+ print("⚠️ Warning: OPENAI_API_KEY not found in environment variables")
19
+
20
+ # Embedding model configuration
21
+ OPENAI_EMBEDDING_MODEL = "text-embedding-3-large"
22
+ EMBEDDING_DIMENSIONS = 3072
23
+
24
+ # =============================================================================
25
+ # QDRANT CONFIGURATION
26
+ # =============================================================================
27
+
28
+ # Qdrant URL (cloud deployment will need external Qdrant)
29
+ QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
30
+ QDRANT_API_KEY = os.getenv("QDRANT_API_KEY") # For cloud Qdrant
31
+
32
+ # Collection configuration
33
+ QDRANT_COLLECTION_NAME = "chunks_collection"
34
+
35
+ # =============================================================================
36
+ # CHUNKING CONFIGURATION
37
+ # =============================================================================
38
+
39
+ # Available chunking methods
40
+ CHUNKING_METHODS = ["fixed", "sentence", "adaptive"]
41
+
42
+ # Fixed chunking parameters
43
+ FIXED_CHUNK_SIZE = 1000
44
+ FIXED_CHUNK_OVERLAP = 200
45
+
46
+ # Sentence chunking parameters
47
+ SENTENCE_MIN_CHUNK_SIZE = 100
48
+ SENTENCE_MAX_CHUNK_SIZE = 1000
49
+
50
+ # Adaptive chunking parameters
51
+ ADAPTIVE_MIN_CHUNK_SIZE = 200
52
+ ADAPTIVE_MAX_CHUNK_SIZE = 800
53
+ ADAPTIVE_SIMILARITY_THRESHOLD = 0.5
54
+
55
+ # =============================================================================
56
+ # SEARCH AND RETRIEVAL CONFIGURATION
57
+ # =============================================================================
58
+
59
+ # Number of chunks to return in search results
60
+ TOP_K_RESULTS = 10
61
+
62
+ # Minimum similarity score for search results
63
+ MIN_SIMILARITY_SCORE = 0.5
64
+
65
+ # =============================================================================
66
+ # LOGGING CONFIGURATION
67
+ # =============================================================================
68
+
69
+ # Logging level for deployment
70
+ LOG_LEVEL = "INFO"
71
+
72
+ # =============================================================================
73
+ # DEPLOYMENT NOTES
74
+ # =============================================================================
75
+
76
+ """
77
+ For Gradio Spaces deployment:
78
+
79
+ 1. Set environment variables in Space settings:
80
+ - OPENAI_API_KEY: Your OpenAI API key
81
+ - QDRANT_URL: URL to your cloud Qdrant instance
82
+ - QDRANT_API_KEY: API key for cloud Qdrant (if required)
83
+
84
+ 2. Consider using Qdrant Cloud or self-hosted Qdrant instance
85
+ since Gradio Spaces don't support Docker containers
86
+
87
+ 3. The app will show warning messages if components fail to initialize
88
+ but will still launch for configuration troubleshooting
89
+ """
utils_logging.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MIT License
3
+ Autor: Fernando Lafuente
4
+ Proyecto: Ejercicio opcional NLP - Chunking y Recuperación
5
+ Descripción: Configuración de sistema de logging y utilidades generales
6
+
7
+ Este módulo proporciona utilidades para logging estructurado y funciones auxiliares
8
+ que son utilizadas por múltiples componentes del sistema. Centraliza la configuración
9
+ de logging para mantener consistencia en toda la aplicación.
10
+ """
11
+
12
+ import logging
13
+ import sys
14
+ from datetime import datetime
15
+ from typing import Any, Optional
16
+
17
+ import settings
18
+
19
+
20
+ def setup_logging(
21
+ level: str = settings.LOG_LEVEL,
22
+ format_string: str = settings.LOG_FORMAT
23
+ ) -> logging.Logger:
24
+ """
25
+ Configura el sistema de logging de la aplicación con formato estándar.
26
+
27
+ Esta función establece un logger principal que será utilizado por todos
28
+ los módulos del sistema para mantener un formato consistente y facilitar
29
+ el debugging y monitoreo de operaciones.
30
+
31
+ Args:
32
+ level: Nivel de logging (DEBUG, INFO, WARNING, ERROR)
33
+ format_string: Formato de los mensajes de log
34
+
35
+ Returns:
36
+ logging.Logger: Logger configurado para la aplicación
37
+
38
+ Example:
39
+ logger = setup_logging()
40
+ logger.info("Sistema de chunking inicializado correctamente")
41
+ """
42
+ # Convertir string de nivel a constante de logging
43
+ numeric_level = getattr(logging, level.upper(), None)
44
+ if not isinstance(numeric_level, int):
45
+ raise ValueError(f"Nivel de logging inválido: {level}")
46
+
47
+ # Configurar formato del logger
48
+ formatter = logging.Formatter(format_string)
49
+
50
+ # Configurar handler para consola
51
+ console_handler = logging.StreamHandler(sys.stdout)
52
+ console_handler.setFormatter(formatter)
53
+
54
+ # Crear y configurar logger principal
55
+ logger = logging.getLogger("chunking_system")
56
+ logger.setLevel(numeric_level)
57
+ logger.addHandler(console_handler)
58
+
59
+ # Evitar duplicación de mensajes
60
+ logger.propagate = False
61
+
62
+ return logger
63
+
64
+
65
+ def log_operation_time(func):
66
+ """
67
+ Decorador que mide y registra el tiempo de ejecución de una función.
68
+
69
+ Este decorador es útil para monitorear el rendimiento de operaciones
70
+ críticas como chunking, generación de embeddings y búsquedas vectoriales.
71
+
72
+ Args:
73
+ func: Función a decorar
74
+
75
+ Returns:
76
+ Función decorada que registra su tiempo de ejecución
77
+
78
+ Example:
79
+ @log_operation_time
80
+ def process_document(document):
81
+ # procesar documento
82
+ return result
83
+ """
84
+ def wrapper(*args, **kwargs):
85
+ logger = logging.getLogger("chunking_system")
86
+ start_time = datetime.now()
87
+
88
+ logger.info(f"Iniciando operación: {func.__name__}")
89
+
90
+ try:
91
+ result = func(*args, **kwargs)
92
+ end_time = datetime.now()
93
+ duration = (end_time - start_time).total_seconds()
94
+
95
+ logger.info(f"Operación completada: {func.__name__} "
96
+ f"(duración: {duration:.2f} segundos)")
97
+ return result
98
+
99
+ except Exception as e:
100
+ end_time = datetime.now()
101
+ duration = (end_time - start_time).total_seconds()
102
+
103
+ logger.error(f"Error en operación: {func.__name__} "
104
+ f"(duración: {duration:.2f} segundos) - {str(e)}")
105
+ raise
106
+
107
+ return wrapper
108
+
109
+
110
+ def safe_division(numerator: float, denominator: float, default: float = 0.0) -> float:
111
+ """
112
+ Realiza división segura evitando errores por división entre cero.
113
+
114
+ Esta función es especialmente útil al calcular métricas de similitud
115
+ y normalización de vectores, donde pueden aparecer casos límite.
116
+
117
+ Args:
118
+ numerator: Numerador de la división
119
+ denominator: Denominador de la división
120
+ default: Valor por defecto si el denominador es cero
121
+
122
+ Returns:
123
+ float: Resultado de la división o valor por defecto
124
+
125
+ Example:
126
+ # Cálculo seguro de similitud coseno
127
+ similarity = safe_division(dot_product, norm_product, 0.0)
128
+ """
129
+ if abs(denominator) < 1e-10: # Evitar divisiones por números muy pequeños
130
+ return default
131
+ return numerator / denominator
132
+
133
+
134
+ def clean_text_basic(text: str) -> str:
135
+ """
136
+ Aplica limpieza básica a un texto eliminando caracteres problemáticos.
137
+
138
+ Esta función preprocesa el texto para mejorar la calidad del chunking
139
+ y la generación de embeddings, eliminando caracteres que pueden introducir
140
+ ruido en el procesamiento posterior.
141
+
142
+ Args:
143
+ text: Texto a limpiar
144
+
145
+ Returns:
146
+ str: Texto limpio y normalizado
147
+
148
+ Example:
149
+ texto_limpio = clean_text_basic("Texto con\n\nsaltos\t\tde línea")
150
+ # Resultado: "Texto con saltos de línea"
151
+ """
152
+ if not text or not isinstance(text, str):
153
+ return ""
154
+
155
+ # Eliminar caracteres especificados en configuración
156
+ for char in settings.CHARS_TO_REMOVE:
157
+ text = text.replace(char, "")
158
+
159
+ # Reemplazar caracteres especificados
160
+ for old_char, new_char in settings.CHARS_TO_REPLACE.items():
161
+ text = text.replace(old_char, new_char)
162
+
163
+ # Normalizar espacios múltiples
164
+ while " " in text:
165
+ text = text.replace(" ", " ")
166
+
167
+ # Eliminar espacios al inicio y final
168
+ text = text.strip()
169
+
170
+ return text
171
+
172
+
173
+ def validate_text_length(text: str, min_length: int = settings.MIN_TEXT_LENGTH) -> bool:
174
+ """
175
+ Valida que un texto tenga longitud suficiente para ser procesado.
176
+
177
+ Esta función evita procesar chunks demasiado cortos que no aportan
178
+ información semántica útil y pueden introducir ruido en la búsqueda.
179
+
180
+ Args:
181
+ text: Texto a validar
182
+ min_length: Longitud mínima requerida
183
+
184
+ Returns:
185
+ bool: True si el texto es válido, False en caso contrario
186
+
187
+ Example:
188
+ if validate_text_length(chunk):
189
+ # Procesar chunk válido
190
+ embeddings = generate_embeddings(chunk)
191
+ """
192
+ if not text or not isinstance(text, str):
193
+ return False
194
+
195
+ # Limpiar texto antes de validar longitud
196
+ clean_text = clean_text_basic(text)
197
+
198
+ return len(clean_text) >= min_length
199
+
200
+
201
+ def format_similarity_score(score: float, decimals: int = 4) -> str:
202
+ """
203
+ Formatea una puntuación de similitud para mostrar al usuario.
204
+
205
+ Args:
206
+ score: Puntuación de similitud (0-1)
207
+ decimals: Número de decimales a mostrar
208
+
209
+ Returns:
210
+ str: Puntuación formateada como string
211
+
212
+ Example:
213
+ formatted = format_similarity_score(0.85739)
214
+ # Resultado: "0.8574"
215
+ """
216
+ if not isinstance(score, (int, float)):
217
+ return "N/A"
218
+
219
+ return f"{score:.{decimals}f}"
220
+
221
+
222
+ def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
223
+ """
224
+ Trunca un texto a una longitud máxima agregando sufijo si es necesario.
225
+
226
+ Útil para mostrar previews de chunks en logs o resultados sin saturar
227
+ la consola con textos muy largos.
228
+
229
+ Args:
230
+ text: Texto a truncar
231
+ max_length: Longitud máxima permitida
232
+ suffix: Sufijo a agregar si se trunca
233
+
234
+ Returns:
235
+ str: Texto truncado o original si no excede la longitud
236
+
237
+ Example:
238
+ preview = truncate_text(long_chunk, 50)
239
+ logger.info(f"Chunk procesado: {preview}")
240
+ """
241
+ if not text or len(text) <= max_length:
242
+ return text
243
+
244
+ return text[:max_length - len(suffix)] + suffix
245
+
246
+
247
+ def get_current_timestamp() -> str:
248
+ """
249
+ Obtiene timestamp actual en formato legible para logs.
250
+
251
+ Returns:
252
+ str: Timestamp formateado
253
+
254
+ Example:
255
+ timestamp = get_current_timestamp()
256
+ # Resultado: "2024-01-15 14:30:45"
257
+ """
258
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")