Refresco del índice: incremental vs full
El problema del re-ingest completo
Sección titulada «El problema del re-ingest completo»pnpm vercel:ingest borra y recrea la collection cada vez. Para el curso es perfecto: idempotente, simple, sin estado raro entre corridas.
Para producción, no.
Imaginá tu RAG en producción con 100k chunks. Editás un párrafo de un documento. Para que tu RAG vea ese cambio, ¿re-embebés los 99,999 chunks que no cambiaron?
- Tiempo: 100k embeddings × ~10ms cada uno = ~17 minutos. Tu RAG está caído todo ese tiempo (la collection se re-crea desde cero).
- Costo (si pagás API): proporcional a 100k tokens. En vez de 1 chunk × 200 tokens = 0.4¢, gastás 100k × 200 = ~$2 por edición trivial.
- Daño colateral: si el job falla a la mitad, te quedás con un índice parcial.
Solución: refresh incremental. Detectás qué docs cambiaron, borrás solo sus chunks viejos, embebés solo los nuevos.
El mecanismo: hash en el payload
Sección titulada «El mecanismo: hash en el payload»Cada vez que ingest.ts o refresh.ts insertan chunks, agregan un contentHash al payload — el SHA-256 del contenido del documento de origen:
const docHashes = new Map<string, string>();for (const doc of docs) docHashes.set(doc.id, hashDoc(doc.content));
const points = chunks.map((chunk, i) => ({ id: i, vector: embeddings[i]!, payload: { docId: chunk.docId, filename: chunk.filename, index: chunk.index, content: chunk.content, contentHash: docHashes.get(chunk.docId) ?? '', },}));Como todos los chunks del mismo doc comparten el mismo hash, refresh.ts puede leer un chunk por docId y comparar. Si el hash en disco es distinto al hash en el índice, el doc cambió.
El módulo refresh.ts
Sección titulada «El módulo refresh.ts»packages/01-vercel-ai-sdk/src/ops/refresh.ts hace 4 pasos:
1. Leer estado actual del índice
Sección titulada «1. Leer estado actual del índice»async function readIndexState(): Promise<Map<string, IndexedDoc>> { const state = new Map<string, IndexedDoc>(); let offset: string | number | undefined; while (true) { const result = await qdrant.scroll(COLLECTION, { limit: 200, offset, with_payload: true, }); for (const point of result.points) { const payload = point.payload as { docId?: string; contentHash?: string }; if (!payload?.docId) continue; const cur = state.get(payload.docId); if (cur) cur.pointIds.push(point.id); else state.set(payload.docId, { docId: payload.docId, hash: payload.contentHash ?? '', pointIds: [point.id] }); } if (!result.next_page_offset) break; offset = result.next_page_offset; } return state;}Usa qdrant.scroll(...) (paginado, no carga todo en memoria de una). Para cada chunk, agrupa por docId y guarda el hash + los pointIds.
2. Computar el diff
Sección titulada «2. Computar el diff»function computeDiff(docs: SourceDoc[], indexed: Map<string, IndexedDoc>): Diff { const toReindex: SourceDoc[] = []; const toDelete: string[] = []; let unchanged = 0; const onDisk = new Set(docs.map((d) => d.id));
for (const doc of docs) { const hash = hashDoc(doc.content); const cur = indexed.get(doc.id); if (!cur) toReindex.push(doc); // nuevo else if (cur.hash === '' || cur.hash !== hash) toReindex.push(doc); // cambió else unchanged++; // igual }
for (const docId of indexed.keys()) { if (!onDisk.has(docId)) toDelete.push(docId); // borrado }
return { toReindex, toDelete, unchanged };}Tres categorías:
- Nuevos: docId en disco pero no en el índice → embed + upsert.
- Cambiados: hash distinto → delete viejos + embed + upsert.
- Borrados: docId en índice pero no en disco → delete (sin embed).
- Sin cambios: skip.
3. Borrar selectivo
Sección titulada «3. Borrar selectivo»async function deleteDocs(docIds: string[], indexed: Map<string, IndexedDoc>): Promise<number> { const points = docIds.flatMap((id) => indexed.get(id)?.pointIds ?? []); if (points.length === 0) return 0; await qdrant.delete(COLLECTION, { points }); return points.length;}Qdrant soporta delete por ids específicos. Lo usamos para los docs cambiados/borrados — el resto del índice queda intacto.
4. Embed + upsert
Sección titulada «4. Embed + upsert»async function reindex(docs: SourceDoc[], indexed: Map<string, IndexedDoc>): Promise<number> { if (docs.length === 0) return 0;
await deleteDocs(docs.map((d) => d.id), indexed);
const chunks = chunkDocs(docs); const { embeddings } = await embedMany({ model: ollama.embeddingModel(OLLAMA_EMBED), values: chunks.map((c) => c.content), });
const docHashes = new Map<string, string>(); for (const doc of docs) docHashes.set(doc.id, hashDoc(doc.content));
const points = chunks.map((chunk, i) => ({ id: crypto.randomUUID(), vector: embeddings[i]!, payload: { docId: chunk.docId, filename: chunk.filename, index: chunk.index, content: chunk.content, contentHash: docHashes.get(chunk.docId) ?? '', }, }));
await qdrant.upsert(COLLECTION, { wait: true, points }); return points.length;}Detalles:
- UUIDs como ids: refresh genera ids nuevos para evitar colisiones con los numéricos secuenciales que usa
ingest.ts. Qdrant acepta ambos formatos sin drama. - Embed solo los nuevos: la llamada a
embedManysolo recibe los chunks de docs cambiados. Si una sola doc cambió y tiene 5 chunks, embebés 5 — no 100k.
El comando
Sección titulada «El comando»pnpm vercel:refreshOutput esperado en un caso típico (1 doc cambiado, 3 sin cambios):
Reading current corpus from disk…Reading index state from Qdrant… indexed docs: 4Diff: 1 to reindex (new + changed), 0 to delete (removed), 3 unchanged upserted 7 points (reindexed docs)7 chunks re-embebidos en lugar de los 30+ que tendría que rehacer un full reingest.
Corpus legacy: el primer refresh
Sección titulada «Corpus legacy: el primer refresh»Las collections que ya están en Qdrant antes de este nivel del curso fueron creadas por ingest.ts viejo, que NO escribía contentHash. Para esas, refresh detecta hash vacío y trata todos los docs como “stale” → primera corrida es esencialmente un full reingest.
A partir de ahí, cada chunk tiene su contentHash y refresh es incremental. Esto es por diseño: no hay paso de migración manual.
Probá la incrementalidad:
# 1. Setup limpiopnpm vercel:ingest
# 2. Refresh inmediato — todo unchangedpnpm vercel:refresh# → "Diff: 0 to reindex, 0 to delete, 4 unchanged"
# 3. Editá un archivo del corpus, ej. data/clean-architecture.mdecho "## Nuevo capítulo" >> data/clean-architecture.md
# 4. Refresh — solo ese docpnpm vercel:refresh# → "Diff: 1 to reindex, 0 to delete, 3 unchanged"# → "upserted N points (reindexed docs)"El segundo refresh tarda menos que el primero proporcionalmente al cambio.
Cuándo NO usar refresh
Sección titulada «Cuándo NO usar refresh»- Cambio del chunker (size, overlap): los chunk-ids implícitos cambian. Necesitás full reingest.
- Cambio del modelo de embeddings: los vectores no son comparables. Full reingest.
- Cambio del distance metric (Cosine → Euclid): full reingest.
Regla: cualquier cambio que afecte cómo se generan los vectores invalida el incrementalidad. Refresh sirve cuando el delta es solo en el contenido del corpus.
Lo que viene
Sección titulada «Lo que viene»Tu RAG es observable, mide costos, refresca incremental. Falta lo último: deployarlo. Con latencia razonable para usuarios fuera de tu región. Eso es edge — Cloudflare Workers para el query path.