Chunking para RAG: corta por estructura o corrompe en silencio

El troceo por caracteres no es un detalle técnico: es cómo conviertes un documento bueno en basura que el modelo se traga creyendo que está completa. Aquí va el criterio, el código y cómo evaluar sin hacerte trampas.

17 min de lectura

En este artículo

Lo que crees que es chunking (y no lo es)

Montas un RAG. Metes PDFs. Partes el texto cada 500 o 1000 caracteres con solape, embebes, guardas vectores y rezas. Cuando la respuesta sale coja, culpas al modelo, al embedding o al “prompt”.

Casi nunca miras el corte.

El chunking no es un preproceso aburrido antes de lo divertido. Es la decisión de qué pedazo de realidad va a ver el modelo. Si ese pedazo está roto por la mitad de una tabla, sin el encabezado que le da sentido, o amputado a los 800 caracteres porque “así

La tesis de este medio es simple y no es negociable:

Truncar por bytes (o por un [:N] sobre el texto serializado) es corrupción silenciosa. Filtra en origen y recorta por estructura. Nunca por caracteres a ciegas.

Y la segunda cara de la misma moneda: el presupuesto de contexto se gestiona eligiendo, no resumiendo. Si para “que quepa” resumes el chunk antes de evaluarlo, ya no estás midiendo tu pipeline de retrieval. Estás midiendo un resumen que en producción no existía.

Esto no es una lista de “10 estrategias mágicas de chunking”. Es una guía operativa: qué hacer, en qué orden, con código reproducible, y cómo saber si tus chunks siguen teniendo la información relevante sin autoengañarte.

Qué problema resuelve (y cuál no)

Un buen chunking resuelve tres cosas a la vez:

  1. Unidad de sentido. El fragmento que recuperas debería bastar para responder una pregunta concreta sin necesitar el párrafo de antes… o, si lo necesita, el solape o la metadata tienen que traerlo.
  2. Encaje en el presupuesto. Lo recuperado + el prompt + la pregunta + la holgura de salida tienen que caber. Aritmética, no fe.
  3. Señal honesta para el evaluador y para el modelo. Si algo se recortó, tiene que verse. Si no se ve, el sistema razona como si tuviera el documento completo.

Lo que no resuelve el chunking por sí solo: embeddings malos, corpus basura, preguntas fuera de dominio, ni un generador al que le pides citas de páginas que nunca le diste. Si tu base está podrida, trocearla “mejor” solo reparte la podredumbre en porciones más monas.

Si además orquestas varios agentes sobre el mismo corpus, el troceo malo se multiplica: cada uno se come un fragmento distinto y se pisan el contexto. Eso se conecta con cómo orquestar agentes sin fundirte en tokens ni pisaros el contexto, pero aquí el foco es el corte del documento, no el enjambre.

Requisitos mínimos para trabajar esto en serio

No hace falta un cluster ni una “plataforma enterprise de knowledge”.

  • Python 3.10+ (o el que ya uses en el servicio).
  • Un corpus real tuyo (aunque sean 20 documentos feos de verdad). Fixtures de juguete solo valen para tests de lógica, no para calibrar cortes.
  • Una forma de contar tokens del modelo que va a consumir el contexto (no “palabras × 1.3 del inglés” si escribes en español).
  • Un almacén vectorial o, al menos, una lista de chunks en memoria para iterar.
  • Criterio de evaluación con preguntas ancladas a ese corpus, no un benchmark genérico descargado de internet.

Opcional pero útil: la vía de chunking semántico de LangChain (docs de semantic chunker en python.langchain.com) si quieres comparar un corte por similitud de frases contra tu corte estructural. No es obligatoria para aplicar el principio; es una herramienta más en la mesa.

El orden que casi nadie respeta: origen → estructura → marca → presupuesto

La mayoría empieza por el final: “¿de cuántos caracteres hago el chunk?”. Eso ya es el fallo.

1. Filtra en origen

Antes de partir nada, pregunta: ¿qué no debería llegar nunca al embedder?

Boilerplate de web, menús, “compartir en redes”, números de página sueltos, disclaimers legales repetidos 40 veces, columnas vacías de CSV, logs binarios disfrazados de texto, comentarios HTML. El cuello de coste de un pipeline RAG casi nunca es el número de llamadas de embedding: es el volumen de mierda que le entregas a la llamada cara (embedding + generación).

