Cuando monté el primer RAG serio para búsqueda semántica en JXBS, me pasé días comparando modelos de embeddings y probando LLMs cada vez más grandes encima del retriever. El salto de calidad real no vino de ahí. Vino de arreglar cómo partía los documentos antes de indexarlos. El chunking —esa parte aburrida que todo el mundo resuelve con un split por caracteres y sigue— es la decisión que más determina si tu RAG responde bien o alucina con seguridad. Este artículo es lo que aprendí a fuerza de recuperar fragmentos inútiles.
El retriever solo puede recuperar lo que indexaste bien
Un RAG tiene dos mitades: recuperar los fragmentos relevantes y generar una respuesta a partir de ellos. La segunda mitad es la que se lleva toda la atención porque es donde vive el LLM vistoso. Pero la generación está limitada por lo que le llega: si el retriever te trae tres fragmentos mediocres, ningún modelo, por grande que sea, va a inventar el contexto que falta. Como mucho lo va a fingir, que es peor.
Y lo que el retriever puede recuperar depende por completo de cómo cortaste el texto. Un embedding representa el significado de un fragmento entero. Si ese fragmento mezcla dos ideas distintas, su vector queda en un punto intermedio que no representa bien a ninguna de las dos, y la búsqueda por similitud lo pasa por alto justo cuando lo necesitas. El problema no es el modelo de embeddings: es que le diste a codificar un trozo de texto sin una idea clara dentro.
Partir por caracteres es el error por defecto
La receta que trae casi todo tutorial es cortar cada 1000 caracteres con 200 de solape. Es cómodo y es lo que hice al principio. El problema es que 1000 caracteres no significan nada semánticamente: el corte cae a mitad de una frase, separa una definición de su ejemplo, o mete el final de una sección y el principio de la siguiente en el mismo chunk. Acabas con vectores que representan fragmentos partidos por la mitad.
El primer arreglo barato es partir por estructura, no por longitud. Los documentos ya vienen con fronteras semánticas: párrafos, encabezados, ítems de lista. Cortar respetando esas fronteras hace que cada chunk tenga una idea razonablemente completa dentro.
// En vez de trocear a ciegas cada N caracteres,
// respeta las fronteras naturales del documento
function chunkByStructure(markdown: string): string[] {
// Parte por encabezados de sección primero
const sections = markdown.split(/\n(?=#{1,3}\s)/);
return sections.flatMap((section) => {
// Si una sección es corta, es un chunk entero
if (section.length <= 1200) return [section];
// Si es larga, subdivide por párrafos, no por caracteres
return section
.split(/\n\n+/)
.reduce<string[]>((acc, para) => {
const last = acc[acc.length - 1];
if (last && (last + '\n\n' + para).length <= 1200) {
acc[acc.length - 1] = last + '\n\n' + para;
} else {
acc.push(para);
}
return acc;
}, []);
});
}
No es sofisticado, pero el salto de calidad respecto al corte ciego es inmediato porque cada vector pasa a representar una unidad de sentido y no un pedazo arbitrario.
El tamaño del chunk es un trade-off, no un número mágico
Aquí no hay un valor correcto, hay una tensión que tienes que resolver según tu caso. Chunks pequeños dan embeddings muy precisos —el vector representa una idea concreta— pero fragmentan el contexto: la respuesta a una pregunta puede quedar repartida en cinco trozos y el retriever solo te trae los tres más parecidos. Chunks grandes conservan contexto pero diluyen el embedding: cuanto más texto metes, más se promedia el significado y menos discrimina la búsqueda.
Mi regla práctica es partir de chunks medianos, del tamaño de un par de párrafos que traten un solo subtema, y ajustar mirando qué recupera el sistema en consultas reales. Si veo que las respuestas correctas quedan cortadas, subo el tamaño; si veo que se recuperan fragmentos que hablan de varias cosas a la vez, lo bajo. El solape entre chunks contiguos ayuda a que una idea a caballo entre dos trozos no se pierda, pero el solape no arregla un corte hecho en el sitio equivocado.
💡 No optimices el tamaño del chunk en abstracto. Escribe diez consultas reales de tu producto, mira qué fragmentos recupera el sistema para cada una, y ajusta a partir de eso. El eval manual de diez casos te dice más que cualquier heurística.
Metadatos y contexto: el chunk no vive solo
Un fragmento arrancado del medio de un documento pierde de dónde salió. "El plazo es de treinta días" no significa nada sin saber plazo de qué. Por eso a cada chunk le adjunto metadatos —título del documento, sección, fecha— y cuando puedo antepongo una línea de contexto al propio texto que se embebe, de modo que el vector también capture a qué pertenece el fragmento. Ese pequeño encabezado hace que fragmentos ambiguos se vuelvan recuperables por la consulta correcta.
Los metadatos además te dan filtrado previo. En pgvector puedo combinar la búsqueda por similitud con un WHERE sobre columnas normales, y acotar el espacio de búsqueda antes de comparar vectores.
SELECT id, contenido
FROM chunks
WHERE documento_tipo = 'contrato'
AND idioma = 'es'
ORDER BY embedding <=> $1
LIMIT 5;
Filtrar por metadatos antes de ordenar por distancia no solo mejora la precisión: reduce el conjunto sobre el que corre la búsqueda vectorial y con ello la latencia.
Dónde pongo el esfuerzo hoy
Si tuviera que repartir el tiempo de nuevo, dedicaría la mayor parte a la fase de ingesta —cómo parto, qué metadatos guardo, cómo enriquezco cada chunk con su contexto— y mucho menos a probar modelos. El modelo de embeddings importa, y el LLM generador importa, pero ambos operan sobre lo que la ingesta les prepara. Elegí invertir en chunking en vez de en un modelo más grande porque el chunking es barato de iterar y su efecto es multiplicador: mejora cada consulta que el sistema atienda, hoy y en el futuro. A cambio, es trabajo poco vistoso y difícil de presumir. Me sale a cuenta igual.