Hace unos meses, un cliente me pidió construir un sistema de búsqueda sobre 40.000 documentos legales — contratos, acuerdos de confidencialidad, términos de servicio. La primera versión que entregué era un desastre. Los usuarios hacían preguntas perfectamente razonables y el sistema les devolvía fragmentos de texto que no tenían ninguna relación con lo que pedían. Un abogado me escribió literalmente: “Esto es peor que Ctrl+F.”
Tenía razón.
El problema no era el modelo. Era todo lo anterior al modelo: cómo dividía los documentos, qué base de datos vectorial usaba, y cómo recuperaba la información. Pasé las siguientes dos semanas desmontando el pipeline completo y reconstruyéndolo con criterio. Lo que comparto aquí es producto de ese proceso — incluyendo los errores que cometí y que tú puedes evitar.
Por qué el chunking de 512 tokens es una trampa
Cuando empecé con RAG, hice lo que hace todo el mundo: dividir documentos en chunks de tamaño fijo, típicamente 512 tokens con un overlap de 50-100 tokens. Es la configuración por defecto en casi todos los tutoriales. Y funciona… más o menos, para casos simples.
El problema surge cuando los documentos tienen estructura real. Un contrato legal tiene cláusulas, y una cláusula puede durar tres párrafos. Con chunking de tamaño fijo, puedes cortar una cláusula a la mitad, y ahora tienes dos fragmentos que por separado no tienen sentido completo. Cuando el sistema recupera uno de esos fragmentos, el modelo trabaja con información incompleta.
Lo que me tomó tiempo entender es que el chunking no es un problema de ingeniería de software — es un problema semántico. La pregunta correcta no es “¿cuántos tokens?” sino “¿qué unidad de información tiene sentido como respuesta aislada?”.
Para documentos legales, la respuesta era: la cláusula completa. Para código fuente, la función. Para artículos técnicos, el párrafo o la subsección.
Tres estrategias de chunking que realmente probé
Chunking recursivo fue mi primer salto desde tamaño fijo, y fue una mejora inmediata. LangChain tiene una implementación en RecursiveCharacterTextSplitter que intenta dividir por párrafos primero, luego por oraciones, luego por palabras. Funciona bien para texto en prosa pero sigue siendo ciego a la semántica.
Chunking semántico fue donde las cosas se pusieron interesantes. La idea: usar embeddings para medir la similitud entre oraciones consecutivas, y cuando la similitud cae por debajo de un umbral, ahí va el corte. Esto captura cambios de tema de forma natural.
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
# SemanticChunker de LangChain 0.3 — necesita langchain-experimental
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
chunker = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile", # o "standard_deviation"
breakpoint_threshold_amount=85, # corta cuando la disimilitud supera el percentil 85
)
chunks = chunker.create_documents([texto_del_contrato])
# En mis pruebas: chunks más grandes pero más coherentes semánticamente
# Desventaja: es lento — procesa todos los embeddings antes de cortar
Los chunks eran más grandes (300-800 tokens en promedio, vs. los 512 fijos) pero capturaban ideas completas. La recuperación mejoró de forma visible — no fue dramático al instante, pero sí consistente en las pruebas.
Parent-document retrieval fue la técnica que más me sorprendió. La idea es indexar chunks pequeños para la búsqueda (mejor precisión semántica), pero recuperar el chunk “padre” más grande cuando encuentras un match. Es como tener dos granularidades simultáneamente.
Lo implementé con el ParentDocumentRetriever de LangChain, guardando los documentos completos en un InMemoryStore (o Redis para producción). El resultado: recuperación precisa sin perder contexto. Es mi configuración favorita para documentos largos con estructura jerárquica.
Un error que cometí: olvidé que los chunks pequeños para indexar deben ser lo suficientemente pequeños (50-100 tokens) para que el embedding capture una idea específica. Si son demasiado grandes, pierdes la ventaja de la precisión.
Bases de datos vectoriales: mi opinión honesta después de probar cinco
Aquí es donde tengo opiniones fuertes, así que voy directo.
Chroma es excelente para prototipos. La configuración toma cinco minutos, funciona en memoria o en disco, y tiene buena integración con LangChain. Pero en cuanto quise hacer búsqueda híbrida (vectorial + keyword) o filtros complejos por metadata, empecé a sentir las limitaciones. No lo usaría para producción con más de un millón de documentos.
Pinecone es el que más aparece en demos de Y Combinator. Lo usé en la primera versión del proyecto legal y, honestamente, me decepcionó un poco la migración a su v3 API — cambió bastante y la documentación tardó en actualizarse. El managed service es conveniente, pero el pricing escala rápido y el vendor lock-in es real.
Weaviate tiene cosas interesantes — BM25 integrado, soporte para múltiples vectores por objeto — pero me pareció más complejo de configurar de lo que el problema requería. No lo descarté por malo sino por innecesariamente difícil para lo que necesitaba.
pgvector — opinión impopular: para muchos casos de uso, pgvector en PostgreSQL es suficiente y mucho más simple operacionalmente. Si ya tienes Postgres, añadir pgvector es trivial. La búsqueda vectorial es más lenta que soluciones dedicadas a escala, pero para menos de 500k vectores con índices HNSW, funciona perfectamente bien. El hecho de que puedas hacer JOINs con tu data relacional es una ventaja real que las soluciones dedicadas no pueden igualar.
Qdrant fue la sorpresa agradable. Es open source, tiene una API limpia, soporta búsqueda híbrida con sparse vectors (SPLADE) de forma nativa, filtros por payload muy eficientes, y puedes correrlo en Docker sin fricción. Terminé migrando el proyecto legal a Qdrant y no me arrepiento. La colección de 40k documentos (con embeddings de 1536 dimensiones) ocupa unos 2GB en disco y las queries retornan en ~30ms.
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import uuid
client = QdrantClient("localhost", port=6333)
# Crear colección con HNSW
client.create_collection(
collection_name="contratos",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
# HNSW: m=16 es el default, ef_construct=100 mejora recall a costa de tiempo de indexado
hnsw_config={"m": 16, "ef_construct": 100},
)
# Insertar con metadata — los filtros por payload son muy eficientes en Qdrant
client.upsert(
collection_name="contratos",
points=[
PointStruct(
id=str(uuid.uuid4()),
vector=embedding,
payload={
"texto": chunk_texto,
"cliente": "Empresa ABC",
"tipo_doc": "NDA",
"fecha": "2025-11-01",
},
)
for chunk_texto, embedding in zip(chunks, embeddings)
],
)
Reranking: el paso que la mayoría omite (y por qué es un error)
Aquí cometí mi error más costoso en términos de tiempo. Tenía un retriever que devolvía los 5 chunks más similares semánticamente, y asumí que “más similar según embeddings” equivalía a “más relevante para la pregunta”. No es lo mismo.
Los embeddings capturan similitud semántica general, pero son malos para capturar relevancia específica a una consulta. Un chunk sobre “penalidades contractuales” puede tener alta similitud coseno con una pregunta sobre “consecuencias por incumplimiento”, pero un chunk diferente puede ser más preciso para esa pregunta en particular.
La solución es reranking: recuperar más candidatos (digamos, top-20) y luego usar un modelo cross-encoder más potente para re-ordenarlos.
Probé dos opciones:
Cohere Rerank (API externa) — fácil de integrar, resultados excelentes. Precio razonable para volumen moderado. Si no quieres gestionar infraestructura, esta es la opción más rápida.
cross-encoder/ms-marco-MiniLM-L-6-v2 (local, de HuggingFace) — más lento pero gratuito y privado. Para el caso de documentos legales, donde el cliente no quería datos saliendo a APIs externas, fue la elección obvia.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def recuperar_con_reranking(query: str, retriever, top_k: int = 5) -> list:
# Paso 1: recuperar más candidatos de los que necesitamos
candidatos = retriever.get_relevant_documents(query, k=20)
# Paso 2: crear pares (query, documento) para el cross-encoder
pares = [(query, doc.page_content) for doc in candidatos]
# Paso 3: reranking — devuelve scores, no indices
scores = reranker.predict(pares)
# Paso 4: ordenar por score y retornar top_k
candidatos_con_score = sorted(
zip(candidatos, scores),
key=lambda x: x[1],
reverse=True
)
return [doc for doc, _ in candidatos_con_score[:top_k]]
Cuando mostré los resultados al cliente legal, la diferencia era obvia. Pasamos de “respuestas vagamente relacionadas” a “respuestas que citan la cláusula exacta”. El overhead de latencia del cross-encoder local es de ~200-400ms, que para una aplicación de búsqueda legal es perfectamente aceptable.
Lo otro que mejoró bastante: la búsqueda híbrida (vectorial + BM25 keyword search). Para términos legales muy específicos — nombres de cláusulas, artículos, referencias cruzadas — la búsqueda vectorial sola falla porque los embeddings tienden a capturar significado general. BM25 es mejor para coincidencias exactas de términos raros. Combinar ambas con un peso ajustable fue la diferencia entre 70% y 88% de precisión en nuestro conjunto de evaluación — en Qdrant esto se implementa con sparse vectors.
Lo que yo usaría hoy
No voy a darte “depende de tu caso de uso” porque eso no ayuda a nadie. Voy a decirte lo que yo montaría si empezara un proyecto RAG mañana:
Para el chunking: Parent-document retrieval con chunks pequeños (100 tokens) para indexar y recuperar el chunk padre (300-500 tokens) para el contexto. Si el documento tiene estructura clara — secciones, cláusulas, funciones de código — extráela explícitamente en lugar de cortar ciegamente.
Para la base de datos vectorial: Qdrant si necesitas control, features avanzadas y no quieres pagar por managed services. pgvector si ya tienes Postgres y el volumen es manejable. No elegiría Pinecone a menos que el cliente tenga una razón operacional específica para managed cloud.
Para la recuperación: siempre reranking. El costo computacional es mínimo comparado con la mejora en calidad. Si el presupuesto lo permite, Cohere Rerank. Si no, ms-marco-MiniLM local funciona bien. Y añadir BM25 híbrido para dominios con terminología especializada — derecho, medicina, código fuente.
Una cosa que no cubro en profundidad aquí pero que merece una nota: la evaluación. No sabrás si tu pipeline mejoró sin métricas. RAGAS es una librería razonable para evaluar RAG sin anotaciones manuales, usando el propio LLM para juzgar relevancia y faithfulness. No es perfecta — sus métricas no siempre escalan bien en dominios muy específicos — pero es mejor que confiar solo en la intuición.
El abogado que me dijo que mi sistema era peor que Ctrl+F me escribió tres semanas después: “Ahora encuentro en 10 segundos lo que antes me llevaba una hora.” Eso no fue magia — fue chunking correcto, Qdrant, y un cross-encoder de 22MB corriendo en una instancia EC2 modesta.