CreaRack-SL

Handler MCP bib_index_chunks — Indexación determinista de chunks con poda de generaciones

Descripción

Componente: Handler MCP bib_index_chunks del módulo functions/api/mcp/handlers/archivo.ts.

Responsabilidad: Indexar chunks de documentos en D1 (tabla bib_chunks) y Vectorize (índice de embeddings), garantizando determinismo de índices y limpieza de generaciones obsoletas. Consumido por el pipeline de ingesta del Bibliotecario (bib_ingest_claude_method.mjs).


Problema y fixes (commit@568494d)

1. Chunk indices no deterministas (footgun s200)

Síntoma: Cambios en la forma del corpus (nuevo doc, eliminación de doc, reordenamiento de lotes) desplazaban la posición relativa de chunks dentro del lote compartido. Sin forma de identificar un chunk por su posición absoluta en su documento, los índices se volvían frágiles.

Impacto: Nodos del Atlas acumulaban 19-25 chunks huérfanos (del 5 de junio, nunca eliminados) que rankeaban en bib_ask, degradando la búsqueda.

Fix: Nuevo parámetro chunk_index?: number en ChunkInput. Representa la posición del chunk dentro de su documento específico (0..N-1), no su posición en el lote. El indexador (Phase B de bib_ingest_claude_method) maneja este índice:

interface ChunkInput {
  node_id: number;
  chunk_index?: number;  // 0..N-1 POR DOC. Sin el, fallback a posición de lote (legacy).
  title: string;
  content: string;
  source_path?: string;
  source_type: string;
  metadata?: Record<string, unknown>;
}

La lógica de upsert usa (node_id, chunk_index) como clave única:

const chunkIndex = typeof chunk.chunk_index === 'number' ? chunk.chunk_index : i + j;
const existing = await db
  .prepare(`SELECT id, content_hash FROM bib_chunks WHERE node_id = ? AND chunk_index = ?`)
  .bind(chunk.node_id, chunkIndex)
  .first();

Cambio previo: upsert por (node_id, i + j) donde i + j era posición en el lote → frágil.

2. Acumulación de generaciones viejas

Síntoma: Sin mecanismo de limpieza, chunks obsoletos persistían en D1 y Vectorize. Ejemplo: si un doc tenía 10 chunks en la generación anterior y ahora tiene 5, los índices 5–9 seguían existiendo, sumando ruido al ranking.

Fix: Nuevo parámetro prune (array) que viaja en el último lote de indexación:

interface PruneInput {
  node_id: number;
  keep_count: number;  // Borra chunks con chunk_index >= keep_count
}

Lógica de poda (batch para eficiencia):

// Selecciona chunks huérfanos (chunk_index >= keep_count) por nodo
const selects = pruneList.map((p) =>
  db.prepare(`SELECT id FROM bib_chunks WHERE node_id = ? AND chunk_index >= ?`)
    .bind(p.node_id, p.keep_count)
);
const selected = await db.batch(selects);
const staleIds = selected.flatMap(r => r.results || []).map(row => row.id);

// Borra de Vectorize (lotes de 500)
if (env.VECTORIZE) {
  for (let v = 0; v < staleIds.length; v += 500) {
    await env.VECTORIZE.deleteByIds(staleIds.slice(v, v + 500).map(String));
  }
}

// Borra de D1
const deletes = pruneList.map((p) =>
  db.prepare(`DELETE FROM bib_chunks WHERE node_id = ? AND chunk_index >= ?`).bind(p.node_id, p.keep_count)
);
await db.batch(deletes);

Retorna chunks_pruned (número de filas borradas).

3. Nodos gemelos por path no normalizado

Síntoma: El prefijo public/ del repo (raíz de algunos indexadores) no se quitaba en bib_register_doc ni bib_index_docs. Resultado: 12 nodos gemelos (public/supercontext/atlas-arquitectura/* + supercontext/atlas-arquitectura/* ambos activos).

Fix: Normalización en ambos handlers:

// bib_register_doc
const filePath = (args.file_path as string | undefined)?.replace(/^public\//, '');

// bib_index_docs (Phase A)
const fp = (docData.file_path as string).replace(/^public\//, '');

Resultado: ruta canónica única por doc.


Interfaces públicas

Input del handler

{
  chunks: [
    {
      node_id: number;
      chunk_index?: number;          // OBLIGATORIO si se indexa con poda
      title: string;
      content: string;
      source_path?: string;
      source_type: string;
      metadata?: Record<string, unknown>;
    },
    ...
  ],
  prune?: [                          // Viaja en el ÚLTIMO lote
    {
      node_id: number;
      keep_count: number;
    },
    ...
  ]
}

Output del handler

{
  chunks_created: number;
  chunks_updated: number;
  chunks_skipped: number;
  chunks_pruned: number;             // Nueva métrica
  errors?: string[];
}

Integración con Phase B del Bibliotecario

El script bib_ingest_claude_method.mjs (Phase B: “Preparando chunks”) genera estos inputs:

// Phase B: asignar chunk_index determinista POR DOC
const allChunks = [];
const pruneList = [];
for (const doc of docs) {
  const nodeId = nodeIdMap.get(doc.relPath);
  const rawChunks = docChunkMap.get(doc.relPath) || [];
  
  rawChunks.forEach((c, idx) => {
    allChunks.push({ ...c, node_id: nodeId, chunk_index: idx }); // idx = 0, 1, 2...
  });
  pruneList.push({ node_id: nodeId, keep_count: rawChunks.length });
}

// Indexación en lotes de 25 chunks (límite bge-m3)
// El pase final incluye pruneList
await indexChunksBatch(mcpUrl, token, allChunks, pruneList);

Consideraciones operacionales

  1. Backward compatibility: Si un cliente viejo no manda chunk_index, el handler fallback a la posición de lote (i + j). Pero se pierde determinismo → NO RECOMENDADO para producción.

  2. Atomicidad de poda: La poda viaja con el último lote. Si hay error en poda, se registra en errors[] pero no aborta el upsert. Vectorize borra antes que D1 para permitir retry limpio.

  3. Escalabilidad: El corpus del reindex manda ~150 entradas de poda en una sola llamada. db.batch() agrupa selects en 1 subrequest, deletes en 1 subrequest → eficiente.

  4. Métrica: chunks_pruned permite monitorear acumulación histórica y validar que la limpieza funciona.


Historial de cambios

FechaCambioImpacto
2026-07-05Fix commit@568494dDeterminismo de índices + poda de generaciones + normalización de paths

Véase también

  • [[entity—biblioteca—service—bib-ingest-claude-method]]
  • [[entity—biblioteca—handler—bib-ask]]
  • [[entity—functions—table—bib-chunks]]
  • [[entity—biblioteca—tool—bib-index-chunks]]
  • [[concept—saas—determinismo]]