RAG Profundo: Estrategias de Chunking, Bases de Datos Vectoriales y Optimización
Source: Dev.to
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é
1. Chunking recursivo
Fue mi primer salto desde tamaño fijo y una mejora inmediata. LangChain ofrece RecursiveCharacterTextSplitter, que intenta dividir por párrafos primero, luego por oraciones y, por último, por palabras. Funciona bien para texto en prosa pero sigue siendo ciego a la semántica.
2. Chunking semántico
Aquí las cosas se pusieron interesantes. La idea: usar embeddings para medir la similitud entre oraciones consecutivas y cortar cuando la similitud cae por debajo de un umbral. 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 resultantes 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.
3. 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 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.
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
| Base de datos | Pros | Contras | Uso recomendado |
|---|---|---|---|
| Chroma | Excelente para prototipos; configuración rápida; buena integración con LangChain. | Limitado para búsquedas híbridas (vector + keyword) y filtros complejos por metadata. | Prototipos y pruebas de concepto. |
| Pinecone | Servicio gestionado; fácil de escalar. | Migración a v3 API fue confusa; documentación tardó en actualizarse; precios escalan rápido; vendor lock‑in real. | Proyectos con presupuesto flexible y necesidad de gestión completa. |
| Weaviate | BM25 integrado; soporte para múltiples vectores por objeto. | Configuración más compleja de lo necesario para muchos casos. | Cuando se necesita búsqueda híbrida avanzada y se dispone de tiempo para la configuración. |
| pgvector | Opinión impopular: para muchos casos es suficiente y mucho más simple operacionalmente. Si ya usas PostgreSQL, añadir pgvector es trivial. La búsqueda es más lenta que soluciones dedicadas a gran escala, pero para volúmenes moderados funciona bien. | — | Ideal cuando ya tienes una base PostgreSQL y buscas una solución ligera. |
Ejemplo de reranking con cross‑encoder (usando pgvector)
# 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 índices
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‑400 ms, perfectamente aceptable para una aplicación de búsqueda legal.
Búsqueda híbrida (vectorial + BM25)
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
| Área | Recomendación |
|---|---|
| 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), extráela explícitamente en lugar de cortar ciegamente. |
| 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. Evitaría Pinecone salvo que el cliente requiera una solución totalmente gestionada en la nube. |
| Recuperación | Siempre reranking. El costo computacional es mínimo comparado con la mejora en calidad. Si el presupuesto lo permite, usar Cohere Rerank; si no, el modelo local ms-marco-MiniLM funciona bien. Añadir BM25 híbrido para dominios con terminología especializada (derecho, medicina, código fuente). |
| 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 22 MB corriendo en una instancia EC2 modesta.