Ir al contenido

Refresco del índice: incremental vs full

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.

Cada vez que ingest.ts o refresh.ts insertan chunks, agregan un contentHash al payload — el SHA-256 del contenido del documento de origen:

ingest.ts: contentHash en payload
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ó.

packages/01-vercel-ai-sdk/src/ops/refresh.ts hace 4 pasos:

refresh.ts: readIndexState
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.

refresh.ts: computeDiff
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.
refresh.ts: deleteDocs
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.

refresh.ts: reindex
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 embedMany solo recibe los chunks de docs cambiados. Si una sola doc cambió y tiene 5 chunks, embebés 5 — no 100k.
Ventana de terminal
pnpm vercel:refresh

Output esperado en un caso típico (1 doc cambiado, 3 sin cambios):

Reading current corpus from disk…
Reading index state from Qdrant…
indexed docs: 4
Diff: 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.

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:

Ventana de terminal
# 1. Setup limpio
pnpm vercel:ingest
# 2. Refresh inmediato — todo unchanged
pnpm vercel:refresh
# → "Diff: 0 to reindex, 0 to delete, 4 unchanged"
# 3. Editá un archivo del corpus, ej. data/clean-architecture.md
echo "## Nuevo capítulo" >> data/clean-architecture.md
# 4. Refresh — solo ese doc
pnpm 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.

  • 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.

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.