Ingesta: trocear con solape, incrustar y guardar
Antes de que nadie pregunte nada hay que preparar el corpus, y ahí se decide la mitad de la calidad del sistema. Trocear un documento no es partirlo cada mil caracteres: es elegir cuál será la unidad de significado que tu buscador puede devolver, con un solape que impida que la frase clave muera en la costura. Después, convertir cada trozo en un vector con Workers AI, y repartir el resultado entre tres almacenes que hacen tres trabajos distintos: Vectorize guarda los vectores, D1 el texto recuperable y R2 el original íntegro. Vemos el troceo, los embeddings por lotes y el reparto idempotente.
La ingesta es la parte aburrida del RAG y la que decide su techo. Ocurre antes de que exista ninguna pregunta, en un proceso por lotes que nadie ve, y lo que se hace mal allí no hay prompt que lo arregle después. Tres decisiones concentran casi todo el riesgo: dónde cortas el documento, con qué modelo lo conviertes en vectores, y dónde acaba viviendo cada pieza del resultado. Un chunk mal cortado es una respuesta que jamás podrá recuperarse; un modelo elegido a la ligera es un índice entero que habrá que rehacer. Esta lección construye el canal de ingesta completo y explica por qué cada decisión pesa tanto.
- Trocear documentos respetando fronteras semánticas y con solape suficiente para no romper ideas.
- Generar
embeddingspor lotes con Workers AI y entender por qué el modelo se elige una sola vez. - Repartir el resultado entre Vectorize, D1 y R2 según lo que cada uno sabe hacer.
- Diseñar una ingesta idempotente y reingestable, con identificadores deterministas.
Trocear: el chunk es la unidad de verdad
Antes de trocear hay que tener texto, y eso no siempre es gratis. Un PDF trae columnas, cabeceras que se repiten en cada página y tablas que al extraerse se convierten en una sopa de números sin encabezado; un HTML trae menús y pies de página que contaminan cada trozo con ruido idéntico. Merece la pena normalizar todo a Markdown antes de cortar nada, porque conserva la jerarquía de encabezados y las tablas legibles, y esa jerarquía es justo lo que luego usarás para cortar por donde el documento ya estaba cortado.
El chunk es lo que tu sistema puede devolver: nunca recuperará media idea ni dos documentos a la vez, solo trozos enteros de los que fabricaste. Esa es la razón por la que el troceo no es preprocesado sino diseño. Un trozo demasiado grande diluye su vector —un párrafo sobre facturación mezclado con otro sobre envíos produce un embedding que no representa bien a ninguno de los dos— y encima gasta contexto con ruido. Un trozo demasiado pequeño pierde el sujeto de la frase, la tabla se separa de su encabezado y el pronombre se queda sin antecedente.
El rango que funciona en la práctica está entre 500 y 1200 caracteres, con un solape del 10 al 20 por ciento. El solape existe por un motivo muy concreto: si la frase que responde a la pregunta cae justo en la costura entre dos trozos, sin solape queda partida en dos mitades que ninguna recupera bien. Con solape, alguna de las dos la contiene entera.
Mejor aún que contar caracteres es cortar por donde el documento ya está cortado: párrafos, encabezados, apartados. Una estructura Markdown o HTML te regala fronteras semánticas gratis, y respetarlas produce trozos que se leen como unidades.
export function trocear(texto: string, tam = 900, solape = 150): string[] {
const parrafos = texto.split(/\n{2,}/); // las fronteras que el autor ya puso
const trozos: string[] = [];
let actual = "";
for (const p of parrafos) {
if (actual.length + p.length > tam && actual.length > 0) {
trozos.push(actual.trim());
actual = actual.slice(-solape); // arrastra la cola como solape
}
actual += p + "\n\n";
}
if (actual.trim()) trozos.push(actual.trim());
return trozos;
}
Un truco barato que sube mucho la calidad: antepón a cada trozo su contexto de origen —título del documento y encabezado de la sección— antes de calcular el vector. Un fragmento que empieza por “Manual de devoluciones, sección Plazos” se recupera bastante mejor que el mismo texto huérfano, porque su embedding incorpora el tema al que pertenece.
Del texto al vector
Un embedding es un vector de números que sitúa a un texto en un espacio donde la proximidad geométrica aproxima la proximidad de significado. Workers AI expone modelos de embedding con un binding, y se les puede pasar un lote entero de trozos en una sola llamada.
const trozos = trocear(documento);
const { data } = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: trozos });
// data[i] es el vector del trozo i, con 768 dimensiones
Tres reglas que no admiten negociación. La primera: el modelo de embedding de la ingesta y el de la consulta tienen que ser exactamente el mismo, porque dos modelos distintos generan espacios incomparables y la búsqueda devolvería ruido con aire de resultado. La segunda: la dimensión del índice de Vectorize se fija al crearlo y debe coincidir con la del modelo —768 para bge-base, 1024 para bge-large, 384 para bge-small—, y cambiar de modelo significa crear un índice nuevo y reingestar el corpus entero. La tercera: la métrica de similitud, normalmente coseno, se elige también en la creación y debe ser la que el modelo espera.
Cada modelo tiene además una ventana máxima de entrada, y lo que la excede se trunca en silencio. Un trozo de cinco mil caracteres enviado a un modelo de 512 tokens produce un vector que solo representa su principio, y nada en la respuesta te avisa de ello.
Elegir modelo de embedding se parece más a elegir el motor de una base de datos que a elegir un LLM. El generador se puede sustituir mañana sin tocar nada; el modelo de embedding está grabado en cada vector del índice, y cambiarlo obliga a recalcularlo todo. Compara candidatos con tu propio corpus antes de indexar un millón de trozos, y anota en algún sitio qué modelo y qué versión produjeron cada índice.
Dónde guardar cada cosa
El error de principiante es querer guardarlo todo en el índice vectorial. Vectorize almacena vectores y una metadata pequeña, y su trabajo es responder rápido a la pregunta de qué vecinos se parecen más; no es un almacén de documentos. El reparto natural sobre Cloudflare usa tres piezas con tres papeles.
Vectorize
El vector y la metadata mínima para filtrar: identificador del documento, tenant, fecha. Devuelve identificadores y puntuaciones.
D1
El texto de cada chunk, indexado por el mismo identificador. Es lo que rehidratas tras la búsqueda para montar el prompt.
R2
El fichero original íntegro: el PDF, el adjunto, el volcado grande. La fuente de verdad para reingestar y para enlazar en la cita.
Identificadores
Deterministas y compuestos, como doc:42#c:7. Son la costura que une los tres almacenes y lo que hace idempotente la reingesta.
const registros = trozos.map((texto, i) => ({
id: `doc:${docId}#c:${i}`, // determinista: reingestar sobrescribe
values: data[i],
metadata: { docId, pos: i, tenant }, // solo lo que vayas a filtrar
}));
await env.INDICE.upsert(registros);
await env.DB.batch(
trozos.map((texto, i) =>
env.DB.prepare(
"INSERT OR REPLACE INTO chunks (id, doc_id, pos, texto) VALUES (?, ?, ?, ?)",
).bind(`doc:${docId}#c:${i}`, docId, i, texto),
),
);
flowchart TB R2[R2 guarda el fichero original] --> T[trocear con solape] T --> EMB[Workers AI calcula los embeddings] EMB --> VZ[Vectorize upsert de vectores y metadata] T --> D1[D1 guarda el texto de cada chunk] VZ -.mismo id.-> D1 style EMB fill:#a6e3a1,color:#11111b style VZ fill:#89b4fa,color:#11111b
El upsert con identificador determinista es lo que hace la ingesta repetible: volver a procesar un documento sobrescribe sus trozos en vez de duplicarlos. La metadata merece una advertencia aparte: guarda solo lo que vayas a usar para filtrar, porque cada campo ocupa espacio en el índice y solo los declarados como indexados al crearlo pueden aparecer en un filtro. El texto completo del chunk nunca va ahí; para eso está D1.
Reingestar sin romper nada
Los documentos cambian, y un índice es una fotografía que envejece. La operación que de verdad hay que diseñar no es la primera ingesta sino la enésima, y tiene una trampa que casi todo el mundo descubre en producción: cuando un documento encoge, los trozos sobrantes de la versión anterior siguen en el índice. El upsert sobrescribe del cero al doce, pero el trece al veinte de la versión antigua sobreviven y seguirán apareciendo en las búsquedas como fantasmas que citan párrafos que ya nadie escribió.
export async function reingestar(env: Env, docId: string, texto: string) {
// 1. fuera todo lo viejo, en los dos almacenes
const viejos = await env.DB.prepare("SELECT id FROM chunks WHERE doc_id = ?")
.bind(docId).all<{ id: string }>();
if (viejos.results.length > 0) {
await env.INDICE.deleteByIds(viejos.results.map((r) => r.id));
await env.DB.prepare("DELETE FROM chunks WHERE doc_id = ?").bind(docId).run();
}
// 2. y ahora la version nueva, entera
await ingestar(env, docId, texto);
}
Hay una nota de escala igual de importante. La ingesta es un trabajo por lotes, no algo que ocurra dentro de la petición de un usuario: un Worker tiene su límite de CPU y de subpeticiones, y un PDF de cuatrocientas páginas no cabe en una invocación. El patrón que aguanta es encolar un mensaje por documento en una Queue, o modelar el proceso como un Workflow con un paso por etapa, de modo que cada trozo tenga su reintento y un fallo de red a mitad del corpus no obligue a empezar de cero.
Guarda además, junto a cada documento, la huella de su contenido y el nombre del modelo con el que se incrustó. Lo primero te deja saltarte los documentos que no cambiaron, que en un corpus grande es la diferencia entre reindexar en minutos u horas. Lo segundo te dice, el día que cambies de modelo, qué parte del índice pertenece al espacio viejo y hay que rehacer.
Hay algo profundamente epistemológico en el troceo, y por eso desconcierta a quien lo trata como un detalle de implementación. Cuando fijas el tamaño de un chunk estás decretando cuál es la unidad mínima de significado que tu sistema es capaz de manejar: nada más fino podrá recuperarse jamás, y nada más grueso podrá distinguirse. Es una decisión de granularidad ontológica disfrazada de parámetro numérico, y es irreversible sin reindexar. Piensa en el compromiso que encierra. Un vector es un punto: comprime un texto entero en unos cientos de números, y esa compresión funciona bien cuando el texto habla de una sola cosa. Si el trozo mezcla tres temas, su vector aterriza en el centroide de los tres, un lugar del espacio donde no vive ninguno, y la consecuencia es que se recupera mediocremente para las tres preguntas. Si el trozo es minúsculo, su vector es nítido pero el texto que devuelve no basta para responder, y el generador tendrá que inventar el puente. Entre ambos extremos no hay óptimo universal: depende de la densidad de tu prosa, de si tus documentos son contratos con cláusulas autónomas o narraciones con hilo largo, y del tipo de preguntas que la gente hace de verdad. Por eso la literatura ha ido derivando hacia estrategias que rompen la simetría entre lo que se indexa y lo que se entrega —indexar un resumen y devolver el pasaje entero, indexar la frase y devolver la sección, indexar una pregunta hipotética generada por un LLM y devolver el párrafo que la contesta—. Todas comparten la misma intuición: lo que hace fácil encontrar un texto no es necesariamente lo que hace fácil usarlo, y no hay ninguna ley que obligue a que sean el mismo objeto. Interiorizar eso es lo que separa a quien parte documentos cada mil caracteres de quien diseña la representación de su corpus. El troceo es el momento en que decides cómo tu sistema va a percibir el mundo escrito que le entregas, y ninguna sofisticación posterior en la consulta o en el prompt puede recuperar una idea que quedó partida por la mitad.
- Trocea un documento real tuyo con solape y sin él. Busca a mano una frase clave que caiga en una costura y comprueba en qué trozos aparece en cada caso.
- Crea un índice de Vectorize con la dimensión y la métrica correctas para tu modelo, e indexa cincuenta trozos con identificadores deterministas.
- Guarda el texto en D1 con el mismo identificador y verifica que puedes recuperar cualquier trozo por su clave sin pasar por el índice.
- Reingesta el mismo documento dos veces y confirma que no aparecen duplicados. Después acórtalo, reingesta, y localiza los trozos fantasma que sobrevivieron.
- Antepón a cada trozo el título del documento y su encabezado antes de calcular el
embedding, reindexa, y compara la calidad de recuperación con la versión sin ese prefijo.