Primera version de chunkeo completo crud

This commit is contained in:
Sebastian
2025-11-05 19:18:11 +00:00
parent df2c184814
commit 7c6e8c4858
36 changed files with 6242 additions and 5 deletions

View File

@@ -0,0 +1,316 @@
"""
Servicio de chunking que orquesta todo el proceso:
- Descarga PDF desde Azure Blob
- Procesa con pipeline de chunking
- Genera embeddings con Azure OpenAI
- Sube a Qdrant con IDs determinísticos
"""
import logging
import uuid
from typing import List, Dict, Any, Optional
from io import BytesIO
from azure.storage.blob import BlobServiceClient
from langchain_core.documents import Document
from ..core.config import settings
from ..utils.chunking import (
process_pdf_with_token_control,
get_gemini_client,
GeminiClient
)
from ..services.embedding_service import get_embedding_service
from ..vector_db.factory import get_vector_db
logger = logging.getLogger(__name__)
class ChunkingService:
"""Servicio para procesar PDFs y subirlos a Qdrant"""
def __init__(self):
"""Inicializa el servicio con conexiones a Azure Blob y clientes"""
self.blob_service = BlobServiceClient.from_connection_string(
settings.AZURE_STORAGE_CONNECTION_STRING
)
self.container_name = settings.AZURE_CONTAINER_NAME
self.embedding_service = get_embedding_service()
self.vector_db = get_vector_db()
def _generate_deterministic_id(
self,
file_name: str,
page: int,
chunk_index: int
) -> str:
"""
Genera un ID determinístico para un chunk usando UUID v5.
Args:
file_name: Nombre del archivo
page: Número de página
chunk_index: Índice del chunk dentro de la página
Returns:
ID en formato UUID válido para Qdrant
"""
id_string = f"{file_name}_{page}_{chunk_index}"
# Usar UUID v5 con namespace DNS para generar UUID determinístico
return str(uuid.uuid5(uuid.NAMESPACE_DNS, id_string))
async def download_pdf_from_blob(
self,
file_name: str,
tema: str
) -> bytes:
"""
Descarga un PDF desde Azure Blob Storage.
Args:
file_name: Nombre del archivo
tema: Tema/carpeta del archivo
Returns:
Contenido del PDF en bytes
Raises:
Exception: Si hay error descargando el archivo
"""
try:
blob_path = f"{tema}/{file_name}"
logger.info(f"Descargando PDF: {blob_path}")
blob_client = self.blob_service.get_blob_client(
container=self.container_name,
blob=blob_path
)
pdf_bytes = blob_client.download_blob().readall()
logger.info(f"PDF descargado: {len(pdf_bytes)} bytes")
return pdf_bytes
except Exception as e:
logger.error(f"Error descargando PDF: {e}")
raise
async def process_pdf_preview(
self,
file_name: str,
tema: str,
max_tokens: int = 950,
target_tokens: int = 800,
chunk_size: int = 1000,
chunk_overlap: int = 200,
use_llm: bool = True,
custom_instructions: str = ""
) -> List[Dict[str, Any]]:
"""
Procesa un PDF y genera exactamente 3 chunks de preview.
Args:
file_name: Nombre del archivo PDF
tema: Tema/carpeta del archivo
max_tokens: Límite máximo de tokens por chunk
target_tokens: Tokens objetivo
chunk_size: Tamaño del chunk
chunk_overlap: Solapamiento
use_llm: Si True, usa Gemini para procesamiento inteligente
custom_instructions: Instrucciones personalizadas (solo si use_llm=True)
Returns:
Lista con exactamente 3 chunks de preview con metadata
"""
try:
logger.info(f"Generando preview para {file_name} (tema: {tema})")
# Descargar PDF
pdf_bytes = await self.download_pdf_from_blob(file_name, tema)
# Configurar cliente Gemini si está habilitado
gemini_client = get_gemini_client() if use_llm else None
# Si LLM está deshabilitado, ignorar custom_instructions
instructions = custom_instructions if use_llm else ""
# Procesar PDF
chunks = process_pdf_with_token_control(
pdf_bytes=pdf_bytes,
file_name=file_name,
max_tokens=max_tokens,
target_tokens=target_tokens,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
merge_related=True,
gemini_client=gemini_client,
custom_instructions=instructions,
extract_images=False # Deshabilitado según requerimientos
)
# Tomar los primeros chunks para preview (máximo 3, mínimo 1)
preview_chunks = chunks[:min(3, len(chunks))] if chunks else []
# Formatear para respuesta
result = []
for idx, chunk in enumerate(preview_chunks):
result.append({
"index": idx,
"text": chunk.page_content,
"page": chunk.metadata.get("page", 0),
"file_name": chunk.metadata.get("file_name", file_name),
"tokens": len(chunk.page_content.split()) # Aproximación
})
logger.info(f"Preview generado: {len(result)} chunks")
return result
except Exception as e:
logger.error(f"Error generando preview: {e}")
raise
async def process_pdf_full(
self,
file_name: str,
tema: str,
collection_name: str,
max_tokens: int = 950,
target_tokens: int = 800,
chunk_size: int = 1000,
chunk_overlap: int = 200,
use_llm: bool = True,
custom_instructions: str = "",
progress_callback: Optional[callable] = None
) -> Dict[str, Any]:
"""
Procesa un PDF completo y lo sube a Qdrant.
Args:
file_name: Nombre del archivo PDF
tema: Tema/carpeta del archivo
collection_name: Nombre de la colección en Qdrant
max_tokens: Límite máximo de tokens por chunk
target_tokens: Tokens objetivo
chunk_size: Tamaño del chunk
chunk_overlap: Solapamiento
use_llm: Si True, usa Gemini para procesamiento inteligente
custom_instructions: Instrucciones personalizadas (solo si use_llm=True)
progress_callback: Callback para reportar progreso
Returns:
Diccionario con resultados del procesamiento
"""
try:
logger.info(f"Procesando PDF completo: {file_name} (tema: {tema})")
if progress_callback:
await progress_callback({"status": "downloading", "progress": 0})
# 1. Descargar PDF
pdf_bytes = await self.download_pdf_from_blob(file_name, tema)
if progress_callback:
await progress_callback({"status": "chunking", "progress": 20})
# 2. Configurar cliente Gemini
gemini_client = get_gemini_client() if use_llm else None
instructions = custom_instructions if use_llm else ""
# 3. Procesar PDF
chunks = process_pdf_with_token_control(
pdf_bytes=pdf_bytes,
file_name=file_name,
max_tokens=max_tokens,
target_tokens=target_tokens,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
merge_related=True,
gemini_client=gemini_client,
custom_instructions=instructions,
extract_images=False
)
if progress_callback:
await progress_callback({"status": "embedding", "progress": 50})
# 4. Generar embeddings
texts = [chunk.page_content for chunk in chunks]
logger.info(f"Generando embeddings para {len(texts)} chunks")
embeddings = await self.embedding_service.generate_embeddings_batch(texts)
logger.info(f"Embeddings generados: {len(embeddings)} vectores de dimensión {len(embeddings[0]) if embeddings else 0}")
if progress_callback:
await progress_callback({"status": "uploading", "progress": 80})
# 5. Preparar chunks para Qdrant con IDs determinísticos
qdrant_chunks = []
page_chunk_count = {} # Contador de chunks por página
logger.info(f"Preparando {len(chunks)} chunks con {len(embeddings)} embeddings para subir")
for chunk, embedding in zip(chunks, embeddings):
page = chunk.metadata.get("page", 0)
# Incrementar contador para esta página
if page not in page_chunk_count:
page_chunk_count[page] = 0
chunk_index = page_chunk_count[page]
page_chunk_count[page] += 1
# Generar ID determinístico
chunk_id = self._generate_deterministic_id(
file_name=file_name,
page=page,
chunk_index=chunk_index
)
qdrant_chunks.append({
"id": chunk_id,
"vector": embedding,
"payload": {
"page_content": chunk.page_content,
"metadata": {
"page": page,
"file_name": file_name
}
}
})
# 6. Subir a Qdrant
logger.info(f"Subiendo {len(qdrant_chunks)} chunks a Qdrant colección '{collection_name}'")
result = await self.vector_db.add_chunks(collection_name, qdrant_chunks)
logger.info(f"Resultado de upsert: {result}")
if progress_callback:
await progress_callback({"status": "completed", "progress": 100})
logger.info(f"Procesamiento completo: {result['chunks_added']} chunks subidos")
return {
"success": True,
"collection_name": collection_name,
"file_name": file_name,
"total_chunks": len(chunks),
"chunks_added": result['chunks_added'],
"message": "PDF procesado y subido exitosamente"
}
except Exception as e:
logger.error(f"Error procesando PDF completo: {e}")
if progress_callback:
await progress_callback({"status": "error", "progress": 0, "error": str(e)})
raise
# Instancia global singleton
_chunking_service: ChunkingService | None = None
def get_chunking_service() -> ChunkingService:
"""
Obtiene la instancia singleton del servicio de chunking.
Returns:
Instancia de ChunkingService
"""
global _chunking_service
if _chunking_service is None:
_chunking_service = ChunkingService()
return _chunking_service

