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,12 @@
"""
Vector Database Module
Este módulo proporciona una abstracción para trabajar con bases de datos vectoriales.
Utiliza el patrón Repository para permitir cambiar fácilmente entre diferentes
implementaciones (Qdrant, Pinecone, Weaviate, etc.).
"""
from .base import VectorDBBase
from .factory import get_vector_db
__all__ = ["VectorDBBase", "get_vector_db"]

View File

@@ -0,0 +1,189 @@
"""
Clase abstracta base para operaciones con bases de datos vectoriales.
Este módulo define la interfaz que todas las implementaciones de bases de datos
vectoriales deben seguir, permitiendo cambiar fácilmente entre diferentes proveedores.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional
class VectorDBBase(ABC):
"""
Clase abstracta que define las operaciones básicas para una base de datos vectorial.
Esta interfaz permite implementar el patrón Repository/Strategy para
abstraer la lógica de acceso a datos vectoriales.
"""
@abstractmethod
async def collection_exists(self, collection_name: str) -> bool:
"""
Verifica si existe una colección con el nombre especificado.
Args:
collection_name: Nombre de la colección a verificar
Returns:
bool: True si la colección existe, False en caso contrario
"""
pass
@abstractmethod
async def create_collection(
self,
collection_name: str,
vector_size: int = 3072,
distance: str = "Cosine"
) -> bool:
"""
Crea una nueva colección en la base de datos vectorial.
Args:
collection_name: Nombre de la colección a crear
vector_size: Dimensión de los vectores (por defecto 3072)
distance: Métrica de distancia ("Cosine", "Euclid", "Dot")
Returns:
bool: True si se creó exitosamente, False en caso contrario
"""
pass
@abstractmethod
async def delete_collection(self, collection_name: str) -> bool:
"""
Elimina una colección completa.
Args:
collection_name: Nombre de la colección a eliminar
Returns:
bool: True si se eliminó exitosamente, False en caso contrario
"""
pass
@abstractmethod
async def file_exists_in_collection(
self,
collection_name: str,
file_name: str
) -> bool:
"""
Verifica si un archivo ya existe en una colección.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo a buscar
Returns:
bool: True si el archivo existe, False en caso contrario
"""
pass
@abstractmethod
async def get_chunks_by_file(
self,
collection_name: str,
file_name: str,
limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Obtiene todos los chunks de un archivo específico.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
limit: Límite opcional de resultados
Returns:
List[Dict]: Lista de chunks con su metadata
"""
pass
@abstractmethod
async def delete_file_from_collection(
self,
collection_name: str,
file_name: str
) -> int:
"""
Elimina todos los chunks de un archivo de una colección.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo a eliminar
Returns:
int: Número de chunks eliminados
"""
pass
@abstractmethod
async def add_chunks(
self,
collection_name: str,
chunks: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Agrega múltiples chunks a una colección.
Args:
collection_name: Nombre de la colección
chunks: Lista de chunks con estructura:
{
"id": str,
"vector": List[float],
"payload": {
"text": str,
"file_name": str,
"page": int,
...otros campos opcionales
}
}
Returns:
Dict con 'success' (bool) y 'chunks_added' (int)
"""
pass
@abstractmethod
async def get_collection_info(self, collection_name: str) -> Optional[Dict[str, Any]]:
"""
Obtiene información sobre una colección.
Args:
collection_name: Nombre de la colección
Returns:
Optional[Dict]: Información de la colección o None si no existe
"""
pass
@abstractmethod
async def count_chunks_in_file(
self,
collection_name: str,
file_name: str
) -> int:
"""
Cuenta el número de chunks de un archivo.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
Returns:
int: Número de chunks del archivo
"""
pass
@abstractmethod
async def health_check(self) -> bool:
"""
Verifica que la conexión con la base de datos vectorial esté funcionando.
Returns:
bool: True si la conexión es exitosa, False en caso contrario
"""
pass

View File

@@ -0,0 +1,76 @@
"""
Factory para crear instancias de bases de datos vectoriales.
Este módulo implementa el patrón Factory para crear la instancia correcta
de base de datos vectorial según la configuración.
"""
import logging
from typing import Optional
from app.core.config import settings
from .base import VectorDBBase
from .qdrant_client import QdrantVectorDB
logger = logging.getLogger(__name__)
# Instancia global singleton
_vector_db_instance: Optional[VectorDBBase] = None
def get_vector_db() -> VectorDBBase:
"""
Factory function que retorna la instancia de base de datos vectorial configurada.
Utiliza un patrón Singleton para mantener una sola instancia durante
el ciclo de vida de la aplicación.
Returns:
VectorDBBase: Instancia de la base de datos vectorial configurada
Raises:
ValueError: Si el tipo de base de datos no está soportado
"""
global _vector_db_instance
# Si ya existe una instancia, retornarla
if _vector_db_instance is not None:
return _vector_db_instance
# Crear nueva instancia según configuración
db_type = settings.VECTOR_DB_TYPE.lower()
if db_type == "qdrant":
logger.info(f"Inicializando Qdrant con URL: {settings.QDRANT_URL}")
_vector_db_instance = QdrantVectorDB(
url=settings.QDRANT_URL,
api_key=settings.QDRANT_API_KEY
)
# Aquí se pueden agregar otros proveedores en el futuro
# elif db_type == "pinecone":
# _vector_db_instance = PineconeVectorDB(...)
# elif db_type == "weaviate":
# _vector_db_instance = WeaviateVectorDB(...)
else:
raise ValueError(
f"Tipo de base de datos vectorial no soportado: {db_type}. "
f"Tipos soportados: qdrant"
)
logger.info(f"Base de datos vectorial '{db_type}' inicializada exitosamente")
return _vector_db_instance
def reset_vector_db() -> None:
"""
Resetea la instancia global de la base de datos vectorial.
NOTA: Esta función solo cierra la conexión del cliente en memoria.
NO elimina ni modifica datos en Qdrant.
Útil principalmente para testing.
"""
global _vector_db_instance
_vector_db_instance = None
logger.info("Instancia de base de datos vectorial reseteada")

View File

@@ -0,0 +1,410 @@
"""
Implementación de Qdrant para la interfaz VectorDBBase.
Este módulo proporciona la implementación concreta de todas las operaciones
vectoriales utilizando Qdrant como base de datos.
"""
import logging
from typing import List, Dict, Any, Optional
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
VectorParams,
PointStruct,
Filter,
FieldCondition,
MatchValue
)
from qdrant_client.http.exceptions import UnexpectedResponse
from .base import VectorDBBase
logger = logging.getLogger(__name__)
class QdrantVectorDB(VectorDBBase):
"""
Implementación de VectorDBBase usando Qdrant como proveedor.
Atributos:
client: Cliente de Qdrant
url: URL del servidor Qdrant
api_key: API key para autenticación
"""
def __init__(self, url: str, api_key: str):
"""
Inicializa el cliente de Qdrant.
Args:
url: URL del servidor Qdrant
api_key: API key para autenticación
"""
self.url = url
self.api_key = api_key
self.client = QdrantClient(
url=url,
api_key=api_key,
timeout=30
)
logger.info(f"QdrantVectorDB inicializado con URL: {url}")
async def collection_exists(self, collection_name: str) -> bool:
"""
Verifica si existe una colección en Qdrant.
Args:
collection_name: Nombre de la colección
Returns:
bool: True si existe, False en caso contrario
"""
try:
collections = self.client.get_collections().collections
return any(col.name == collection_name for col in collections)
except Exception as e:
logger.error(f"Error al verificar colección '{collection_name}': {e}")
return False
async def create_collection(
self,
collection_name: str,
vector_size: int = 3072,
distance: str = "Cosine"
) -> bool:
"""
Crea una nueva colección en Qdrant.
Args:
collection_name: Nombre de la colección
vector_size: Dimensión de los vectores (default: 3072)
distance: Métrica de distancia
Returns:
bool: True si se creó exitosamente
"""
try:
# Mapear string a enum de Qdrant
distance_map = {
"Cosine": Distance.COSINE,
"Euclid": Distance.EUCLID,
"Dot": Distance.DOT
}
distance_metric = distance_map.get(distance, Distance.COSINE)
self.client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=vector_size,
distance=distance_metric
)
)
logger.info(f"Colección '{collection_name}' creada exitosamente")
return True
except Exception as e:
logger.error(f"Error al crear colección '{collection_name}': {e}")
return False
async def delete_collection(self, collection_name: str) -> bool:
"""
Elimina una colección completa de Qdrant.
Args:
collection_name: Nombre de la colección
Returns:
bool: True si se eliminó exitosamente
"""
try:
self.client.delete_collection(collection_name=collection_name)
logger.info(f"Colección '{collection_name}' eliminada exitosamente")
return True
except Exception as e:
logger.error(f"Error al eliminar colección '{collection_name}': {e}")
return False
async def file_exists_in_collection(
self,
collection_name: str,
file_name: str
) -> bool:
"""
Verifica si un archivo existe en una colección.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
Returns:
bool: True si el archivo existe
"""
try:
# Buscar un solo punto con el file_name en metadata
result = self.client.scroll(
collection_name=collection_name,
scroll_filter=Filter(
must=[
FieldCondition(
key="metadata.file_name",
match=MatchValue(value=file_name)
)
]
),
limit=1
)
return len(result[0]) > 0
except Exception as e:
logger.error(f"Error al verificar archivo '{file_name}' en colección '{collection_name}': {e}")
return False
async def get_chunks_by_file(
self,
collection_name: str,
file_name: str,
limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
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 resultados
Returns:
List[Dict]: Lista de chunks con metadata
"""
try:
chunks = []
offset = None
while True:
result = self.client.scroll(
collection_name=collection_name,
scroll_filter=Filter(
must=[
FieldCondition(
key="metadata.file_name",
match=MatchValue(value=file_name)
)
]
),
limit=limit if limit else 100,
offset=offset
)
points, next_offset = result
for point in points:
chunks.append({
"id": str(point.id),
"payload": point.payload,
"vector": point.vector if hasattr(point, 'vector') else None
})
# Si hay límite y lo alcanzamos, salimos
if limit and len(chunks) >= limit:
break
# Si no hay más resultados, salimos
if next_offset is None:
break
offset = next_offset
logger.info(f"Obtenidos {len(chunks)} chunks del archivo '{file_name}'")
return chunks
except Exception as e:
logger.error(f"Error al obtener chunks del archivo '{file_name}': {e}")
return []
async def delete_file_from_collection(
self,
collection_name: str,
file_name: str
) -> int:
"""
Elimina todos los chunks de un archivo.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
Returns:
int: Número de chunks eliminados
"""
try:
# Primero obtener todos los IDs del archivo
chunks = await self.get_chunks_by_file(collection_name, file_name)
if not chunks:
logger.info(f"No se encontraron chunks para el archivo '{file_name}'")
return 0
# Extraer los IDs
point_ids = [chunk["id"] for chunk in chunks]
# Eliminar por IDs
self.client.delete(
collection_name=collection_name,
points_selector=point_ids
)
logger.info(f"Eliminados {len(point_ids)} chunks del archivo '{file_name}'")
return len(point_ids)
except Exception as e:
logger.error(f"Error al eliminar archivo '{file_name}': {e}")
return 0
async def add_chunks(
self,
collection_name: str,
chunks: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Agrega múltiples chunks a una colección.
Args:
collection_name: Nombre de la colección
chunks: Lista de chunks con estructura:
{
"id": str,
"vector": List[float],
"payload": {
"page_content": str,
"metadata": {
"file_name": str,
"page": int
}
}
}
Returns:
Dict con 'success' (bool) y 'chunks_added' (int)
"""
try:
points = []
for chunk in chunks:
point = PointStruct(
id=chunk["id"],
vector=chunk["vector"],
payload=chunk["payload"]
)
points.append(point)
self.client.upsert(
collection_name=collection_name,
points=points
)
logger.info(f"Agregados {len(points)} chunks a la colección '{collection_name}'")
return {
"success": True,
"chunks_added": len(points)
}
except Exception as e:
logger.error(f"Error al agregar chunks a '{collection_name}': {e}")
return {
"success": False,
"chunks_added": 0
}
async def get_collection_info(self, collection_name: str) -> Optional[Dict[str, Any]]:
"""
Obtiene información sobre una colección.
Args:
collection_name: Nombre de la colección
Returns:
Optional[Dict]: Información de la colección o None
"""
try:
collection_info = self.client.get_collection(collection_name=collection_name)
return {
"name": collection_name,
"vectors_count": collection_info.points_count,
"vectors_config": {
"size": collection_info.config.params.vectors.size,
"distance": collection_info.config.params.vectors.distance.name
},
"status": collection_info.status.name
}
except UnexpectedResponse as e:
if e.status_code == 404:
logger.warning(f"Colección '{collection_name}' no encontrada")
return None
logger.error(f"Error al obtener info de colección '{collection_name}': {e}")
return None
except Exception as e:
logger.error(f"Error inesperado al obtener info de colección '{collection_name}': {e}")
return None
async def count_chunks_in_file(
self,
collection_name: str,
file_name: str
) -> int:
"""
Cuenta el número de chunks de un archivo.
Args:
collection_name: Nombre de la colección
file_name: Nombre del archivo
Returns:
int: Número de chunks
"""
try:
result = self.client.scroll(
collection_name=collection_name,
scroll_filter=Filter(
must=[
FieldCondition(
key="file_name",
match=MatchValue(value=file_name)
)
]
),
limit=1,
with_payload=False,
with_vectors=False
)
# Qdrant scroll no retorna count directo, así que obtenemos todos
chunks = await self.get_chunks_by_file(collection_name, file_name)
count = len(chunks)
logger.info(f"Archivo '{file_name}' tiene {count} chunks")
return count
except Exception as e:
logger.error(f"Error al contar chunks del archivo '{file_name}': {e}")
return 0
async def health_check(self) -> bool:
"""
Verifica la conexión con Qdrant.
Returns:
bool: True si la conexión es exitosa
"""
try:
self.client.get_collections()
logger.info("Health check de Qdrant exitoso")
return True
except Exception as e:
logger.error(f"Health check de Qdrant falló: {e}")
return False