El pronombre que se quedó huérfano
Tienes un documento sobre Berlín. El segundo párrafo empieza así: “Su población supera los 3,8 millones de habitantes”. Lo trocearás, embeberás cada trozo y lo meterás en tu índice vectorial.
Cuando alguien pregunte “¿cuánta gente vive en Berlín?”, ese chunk no aparecerá entre los primeros resultados. No porque el embedding sea malo, sino porque el chunk no dice “Berlín” en ninguna parte. Dice “su”. Y “su” no se parece a nada.
Este fallo no se arregla con más solape ni con un modelo de embeddings más caro. Es un problema de orden de operaciones.
El pipeline estándar tira contexto a la basura
Mira lo que hace el flujo habitual:
- Cortas el documento en trozos de 512 tokens.
- Pasas cada trozo por el encoder, por separado.
- Haces mean pooling de los tokens de cada trozo y guardas un vector.
El paso 2 es el culpable. Cuando el encoder procesa el chunk 2, no ha visto el chunk 1. La atención solo puede mirar dentro de la ventana que le has dado. El token “su” se contextualiza con las 400 palabras que lo rodean dentro del chunk, nunca con la frase de la que depende, que quedó fuera.
Tú tienes un encoder de contexto largo capaz de leer 8.000 tokens de golpe y le estás dando cachitos de 512. Le has amputado justo la capacidad por la que lo elegiste.
Darle la vuelta al orden
El chunking tardío hace lo mismo cambiando dónde se corta:
- Pasas el documento entero por el encoder. Una sola pasada.
- Obtienes los embeddings de token, ya contextualizados entre sí.
- Cortas esa secuencia de vectores por las fronteras de tus chunks.
- Haces mean pooling de cada tramo por separado.
El corte ocurre después del transformer y justo antes del pooling, de ahí lo de “tardío”. El resultado es un vector por chunk, igual que antes, pero cada uno lleva dentro la huella de todo el documento. El “su” del segundo párrafo ya sabe que habla de Berlín porque su representación pasó por las capas de atención con la primera frase visible.
Y la ventaja práctica: no hay que reentrenar nada. Funciona con cualquier encoder de contexto largo que ya uses.
Cinco líneas de diferencia
from transformers import AutoModel, AutoTokenizer
import torch
modelo = AutoModel.from_pretrained("jinaai/jina-embeddings-v3",
trust_remote_code=True)
tok = AutoTokenizer.from_pretrained("jinaai/jina-embeddings-v3",
trust_remote_code=True)
def chunking_tardio(texto: str, fronteras: list[tuple[int, int]]):
"""fronteras: pares (inicio, fin) en índices de token, nunca de carácter."""
# 1. Una sola pasada con el documento completo.
entradas = tok(texto, return_tensors="pt", truncation=True, max_length=8192)
with torch.no_grad():
# (1, n_tokens, dim): un vector por token, ya contextualizado.
tokens = modelo(**entradas).last_hidden_state[0]
# 2. El pooling se hace por tramo, no por documento.
vectores = []
for inicio, fin in fronteras:
tramo = tokens[inicio:fin]
v = tramo.mean(dim=0)
# Normaliza si tu índice usa similitud coseno.
vectores.append(torch.nn.functional.normalize(v, dim=0))
return torch.stack(vectores)
El detalle que suele romper la implementación está en el docstring: las fronteras
tienen que estar en índices de token del mismo tokenizador. Si troceas por
caracteres y luego intentas mapear, te desalineas con los tokens especiales y los
subtokens. Usa return_offsets_mapping=True para hacer la conversión bien y no
a ojo.
Qué gana y qué cuesta
| Chunking clásico | Chunking tardío | Recuperación contextual | |
|---|---|---|---|
| Contexto en el vector | Solo el del chunk | Todo el documento | El que resuma el LLM |
| Coste de indexación | 1 pasada de encoder por chunk | 1 pasada por documento | 1 llamada a LLM por chunk |
| Requisitos | Cualquier encoder | Encoder de contexto largo | Un LLM y su factura |
| Reentrenamiento | No | No | No |
| Texto almacenado | El chunk | El chunk, sin tocar | El chunk más el prefijo generado |
La tercera columna es la técnica de anteponer a cada chunk un resumen del documento generado por un LLM. Ataca el mismo problema y funciona, pero pagas una llamada de inferencia por chunk y modificas el texto que luego enseñarás al usuario. El chunking tardío no toca el texto: solo cambia cómo se calcula el vector.
Sobre el coste conviene ser honesto: el chunking tardío no es gratis. La atención escala de forma cuadrática con la longitud de la secuencia, así que una pasada sobre 8.192 tokens cuesta más cómputo que 16 pasadas sobre 512. Lo que ganas es que haces una sola llamada en vez de dieciséis, con su overhead fijo, y que sigues indexando en una fracción de lo que costaría una llamada a un LLM por chunk. Sigue siendo muy barato comparado con la alternativa de la tercera columna.
El artículo original reporta mejoras consistentes en varias tareas de recuperación, y la ganancia crece cuanto más largos son los documentos. Tiene sentido: es justo ahí donde se acumulan los pronombres y las referencias cruzadas.
Los límites, que existen
Tres cosas antes de que lo metas en producción:
El documento tiene que caber. Si tu encoder aguanta 8.192 tokens y el PDF tiene 40.000, hay que partirlo igualmente. La práctica razonable es trocear en macrobloques que quepan en la ventana y aplicar chunking tardío dentro de cada uno. Ganas contexto local, no global.
No sirve de nada si tus chunks ya eran autocontenidos. Documentación de API, fichas de producto, entradas de un glosario: si cada trozo ya nombra su sujeto, el contexto extra aporta poco. La mejora se concentra en prosa larga con referencias hacia atrás.
El pooling importa. Si tu modelo usa el token [CLS] en vez de mean pooling,
esto no se aplica tal cual: necesitas un [CLS] por tramo, que no tienes. Con
mean pooling es directo. Comprueba qué hace tu modelo antes de escribir código.
Por dónde empezar
No lo adoptes por elegante. Adóptalo si tienes el síntoma.
Coge 30 consultas reales que fallen y mira los chunks que deberían haber salido. Si buena parte de ellos empiezan con “esto”, “su”, “el anterior” o “dicho sistema”, tienes el problema de contexto y el chunking tardío te va a mover la aguja. Si los chunks correctos son autocontenidos y aun así no se recuperan, el problema está en la consulta o en el reranking, y esto no te salvará.
Si decides probarlo, la ruta corta es esta:
- Reindexa un subconjunto del corpus con los dos métodos, mismo modelo y mismas fronteras de chunk. Cambia una sola variable.
- Mide recall@k y nDCG@10 sobre tu conjunto de evaluación, no sobre BEIR.
- Vigila el consumo de memoria: la secuencia completa de embeddings de token ocupa bastante más que el vector final. Procesa por lotes de documentos, no de chunks.
Y guarda la lección general, que vale para más cosas: cuando un modelo se comporta peor de lo que promete su ficha técnica, la primera sospecha no debería ser el modelo. Suele ser el pipeline que le está ocultando la mitad de lo que necesita ver.
Fuentes
- Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models (Günther et al., 2024), el trabajo que formalizó la técnica y la evaluó.