View File

@@ -0,0 +1,127 @@
"""
Servicio de embeddings usando Azure OpenAI.
Genera embeddings para chunks de texto usando text-embedding-3-large (3072 dimensiones).
"""
import logging
from typing import List
from openai import AzureOpenAI
from ..core.config import settings
logger = logging.getLogger(__name__)
class EmbeddingService:
"""Servicio para generar embeddings usando Azure OpenAI"""
def __init__(self):
"""Inicializa el cliente de Azure OpenAI"""
try:
self.client = AzureOpenAI(
api_key=settings.AZURE_OPENAI_API_KEY,
api_version=settings.AZURE_OPENAI_API_VERSION,
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT
)
self.model = settings.AZURE_OPENAI_EMBEDDING_DEPLOYMENT
self.embedding_dimension = 3072
logger.info(f"EmbeddingService inicializado con modelo {self.model}")
except Exception as e:
logger.error(f"Error inicializando EmbeddingService: {e}")
raise
async def generate_embedding(self, text: str) -> List[float]:
"""
Genera un embedding para un texto individual.
Args:
text: Texto para generar embedding
Returns:
Vector de embedding (3072 dimensiones)
Raises:
Exception: Si hay error al generar el embedding
"""
try:
response = self.client.embeddings.create(
input=[text],
model=self.model
)
embedding = response.data[0].embedding
if len(embedding) != self.embedding_dimension:
raise ValueError(
f"Dimensión incorrecta: esperada {self.embedding_dimension}, "
f"obtenida {len(embedding)}"
)
return embedding
except Exception as e:
logger.error(f"Error generando embedding: {e}")
raise
async def generate_embeddings_batch(
self,
texts: List[str],
batch_size: int = 100
) -> List[List[float]]:
"""
Genera embeddings para múltiples textos en lotes.
Args:
texts: Lista de textos para generar embeddings
batch_size: Tamaño del lote para procesamiento (default: 100)
Returns:
Lista de vectores de embeddings
Raises:
Exception: Si hay error al generar los embeddings
"""
try:
embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
logger.info(f"Procesando lote {i//batch_size + 1}/{(len(texts)-1)//batch_size + 1}")
response = self.client.embeddings.create(
input=batch,
model=self.model
)
batch_embeddings = [item.embedding for item in response.data]
# Validar dimensiones
for idx, emb in enumerate(batch_embeddings):
if len(emb) != self.embedding_dimension:
raise ValueError(
f"Dimensión incorrecta en índice {i + idx}: "
f"esperada {self.embedding_dimension}, obtenida {len(emb)}"
)
embeddings.extend(batch_embeddings)
logger.info(f"Generados {len(embeddings)} embeddings exitosamente")
return embeddings
except Exception as e:
logger.error(f"Error generando embeddings en lote: {e}")
raise
# Instancia global singleton
_embedding_service: EmbeddingService | None = None
def get_embedding_service() -> EmbeddingService:
"""
Obtiene la instancia singleton del servicio de embeddings.
Returns:
Instancia de EmbeddingService
"""
global _embedding_service
if _embedding_service is None:
_embedding_service = EmbeddingService()
return _embedding_service

