RAG — Módulo 3: chunking, cómo cortar los documentos
Tercer módulo del Curso de RAG: el trade-off entre chunks chicos y grandes, cinco estrategias de corte en Python (por caracteres, por tokens, recursivo, estructural y semántico), qué es el overlap, tamaños por tipo de documento y tres trucos que suben el retrieval.
☰ Índice del contenido
- La tesis del módulo
- Por qué hay que cortar
- El trade-off central
- Estrategia 1: fijo por caracteres (el ingenuo)
- Estrategia 2: fijo por tokens
- Estrategia 3: recursivo por separadores (el default sensato)
- Estrategia 4: por estructura del documento (el mejor cuando aplica)
- Estrategia 5: semántico (cuándo vale la pena y cuándo es humo)
- Comparación de las cinco estrategias
- Overlap: por qué los chunks se pisan
- Tamaños recomendados por tipo de documento
- Tres trucos que suben la calidad sin cambiar el modelo
- Código completo: chunking recursivo por tokens
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
Del módulo anterior saliste con documentos normalizados: texto limpio con metadata pegada. Ahora hay que cortarlos, y esta es probablemente la decisión con mejor relación entre esfuerzo y resultado de todo el pipeline. Cambiar el tamaño de chunk es una línea de código y puede mover la calidad del retrieval más que cambiar de modelo de embeddings. También es donde más se rompe un RAG en silencio: la respuesta estaba en el documento, estaba en el índice, y aun así no llegó — porque quedó partida entre dos chunks y ninguno de los dos, por separado, se parecía a la pregunta.
La tesis del módulo
El chunk es la unidad atómica de tu sistema: es lo que se embebe, lo que se busca y lo que se le entrega al modelo. Un chunk debe ser lo bastante chico para tener un significado nítido y lo bastante grande para responder por sí solo. Cortar no es un detalle técnico previo al RAG: es el RAG.
Por qué hay que cortar
Tres razones concretas, y ninguna es “porque el documento es largo”.
Límite del modelo de embeddings. Un modelo de embeddings (el que convierte texto en un vector de significado) tiene una ventana máxima, típicamente 512 u 8.192 tokens. Lo que pasa de ahí simplemente se trunca: se descarta sin aviso. Si le pasas un manual de 200 páginas, indexaste las primeras páginas y nada más.
Precisión del retrieval. Esta es la razón de fondo. Un embedding comprime todo el texto en un solo vector, un promedio de su significado. Si el chunk trata de cinco temas, el vector queda en el centro de gravedad de los cinco y no se parece nítidamente a ninguno. Un chunk enfocado en un tema produce un vector nítido y se recupera bien.
Costo y contexto. Le pasas al LLM los chunks recuperados, y pagas cada token. Recuperar 5 chunks de 300 tokens cuesta 1.500 tokens; recuperar 5 capítulos completos cuesta 50.000 y encima diluye la atención del modelo — el problema de “lost in the middle” del módulo 1.
El trade-off central
Todo el chunking es una negociación entre dos fuerzas opuestas:
- Chunks chicos (100-200 tokens): el embedding es nítido y el retrieval encuentra con precisión. Pero el chunk recuperado puede no tener suficiente información para responder — encontraste la frase correcta sin el contexto que la explica.
- Chunks grandes (1.500+ tokens): el chunk contiene todo lo necesario para responder. Pero el embedding es un promedio difuso de muchos temas, así que la búsqueda lo encuentra peor, y arrastra mucho texto irrelevante al prompt.
RETRIEVAL RESPUESTA
preciso completa
▲ ▲
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ chunk chico │ │ chunk grande│
│ 100-200 tok │ │ 1500+ tok │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
falta contexto embedding difuso
para responder y ruido en prompt
┌───────────────┐
│ ZONA ÚTIL │
│ 200-600 tokens│
│ + overlap │
└───────────────┘
La zona útil para la mayoría de los casos está entre 200 y 600 tokens. No es una ley: es el punto de partida desde el cual se mide.
Ojo: ese rango es un punto de partida, no una respuesta. El tamaño correcto depende de tu corpus y de tus preguntas, y la única forma de saberlo es medir con un set de evaluación — exactamente lo que construimos en el módulo 10. Cualquiera que te dé un número universal sin preguntarte qué documentos tienes, está adivinando.
Estrategia 1: fijo por caracteres (el ingenuo)
Cortar cada N caracteres. Es la primera idea de todos y la peor de todas.
def chunk_caracteres(texto: str, tam: int = 1000) -> list[str]:
"""El chunking ingenuo. Sirve para entender el problema, no para usar."""
return [texto[i:i + tam] for i in range(0, len(texto), tam)]
ejemplo = ("La garantía legal en Chile cubre 6 meses por fallas de "
"fábrica. El plazo corre desde la fecha de la boleta.")
for c in chunk_caracteres(ejemplo, 60):
print(repr(c))
# 'La garantía legal en Chile cubre 6 meses por fallas de fábri'
# 'ca. El plazo corre desde la fecha de la boleta.'
El corte cae en medio de “fábrica”. El primer chunk termina en una palabra rota y el segundo empieza con “ca.”, una sílaba huérfana que no significa nada. Ninguno de los dos se parece a la pregunta “¿cuánto dura la garantía por fallas de fábrica?” tanto como se habría parecido el texto completo.
Estrategia 2: fijo por tokens
El problema con los caracteres es que no son la unidad que ve el modelo. Un token es la pieza mínima en que el modelo parte el texto: puede ser una palabra, un pedazo de palabra o un signo. La relación con los caracteres no es constante — en español ronda 1 token por cada 3,5-4 caracteres, pero el código, los números y los nombres propios gastan muchos más tokens por carácter.
Esto importa porque los límites que te afectan están en tokens: la ventana del modelo de embeddings, la del LLM y el costo. Medir en caracteres es medir en una unidad que no controla nada.
import tiktoken
# cl100k_base es el tokenizador de los modelos de embeddings de OpenAI.
enc = tiktoken.get_encoding("cl100k_base")
def chunk_tokens(texto: str, tam: int = 300) -> list[str]:
"""Corta por cantidad exacta de tokens. Ya no rompe la unidad que
ve el modelo, pero sigue cortando en medio de una frase."""
tokens = enc.encode(texto)
return [enc.decode(tokens[i:i + tam])
for i in range(0, len(tokens), tam)]
texto = "La garantía legal en Chile cubre 6 meses por fallas de fábrica."
print(len(texto), "caracteres →", len(enc.encode(texto)), "tokens")
# 63 caracteres → 17 tokens (ratio ~3.7, típico del español)
Mejor: ahora controlas la unidad real y ningún chunk excede el límite del modelo. Pero el corte sigue siendo arbitrario respecto del significado: puede caer justo en medio de una frase o separar la pregunta de su respuesta.
Estrategia 3: recursivo por separadores (el default sensato)
La idea es simple y potente: en vez de cortar en una posición fija, cortar en los lugares donde el texto ya se separa solo. Un texto tiene una jerarquía natural de separadores, de más fuerte a más débil:
\n\n (párrafo) → \n (línea) → . (oración) → (palabra)
El algoritmo intenta cortar por el separador más fuerte. Si un pedazo resultante todavía es muy grande, lo vuelve a cortar con el siguiente separador, y así recursivamente. Por eso se llama recursivo. El resultado: los cortes caen entre párrafos casi siempre, y solo bajan a cortes más agresivos cuando un párrafo por sí solo excede el tamaño.
Es el RecursiveCharacterTextSplitter de LangChain y es el default correcto cuando no sabes nada de tus documentos.
Estrategia 4: por estructura del documento (el mejor cuando aplica)
Si el documento tiene estructura explícita — encabezados Markdown, secciones de un contrato, artículos de un reglamento — esa estructura ya es un mapa semántico hecho por un humano. Cortar por encabezados produce chunks que corresponden a unidades de sentido reales, no a ventanas de N tokens.
La ventaja adicional es enorme: sabes en qué sección está cada chunk, y esa jerarquía se puede prepender al texto antes de embeberlo (el truco 1 de más abajo). Por eso vale la pena que el parsing del módulo 2 preserve los encabezados: aquí es donde se cobra ese esfuerzo.
Estrategia 5: semántico (cuándo vale la pena y cuándo es humo)
El chunking semántico embebe cada oración por separado, mide la similitud entre oraciones consecutivas y corta donde la similitud cae — asumiendo que una caída marca un cambio de tema.
import numpy as np
def chunk_semantico(oraciones: list[str], embed_fn,
percentil: int = 90) -> list[str]:
"""Corta donde el significado cambia bruscamente entre oraciones
consecutivas. `embed_fn` recibe una lista de textos y devuelve una
matriz de vectores normalizados."""
if len(oraciones) < 2:
return [" ".join(oraciones)]
vecs = embed_fn(oraciones)
# Distancia entre cada par consecutivo: 1 - coseno. Alta = cambio.
distancias = [1 - float(vecs[i] @ vecs[i + 1])
for i in range(len(vecs) - 1)]
# El umbral es relativo al propio documento, no un número fijo:
# cortamos en el 10% de saltos más grandes.
umbral = np.percentile(distancias, percentil)
chunks, actual = [], [oraciones[0]]
for i, d in enumerate(distancias):
if d > umbral:
chunks.append(" ".join(actual))
actual = []
actual.append(oraciones[i + 1])
chunks.append(" ".join(actual))
return chunks
Suena elegante y a veces lo es: funciona bien en texto corrido sin estructura, como transcripciones de reuniones o entrevistas, donde no hay encabezados que aprovechar.
Pero en la mayoría de los casos es humo. Cuesta caro (embebes cada oración del corpus solo para decidir dónde cortar), es lento, produce chunks de tamaño impredecible que pueden pasarse del límite, y casi nunca gana por márgenes que justifiquen el costo frente a un chunking estructural bien hecho. Si el documento tiene encabezados, la estrategia 4 le gana con menos esfuerzo. Pruébalo cuando ya mediste y el chunking estructural no alcanzó.
Comparación de las cinco estrategias
| Estrategia | Costo | Respeta el sentido | Tamaño predecible | Cuándo usarla |
|---|---|---|---|---|
| Fijo por caracteres | Nulo | No, rompe palabras | Sí | Nunca en producción |
| Fijo por tokens | Nulo | No, corta frases | Sí, exacto | Texto sin estructura; garantizar el límite |
| Recursivo | Nulo | Bastante | Sí, aproximado | El default cuando no sabes nada |
| Estructural | Bajo | Sí, unidades reales | No, variable | Markdown, HTML, contratos, wikis |
| Semántico | Alto (embeddings) | Sí, en teoría | No | Transcripciones y texto corrido sin estructura |
Overlap: por qué los chunks se pisan
El overlap (solapamiento) es repetir los últimos N tokens de un chunk al comienzo del siguiente. Existe por una razón concreta: una idea partida en dos deja de ser recuperable.
Sin overlap, si el corte cae entre “El plazo de garantía es de” y “6 meses desde la boleta”, ninguno de los dos chunks responde la pregunta completa. Con overlap, el segundo chunk arrastra el final del primero y la idea sobrevive entera en al menos un chunk.
La cantidad estándar es 10-20% del tamaño del chunk: 300 tokens con 30-60 de overlap. Menos de 10% casi no protege; más de 25% infla el índice sin ganancia proporcional.
Y tiene un costo real que se olvida: el contenido duplicado ocupa espacio, se paga en embeddings, y hace que el top-k pueda devolver dos chunks casi idénticos ocupando dos de tus cinco cupos. El overlap es un seguro, y como todo seguro tiene prima.
Si cortas por estructura, el overlap importa menos: los cortes ya caen en fronteras naturales donde nada se parte.
Tamaños recomendados por tipo de documento
Un solo tamaño para todo el corpus es un error clásico. Un FAQ y un contrato no se cortan igual.
| Tipo de documento | Tokens | Overlap | Estrategia | Por qué |
|---|---|---|---|---|
| FAQ / preguntas cortas | 100-200 | 0 | Una pregunta por chunk | Cada Q&A ya es una unidad completa; partirla no tiene sentido |
| Manual técnico | 300-600 | 10-15% | Estructural por encabezados | Las secciones ya son unidades de procedimiento |
| Contrato legal | 500-1000 | 15-20% | Estructural por cláusula | Una cláusula suelta pierde sentido; hay referencias cruzadas |
| Código fuente | 200-500 | 0 | Por función o clase | La función es la unidad natural; partirla la vuelve inútil |
| Transcripción de reunión | 400-800 | 15-20% | Semántico o por turno | No hay estructura; el tema cambia sin aviso |
| Artículos / blog | 300-500 | 10% | Recursivo o por ## | Párrafos bien delimitados |
| Tablas de datos | 1 fila + header | 0 | Fila con encabezados | Una fila sin sus columnas no significa nada |
La última fila es la más olvidada: al indexar una tabla, cada chunk debe llevar los encabezados de columna. La fila Premium | 45000 | 30 días sin su encabezado es basura; con él es un dato.
Tres trucos que suben la calidad sin cambiar el modelo
Truco 1: prepender el título y la sección al chunk antes de embeberlo. Este es el de mejor retorno de todo el módulo, y es casi gratis.
Un chunk sacado de la mitad de un documento suele decir “El plazo es de 30 días hábiles” sin mencionar de qué plazo habla — el documento ya lo dijo tres páginas antes. Su embedding es genérico y no se parece a “¿cuánto demora un reembolso?”. Si antes de embeberlo le pegas el título y la sección:
Manual de Operaciones > Reembolsos > Plazos
El plazo es de 30 días hábiles.
el vector queda anclado al tema y el retrieval mejora de inmediato. Es una decisión de dos líneas que rinde más que cambiar de modelo de embeddings.
Truco 2: chunking estructural respetando encabezados. Ya lo vimos como estrategia 4, pero vale insistir en el punto práctico: si tu parser preserva los # del Markdown, obtienes gratis los chunks alineados al sentido y la jerarquía para el truco 1. Los dos trucos son el mismo esfuerzo.
Truco 3: small-to-big (parent document). Rompe el trade-off central con una idea simple: buscar con el chunk chico, entregar el chunk grande. Indexas piezas de 150 tokens para que el embedding sea nítido, pero guardas en la metadata el parent_id de la sección completa. Cuando la búsqueda encuentra la pieza chica, le pasas al LLM el padre de 1.500 tokens.
Obtienes precisión de retrieval y contexto de generación al mismo tiempo. Para que funcione basta con guardar el parent_id y el texto del padre desde ahora; la implementación completa está en el módulo 11.
Código completo: chunking recursivo por tokens
Esta es la implementación a mano, para que se entienda el mecanismo y no quede como magia de librería. Corta por la jerarquía de separadores, mide en tokens reales, aplica overlap y hereda la metadata del módulo 2 aplicando el truco 1.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
# De separador más fuerte (párrafo) a más débil (palabra). El orden ES
# el algoritmo: se baja de nivel solo cuando el pedazo sigue muy grande.
SEPARADORES = ["\n\n", "\n", ". ", ", ", " "]
def n_tokens(texto: str) -> int:
return len(enc.encode(texto))
def _partir(texto: str, tam: int, nivel: int = 0) -> list[str]:
"""Parte recursivamente hasta que todos los pedazos quepan en `tam`."""
if n_tokens(texto) <= tam:
return [texto]
if nivel >= len(SEPARADORES):
# Sin separadores disponibles: corte duro por tokens. Es el
# último recurso y garantiza que nada exceda el límite.
tokens = enc.encode(texto)
return [enc.decode(tokens[i:i + tam])
for i in range(0, len(tokens), tam)]
sep = SEPARADORES[nivel]
partes = texto.split(sep)
# Reagrupa las partes en piezas lo más grandes posible sin pasarse:
# cortar de más produce chunks fragmentados sin necesidad.
piezas, actual = [], ""
for parte in partes:
candidato = (actual + sep + parte) if actual else parte
if n_tokens(candidato) <= tam:
actual = candidato
else:
if actual:
piezas.append(actual)
# Esta parte sola puede seguir siendo enorme: baja de nivel.
if n_tokens(parte) > tam:
piezas.extend(_partir(parte, tam, nivel + 1))
actual = ""
else:
actual = parte
if actual:
piezas.append(actual)
return piezas
def _con_overlap(piezas: list[str], overlap: int) -> list[str]:
"""Arrastra los últimos `overlap` tokens de cada pieza al inicio de
la siguiente, para que ninguna idea quede partida sin remedio."""
if overlap <= 0 or len(piezas) < 2:
return piezas
salida = [piezas[0]]
for anterior, actual in zip(piezas, piezas[1:]):
cola = enc.decode(enc.encode(anterior)[-overlap:])
salida.append(f"{cola} {actual}".strip())
return salida
def chunkear(doc: dict, tam: int = 300, overlap: int = 50) -> list[dict]:
"""Recibe un documento normalizado del módulo 2 y devuelve chunks
con metadata heredada. Aplica el truco 1: el texto que se EMBEBE
lleva título y sección; el texto original se conserva aparte para
mostrarlo en la cita sin el prefijo artificial."""
piezas = _con_overlap(_partir(doc["texto"], tam), overlap)
meta = doc["metadata"]
encabezado = meta.get("titulo", "")
if meta.get("seccion"):
encabezado += f" > {meta['seccion']}"
chunks = []
for i, pieza in enumerate(piezas):
chunks.append({
# Lo que va al modelo de embeddings (con contexto prependido).
"texto_embed": f"{encabezado}\n{pieza}" if encabezado else pieza,
# Lo que se le muestra al usuario y se le pasa al LLM.
"texto": pieza,
"metadata": {
**meta, # fuente, página, permisos...
"chunk_index": i,
"total_chunks": len(piezas),
"n_tokens": n_tokens(pieza),
# Para small-to-big del módulo 11.
"parent_id": f"{meta['doc_id']}:p{meta.get('pagina', 0)}",
},
})
return chunks
if __name__ == "__main__":
doc = {
"texto": (
"La garantía legal en Chile cubre 6 meses por fallas de "
"fábrica.\n\nEl plazo corre desde la fecha de la boleta y "
"es independiente de la garantía del fabricante.\n\n"
"Para hacerla efectiva se necesita la boleta original."
),
"metadata": {"doc_id": "a1b2c3", "titulo": "Manual de Ventas",
"seccion": "Garantías", "pagina": 12,
"permisos": ["rol:ventas"]},
}
for c in chunkear(doc, tam=40, overlap=8):
print(f"[{c['metadata']['n_tokens']} tok] {c['texto_embed'][:70]}...")
Y este es el equivalente con LangChain, que hace lo mismo en tres líneas. Ahora que sabes qué ocurre por dentro, usarlo no es magia:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base",
chunk_size=300, # en TOKENS, no caracteres, por el from_tiktoken
chunk_overlap=50,
separators=["\n\n", "\n", ". ", ", ", " ", ""],
)
chunks = splitter.split_text(doc["texto"])
Para documentos con encabezados existe además MarkdownHeaderTextSplitter, que devuelve cada chunk con su jerarquía de títulos en la metadata — es la estrategia 4 y el truco 1 resueltos de una vez.
Errores comunes
- Cortar tablas por la mitad. El corte genérico parte la tabla y deja filas huérfanas sin encabezados:
Premium | 45000 | 30 díassin saber qué columna es cuál. Detecta las tablas en el parsing y trátalas como unidad, repitiendo el encabezado en cada pedazo si hay que dividirlas. - Chunks sin metadata. Un chunk que no sabe de qué documento y página salió no se puede citar, no se puede filtrar y no se puede depurar. La metadata del módulo 2 tiene que heredarse a cada chunk, no quedarse en el documento.
- Un solo tamaño para todo el corpus. Un FAQ de 80 tokens y un contrato de 40 páginas no se cortan igual. Enruta el tamaño por tipo de documento.
- Elegir el tamaño adivinando. “500 con 50 de overlap porque lo vi en un tutorial” no es una decisión, es una copia. El tamaño se decide midiendo con un set de evaluación (módulo 10); es el parámetro más barato de probar y de los que más mueven la aguja.
- Overlap exagerado. 50% de solapamiento duplica el índice, duplica el costo de embeddings y hace que el top-k devuelva el mismo texto dos veces ocupando cupos. El rango sano es 10-20%.
- Embeber el chunk pelado. Sin el título y la sección prependidos, un chunk de la mitad de un documento pierde su tema y su vector queda genérico. Es la mejora más barata que existe y casi nadie la aplica.
- Ignorar la estructura que el parser sí preservó. Si el documento trae encabezados Markdown y cortas por caracteres, botaste a la basura el mapa semántico que un humano ya había escrito.
La frase para llevarse
“El chunk es la unidad atómica de tu RAG: chico para que se encuentre, grande para que responda. Todo el chunking es aprender a vivir entre esas dos exigencias — y medirlo en vez de adivinarlo.”
Qué viene
Módulo 4: embeddings e indexación. Ya tienes los chunks; ahora hay que convertirlos en vectores y guardarlos. Vamos a ver qué es realmente un embedding y por qué textos parecidos quedan cerca, cómo elegir el modelo (dimensiones, idioma, ventana, costo, y el eterno dilema de API contra modelo local), qué es MTEB y cómo leer ese ranking sin dejarse engañar, la diferencia entre normalizar o no, y cómo construir el índice sin quemar el presupuesto en el primer batch.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.