Spaces:
Sleeping
Sleeping
Deploy document chunking system with Gradio interface
Browse files- DEPLOYMENT.md +91 -0
- app.py +345 -0
- chunking_methods.py +567 -0
- document_processor.py +159 -0
- embeddings_qdrant.py +710 -0
- pdf_loader.py +275 -0
- requirements.txt +13 -0
- settings.py +172 -0
- settings_deployment.py +89 -0
- utils_logging.py +258 -0
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")
|