View File

@@ -0,0 +1,442 @@
"""
Servicio de lógica de negocio para operaciones con bases de datos vectoriales.
Este módulo contiene toda la lógica de negocio relacionada con la gestión
de colecciones y chunks en bases de datos vectoriales.
"""
import logging
from typing import List, Dict, Any, Optional
from app.vector_db import get_vector_db
from app.models.vector_models import (
CollectionCreateRequest,
CollectionCreateResponse,
CollectionDeleteResponse,
CollectionExistsResponse,
CollectionInfoResponse,
FileExistsInCollectionResponse,
GetChunksByFileResponse,
DeleteFileFromCollectionResponse,
AddChunksResponse,
VectorDBHealthResponse,
VectorDBErrorResponse
)
logger = logging.getLogger(__name__)
class VectorService:
"""
Servicio para gestionar operaciones con bases de datos vectoriales.
Este servicio actúa como una capa intermedia entre los routers y
la implementación de la base de datos vectorial.
"""
def __init__(self):
"""Inicializa el servicio con la instancia de la base de datos vectorial."""
self.vector_db = get_vector_db()
async def check_collection_exists(self, collection_name: str) -> CollectionExistsResponse:
"""
Verifica si una colección existe.
Args:
collection_name: Nombre de la colección
Returns:
CollectionExistsResponse: Response con el resultado
"""
try:
exists = await self.vector_db.collection_exists(collection_name)
logger.info(f"Verificación de colección '{collection_name}': {exists}")
return CollectionExistsResponse(
exists=exists,
collection_name=collection_name
)
except Exception as e:
logger.error(f"Error al verificar colección '{collection_name}': {e}")
raise
async def create_collection(
self,
request: CollectionCreateRequest
) -> CollectionCreateResponse:
"""
Crea una nueva colección.
Args:
request: Request con los datos de la colección
Returns:
CollectionCreateResponse: Response con el resultado
Raises:
ValueError: Si la colección ya existe
"""
try:
# Verificar si ya existe
exists = await self.vector_db.collection_exists(request.collection_name)
if exists:
logger.warning(f"Intento de crear colección existente: '{request.collection_name}'")
raise ValueError(f"La colección '{request.collection_name}' ya existe")
# Crear la colección
success = await self.vector_db.create_collection(
collection_name=request.collection_name,
vector_size=request.vector_size,
distance=request.distance
)
if success:
logger.info(f"Colección '{request.collection_name}' creada exitosamente")
return CollectionCreateResponse(
success=True,
collection_name=request.collection_name,
message=f"Colección '{request.collection_name}' creada exitosamente"
)
else:
logger.error(f"Fallo al crear colección '{request.collection_name}'")
raise Exception(f"No se pudo crear la colección '{request.collection_name}'")
except ValueError:
raise
except Exception as e:
logger.error(f"Error al crear colección '{request.collection_name}': {e}")
raise
async def delete_collection(self, collection_name: str) -> CollectionDeleteResponse:
"""
Elimina una colección completa.
Args:
collection_name: Nombre de la colección
Returns:
CollectionDeleteResponse: Response con el resultado
Raises:
ValueError: Si la colección no existe
"""
try:
# Verificar que existe
exists = await self.vector_db.collection_exists(collection_name)
if not exists:
logger.warning(f"Intento de eliminar colección inexistente: '{collection_name}'")
raise ValueError(f"La colección '{collection_name}' no existe")
# Eliminar la colección
success = await self.vector_db.delete_collection(collection_name)
if success:
logger.info(f"Colección '{collection_name}' eliminada exitosamente")
return CollectionDeleteResponse(
success=True,
collection_name=collection_name,
message=f"Colección '{collection_name}' eliminada exitosamente"
)
else:
logger.error(f"Fallo al eliminar colección '{collection_name}'")
raise Exception(f"No se pudo eliminar la colección '{collection_name}'")
except ValueError:
raise
except Exception as e:
logger.error(f"Error al eliminar colección '{collection_name}': {e}")
raise
async def get_collection_info(self, collection_name: str) -> Optional[CollectionInfoResponse]:
"""
Obtiene información de una colección.
Args:
collection_name: Nombre de la colección
Returns:
Optional[CollectionInfoResponse]: Información de la colección o None
"""
try:
info = await self.vector_db.get_collection_info(collection_name)
if info is None:
logger.warning(f"Colección '{collection_name}' no encontrada")
return None
return CollectionInfoResponse(**info)
except Exception as e:
logger.error(f"Error al obtener info de colección '{collection_name}': {e}")
raise
async def check_file_exists_in_collection(
self,
collection_name: str,
file_name: str
) -> FileExistsInCollectionResponse:
"""
Verifica si un archivo existe en una colección.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
Returns:
FileExistsInCollectionResponse: Response con el resultado
"""
try:
# Primero verificar que la colección existe
collection_exists = await self.vector_db.collection_exists(collection_name)
if not collection_exists:
logger.warning(f"Colección '{collection_name}' no existe")
return FileExistsInCollectionResponse(
exists=False,
collection_name=collection_name,
file_name=file_name,
chunk_count=0
)
# Verificar si el archivo existe
file_exists = await self.vector_db.file_exists_in_collection(
collection_name,
file_name
)
chunk_count = None
if file_exists:
chunk_count = await self.vector_db.count_chunks_in_file(
collection_name,
file_name
)
logger.info(
f"Archivo '{file_name}' en colección '{collection_name}': "
f"existe={file_exists}, chunks={chunk_count}"
)
return FileExistsInCollectionResponse(
exists=file_exists,
collection_name=collection_name,
file_name=file_name,
chunk_count=chunk_count
)
except Exception as e:
logger.error(
f"Error al verificar archivo '{file_name}' "
f"en colección '{collection_name}': {e}"
)
raise
async def get_chunks_by_file(
self,
collection_name: str,
file_name: str,
limit: Optional[int] = None
) -> GetChunksByFileResponse:
"""
Obtiene todos los chunks de un archivo.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
limit: Límite opcional de chunks
Returns:
GetChunksByFileResponse: Response con los chunks
Raises:
ValueError: Si la colección no existe
"""
try:
# Verificar que la colección existe
exists = await self.vector_db.collection_exists(collection_name)
if not exists:
logger.warning(f"Colección '{collection_name}' no existe")
raise ValueError(f"La colección '{collection_name}' no existe")
# Obtener chunks
chunks = await self.vector_db.get_chunks_by_file(
collection_name,
file_name,
limit
)
logger.info(
f"Obtenidos {len(chunks)} chunks del archivo '{file_name}' "
f"de la colección '{collection_name}'"
)
return GetChunksByFileResponse(
collection_name=collection_name,
file_name=file_name,
chunks=chunks,
total_chunks=len(chunks)
)
except ValueError:
raise
except Exception as e:
logger.error(
f"Error al obtener chunks del archivo '{file_name}' "
f"de la colección '{collection_name}': {e}"
)
raise
async def delete_file_from_collection(
self,
collection_name: str,
file_name: str
) -> DeleteFileFromCollectionResponse:
"""
Elimina todos los chunks de un archivo de una colección.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
Returns:
DeleteFileFromCollectionResponse: Response con el resultado
Raises:
ValueError: Si la colección no existe o el archivo no está en la colección
"""
try:
# Verificar que la colección existe
collection_exists = await self.vector_db.collection_exists(collection_name)
if not collection_exists:
logger.warning(f"Colección '{collection_name}' no existe")
raise ValueError(f"La colección '{collection_name}' no existe")
# Verificar que el archivo existe en la colección
file_exists = await self.vector_db.file_exists_in_collection(
collection_name,
file_name
)
if not file_exists:
logger.warning(
f"Archivo '{file_name}' no existe en colección '{collection_name}'"
)
raise ValueError(
f"El archivo '{file_name}' no existe en la colección '{collection_name}'"
)
# Eliminar el archivo
chunks_deleted = await self.vector_db.delete_file_from_collection(
collection_name,
file_name
)
logger.info(
f"Eliminados {chunks_deleted} chunks del archivo '{file_name}' "
f"de la colección '{collection_name}'"
)
return DeleteFileFromCollectionResponse(
success=True,
collection_name=collection_name,
file_name=file_name,
chunks_deleted=chunks_deleted,
message=f"Archivo '{file_name}' eliminado exitosamente ({chunks_deleted} chunks)"
)
except ValueError:
raise
except Exception as e:
logger.error(
f"Error al eliminar archivo '{file_name}' "
f"de la colección '{collection_name}': {e}"
)
raise
async def add_chunks(
self,
collection_name: str,
chunks: List[Dict[str, Any]]
) -> AddChunksResponse:
"""
Agrega chunks a una colección.
Args:
collection_name: Nombre de la colección
chunks: Lista de chunks a agregar
Returns:
AddChunksResponse: Response con el resultado
Raises:
ValueError: Si la colección no existe
"""
try:
# Verificar que la colección existe
exists = await self.vector_db.collection_exists(collection_name)
if not exists:
logger.warning(f"Colección '{collection_name}' no existe")
raise ValueError(f"La colección '{collection_name}' no existe")
# Agregar chunks
success = await self.vector_db.add_chunks(collection_name, chunks)
if success:
logger.info(
f"Agregados {len(chunks)} chunks a la colección '{collection_name}'"
)
return AddChunksResponse(
success=True,
collection_name=collection_name,
chunks_added=len(chunks),
message=f"Se agregaron {len(chunks)} chunks exitosamente"
)
else:
logger.error(f"Fallo al agregar chunks a '{collection_name}'")
raise Exception(f"No se pudieron agregar los chunks a '{collection_name}'")
except ValueError:
raise
except Exception as e:
logger.error(f"Error al agregar chunks a '{collection_name}': {e}")
raise
async def health_check(self) -> VectorDBHealthResponse:
"""
Verifica el estado de la conexión con la base de datos vectorial.
Returns:
VectorDBHealthResponse: Response con el estado
"""
try:
is_healthy = await self.vector_db.health_check()
if is_healthy:
return VectorDBHealthResponse(
status="healthy",
db_type="qdrant",
message="Conexión exitosa con la base de datos vectorial"
)
else:
return VectorDBHealthResponse(
status="unhealthy",
db_type="qdrant",
message="No se pudo conectar con la base de datos vectorial"
)
except Exception as e:
logger.error(f"Error en health check: {e}")
return VectorDBHealthResponse(
status="error",
db_type="qdrant",
message=f"Error al verificar conexión: {str(e)}"
)
# Instancia global del servicio
vector_service = VectorService()