Haz una tabla mental (mejor aún, en config) del estilo:

  • PDF corporativo → quitar cabeceras/pies repetidos, quedarte con cuerpo y títulos
  • HTML → main/article, fuera nav/footer
  • Ticket de soporte → cuerpo + campos de estado; fuera firmas e hilos reenviados 12 veces
  • Código → archivo completo o función completa; no cortes a mitad de un bloque

Si tu herramienta de carga ya expone filtros, decláralos. Si no, escribís el filtro tú antes del splitter. Meter ruido al vector store y “ya lo limpiaré con el reranker” es pagar dos veces y evaluar mal las dos.

2. Recorta por estructura, nunca por raw[:N]

La unidad de corte tiene que ser algo que un humano reconocería como bloque:

  • títulos y secciones (##, Heading 1/2 del PDF)
  • párrafos
  • elementos de lista (con su intro si la hay)
  • celdas/filas de tabla con el encabezado repetido o referenciado
  • funciones o clases en código
  • turnos de diálogo en una entrevista

El anti-patrón clásico en Python es este:

# MAL: corrupción silenciosa
texto = open("doc.txt", encoding="utf-8").read()
chunks = [texto[i:i+800] for i in range(0, len(texto), 700)]

Ese código no “aproxima” un buen chunk. Parte palabras, parte frases, parte tablas por la mitad y, lo peor: no deja rastro. El consumidor (humano o modelo) cree que el fragmento es coherente.

La versión estructural mínima, sin frameworks, puede ser tan tonta como partir por encabezados y luego empaquetar:

import re
from dataclasses import dataclass

@dataclass
class Chunk:
doc_id: str
section: str
text: str
mark: str | None = None # marca de recorte, si la hay

def split_by_headings(text: str, doc_id: str) -> list[Chunk]:
parts = re.split(r"(?m)^(#{1,3}\s+.+)$", text)
chunks: list[Chunk] = []
section = "(sin sección)"
buf: list[str] = []

def flush():
body = "\n".join(buf).strip()
if body:
chunks.append(Chunk(doc_id=doc_id, section=section, text=body))

for part in parts:
if not part:
continue
if re.match(r"(?m)^#{1,3}\s+", part):
flush()
section = part.strip("# ").strip()
buf = []
else:
buf.append(part.strip())
flush()
return chunks

¿Es el splitter definitivo del universo? No. Es el principio bien aplicado: la costura sigue la costura del documento, no un contador de caracteres.

Cuando una sección sigue siendo monstruosa, no haces section_text[:1200]. Haces el siguiente nivel estructural: párrafos, ítems de lista, subapartados. Si al final tienes que limitar por tokens del modelo, limitas por número de bloques enteros que caben, no partiendo el último a lo bestia.

3. Toda reducción deja marca explícita

Si de 40 párrafos de una sección metes 8, el chunk tiene que decirlo en la propia serialización. No en un log que nadie lee.

def pack_paragraphs(doc_id: str, section: str, paragraphs: list[str], max_paras: int) -> Chunk:
if len(paragraphs) <= max_paras:
return Chunk(doc_id=doc_id, section=section, text="\n\n".join(paragraphs))
kept = paragraphs[:max_paras]
mark = f"_truncado: parrafos: {max_paras} de {len(paragraphs)}"
body = "\n\n".join(kept) + f"\n\n[{mark}]"
return Chunk(doc_id=doc_id, section=section, text=body, mark=mark)

Esa marca no es postureo de observabilidad. Es la diferencia entre:

  • el modelo inventando el final de una política de devoluciones, y
  • el modelo (o tu evaluador) diciendo “me falta el resto de la sección”.

Regla de consumo: dato completo primero, resumen después, y solo si el resumen es un índice, no la fuente. Si pones el resumen delante y el modelo es vago, se come el resumen y ni mira el bloque. En chunking pasa lo mismo cuando “compactas” el fragmento antes de guardarlo: has cambiado la evidencia.

4. El límite es dinámico y la cadena tiene que cuadrar en tokens

Un CHUNK_SIZE = 1000 hardcodeado en tres sitios distintos es deuda. El tamaño máximo del chunk se deriva del presupuesto real de la llamada que lo va a leer:

  • ventana del modelo
  • tokens del system prompt
  • tokens de la pregunta
  • tokens reservados para la respuesta
  • número máximo de chunks recuperados
  • overhead de JSON/tool messages si hay agentes

El patrón de ingeniería es escribir la desigualdad en voz alta, no “ya cabrá”:

max_out(etapa_N+1) > max_out(etapa_N) × factor_expansion + overhead

Traducido a RAG basto:

tokens_contexto_disponible
>= sum(tokens(chunk_i) for i in top_k)
+ tokens(prompt)
+ tokens(pregunta)
+ reserva_salida

Si top_k = 6 y cada chunk puede irse a 800 tokens, necesitas ~4800 solo de evidencia. Si tu modelo útil te deja 3000 de contexto tras el prompt, no tienes un problema de “mejorar el splitter”: tienes un problema de presupuesto. O bajas k, o bajas el tamaño estructural, o cambias de modelo/ventana. Elegir. No resumir a ciegas para que “parezca” que cierra.

Los presupuestos viven en una tabla de configuración, no como literales repartidos por el repo. Así la cadena se lee de un vistazo y un test puede afirmar la desigualdad sin llamar a ningún modelo.

Y cuando el proveedor te devuelve un finish_reason de longitud: es error ruidoso, no un caso que “reparas” aguas abajo tragándote media respuesta. Lo mismo aplica al chunking: si tu empaquetado tuvo que marcar truncado en el 80 % de los bloques, no es un detalle; es que el diseño no cierra.

Cómo montar el flujo paso a paso (reproducible)

Paso A, Normaliza y filtra

  1. Extrae texto con la herramienta que ya uses (PDF, HTML, markdown).
  2. Aplica filtros de origen: quita repetidos, navegación, basura.
  3. Conserva metadata mínima: doc_id, ruta o URL interna, título, fecha si existe, tipo de documento.
  4. No embebas todavía.

Paso B, Parte por la jerarquía del documento

  1. Detecta encabezados o, si el formato es plano, párrafos dobles.
  2. Genera candidatos de chunk = bloque estructural + metadata de sección.
  3. Si un bloque supera el presupuesto de un chunk, subdivide por el siguiente nivel (párrafos, lista, tabla por filas con header).
  4. Prohibido: texto.encode()[:nbytes].decode(errors="ignore"). Eso no es recorte; es lotería de caracteres rotos.

Paso C, Empaqueta con solape estructural (no solo de caracteres)

El solape de 200 caracteres es el parche favorito de los tutoriales. A veces ayuda. A menudo solo duplica basura.

Solape con sentido:

  • repetir el título de sección en cada subchunk
  • arrastrar la fila de encabezado en cada trozo de tabla
  • en código, incluir la firma de la función en cada fragmento del cuerpo si no queda más remedio que partir
def attach_section_header(section: str, body: str) -> str:
return f"Sección: {section}\n\n{body}"

Paso D, Serializa para embedding y para el LLM por caminos conscientes

El texto que embebes y el texto que metes al prompt no tienen por qué ser idénticos al 100 %, pero tienen que contar la misma historia. Si al LLM le añades la marca _truncado y al embedding no, el retrieval no sabe que el bloque está incompleto; si al revés, recuperas algo que luego “crece” en el prompt. Sé explícito en el diseño.

Paso E, Indexa con IDs estables

Cada chunk necesita un id determinista (doc_id + section + índice). Sin eso no puedes:

  • citar
  • deduplicar
  • rehacer solo un documento
  • auditar qué vio el modelo en una respuesta concreta

La atribución hecho→fuente se gana porque el hecho usado apunta a un id real, no porque “había un PDF en el lote”. Si más adelante montas citas, el modelo puede emitir la URL o el nombre que ve; el código resuelve a ids. No le pidas índices mágicos al LLM.

Evaluación sin trampas (aquí es donde se pillan casi todos)

Puedes tener el splitter más elegante del barrio y seguir midiendo humo. Las trampas habituales:

Trampa 1, Evaluar sobre el documento completo y servir chunks

En lab le das al modelo el PDF entero “para ver si el corpus tiene la respuesta”. En prod le das 4 chunks de 600 tokens. Estás midiendo otro sistema.

Regla dura, copiada del principio de medición:

En una medición, el sujeto ve exactamente lo que mides, ni un byte más.

Si tu RAG en producción inyecta chunks, tu eval inyecta los mismos chunks, con el mismo empaquetado, las mismas marcas de truncado y el mismo top_k. Cualquier otra cosa es teatro.

Trampa 2, Resumir el contexto “para que quepa en la eval”

El presupuesto se gestiona eligiendo (menos chunks, chunks más pequeños pero enteros, mejor filtro de origen), no resumiendo la evidencia para forzar un verde en el dashboard. El resumen destruye justo la señal que querías medir: si el chunk original mantenía el número de la cláusula 4.2 o no.

Cómo saber si tus chunks mantienen lo relevante sin pasar por un resumen tramposo:

  1. Define un set de preguntas ancladas al corpus real (no al de juguete del tutorial).
  2. Para cada pregunta, etiqueta la respuesta mínima: un número, un nombre, una condición, una cita corta. Eso es el gold.
  3. Ejecuta solo retrieval y mira si el gold aparece literal o inequívoco en los chunks devueltos (hit de evidencia), antes de mirar si el LLM contesta bonito.
  4. Ejecuta generación con esos mismos chunks y puntúa fidelidad al gold.
  5. Cuando falle, clasifica el fallo: ¿no se recuperó? ¿se recuperó pero el corte partió el dato? ¿el dato nunca estuvo porque lo filtraste de más?

Eso te separa “splitter idiota” de “embedding sordo” de “generador inventa”.

Trampa 3, Calibrar el tamaño de chunk con 3 PDFs limpios

El conjunto de calibración se ancla al corpus real que crece, no al de pruebas hermético. Los tests unitarios del splitter sí deben ser fijos (un markdown con dos H2 y una lista). La calibración de “¿500 o 1200 tokens estructurales?” se hace sobre la distribución real: manuales, tickets, contratos, lo que sea tu producción.

Si tu corpus real está lleno de tablas y tu calibración fueron ensayos en prosa, vas a “demostrar” que el corte por párrafos es cojonudo… hasta el lunes.

Trampa 4, Parseo blando de salidas de eval

Si tu juez automático (otro LLM o un parser) te devuelve un veredicto de 40 tokens cuando el esquema pide 200, no es “una eval un poco corta”: es salida degradada. Recházala. Un parseo tolerante sin cota inferior de longitud convierte un fallo duro en una entrega silenciosa mala, el mismo espíritu que el truncado por bytes.

Comprueba siempre señales de corte (finish_reason, longitud mínima respecto a la entrada de la etapa, campos obligatorios presentes). Callar el error para no romper el pipeline de CI es cómo terminas optimizando basura.

Un esqueleto de eval honesta (mínimo viable)

@dataclass
class Case:
q: str
gold: str # lo mínimo que debe aparecer en la evidencia o en la respuesta
doc_ids: list[str] # opcional: de dónde debería salir

def evidence_hit(chunks: list[Chunk], gold: str) -> bool:
blob = "\n".join(c.text for c in chunks)
return gold.lower() in blob.lower()

def evaluate(cases: list[Case], retrieve, generate) -> dict:
hits = 0
faithful = 0
for case in cases:
chunks = retrieve(case.q) # mismos chunks que prod
hit = evidence_hit(chunks, case.gold)
hits += int(hit)
answer = generate(case.q, chunks) # ni byte más de contexto
faithful += int(case.gold.lower() in answer.lower())
n = max(len(cases), 1)
return {
"evidence_recall": hits / n,
"answer_faithfulness_proxy": faithful / n,
}

Esto no sustituye un pipeline de evaluación con datos propios completo. Es la línea roja para no mentirte con el chunking: primero la evidencia tiene que estar en el trozo; luego ya discutimos si el modelo la usa.

Si trabajas con memoria a largo plazo o con notas tipo Obsidian al lado del RAG, no mezcles los problemas: no son el mismo tipo de memoria. El chunking del corpus recuperable sigue siendo el del corpus recuperable.

Errores comunes (los que comete todo el mundo la primera vez)

  • Cortar por caracteres porque “es lo que salía en el tutorial”. Funciona en demos con prosa inglesa limpia. Revienta con tablas, español administrativo y código.
  • Un único tamaño para todo el corpus. Un FAQ no se trocea como un contrato ni como un log. Misma tubería, reglas de estructura distintas por doc_type.
  • Solape enorme para tapar un mal corte. Pagas embedding duplicado y retrieval redundante. Arregla la costura.
  • Tirar la metadata de sección. Luego el modelo no sabe si “artículo 5” es del convenio o del anexo de vacaciones.
  • Medir solo la respuesta final con un LLM-as-judge grandilocuente. Sin evidence_hit previo, el juez te premia prosa confiada. Ya sabemos cómo acaba eso.
  • Silenciar truncados. Si el 30 % de tus chunks llevan marca de recorte, tu sistema te está gritando que el presupuesto o el filtro de origen están mal. No pongas la marca en debug=True.
  • Resumir chunks “para optimizar tokens” antes de indexar. Estás indexando otra cosa. Si necesitas resúmenes, son un índice auxiliar, no el reemplazo del bloque con el dato.

Tips de quien ya se ha pegado con la tubería

  • Escribe en la config, en una sola tabla: max_tokens_chunk, top_k, reserva_salida, overhead_mensajes. Un test de CI que lea esa tabla y falle si la desigualdad no cierra te ahorra el viernes por la tarde.
  • Cuando pruebes un semantic chunker (el de LangChain u otro), compáralo contra el estructural con la misma eval y el mismo corpus. Si gana por 2 puntos de recall de evidencia pero te parte tablas sin header, no es victoria: es otra forma de corrupción.
  • Para código fuente, la unidad por defecto es función/clase. Partir a 500 caracteres es sabotaje.
  • Para tablas, o las serializas a filas con encabezado explícito en cada chunk, o aceptas que el modelo se inventará la columna.
  • Guarda en cada respuesta de producción los ids de chunk inyectados. Sin eso no hay post-mortem serio cuando “el RAG alucinó”.
  • Si una etapa del pipeline (limpieza → split → retrieve → generate) puede expandir salida, el presupuesto de la siguiente tiene que estar calculado con el peor caso, no con el ejemplo feliz del README.

Cierre

El chunking bueno es aburrido de explicar y salvaje de ignorar. No brilla en la keynote. Solo decide si tu RAG recupera la cláusula buena o un trozo de basura con pinta de párrafo.

Filtra en origen. Corta por estructura. Deja marca cuando falte algo. Cuadra la aritmética de tokens entre etapas. Evalúa con exactamente lo que verá el modelo, sin resumir para que el dashboard salga mono.

Si tu splitter sigue siendo texto[i:i+800], no tienes una estrategia de chunking. Tienes una lotería con embeddings caros.

Preguntas frecuentes

¿Qué es el chunking en un RAG y para qué sirve?

Es el troceo del corpus en fragmentos que se embeben, se recuperan y se inyectan al modelo como evidencia. Sirve para que la pregunta del usuario active trozos con sentido completo, no el documento entero ni un recorte aleatorio. Un mal chunking parte datos por la mitad y empuja al modelo a inventar el resto con seguridad absurda.

¿Por qué no basta con cortar cada 500 o 1000 caracteres?

Porque el contador de caracteres no respeta secciones, tablas ni funciones: produce corrupción silenciosa. El modelo recibe un fragmento roto y lo trata como si fuera el texto completo. La alternativa es filtrar basura en origen, partir por estructura del documento y, si hay que limitar, empaquetar bloques enteros con marca explícita de lo recortado.

¿Cómo sé si mis chunks conservan la información relevante?

Con evaluación anclada al corpus real: preguntas con un gold mínimo (cifra, nombre, condición) y dos mediciones separadas. Primero, si el gold aparece en los chunks recuperados (recall de evidencia). Después, si la respuesta lo usa, inyectando solo esos chunks. Si resumes el contexto para que quepa en la prueba, ya no estás midiendo tu RAG de producción.

¿El chunking semántico sustituye al corte por encabezados y párrafos?

No automáticamente. El corte semántico agrupa frases por similitud y puede ayudar en prosa irregular, pero sigue sin liberarte de filtros de origen, presupuestos de tokens ni marcas de truncado. Compáralo con el estructural usando la misma eval y el mismo top_k; gana el que preserva el gold en evidencia sin partir unidades que el lector humano consideraría indivisibles.

Fuentes

  1. LangChain overview - Docs by LangChainpython.langchain.com
  2. microsoft/Phi-3-mini-4k-instruct · Hugging Facehuggingface.co · 2026-03-02
  3. Getting Started - Generative AI with Phi-3-mini: A Guide to Inference and Deploymenttechcommunity.microsoft.com
  4. GitHub - machinelearnear/milei-gpt: Presidential bot built on top of Llama3-8B fine-tune over +100 hours of video interviewsgithub.com