El fallo que no es una alucinación
La política de devoluciones cambió el 1 de julio. La versión vieja se archivó ese mismo día. Y el asistente interno sigue respondiendo con ella en septiembre: párrafo literal, tono seguro, enlace al documento archivado.
Eso no es una alucinación. El retriever hizo su trabajo sin un solo error: buscó, encontró y devolvió lo que había en el índice. El modelo tampoco se inventó nada. El fallo está una capa más abajo, en el sitio donde casi nunca se mira: el índice es una foto y el corpus es una película.
Y es un fallo peor que alucinar, porque viene con cita. Un sistema que cita sus fuentes y las cita mal es más convincente que uno que se inventa el dato.
Cuatro formas de envejecer
“El índice está desactualizado” mete en el mismo saco cuatro problemas con soluciones distintas. Conviene separarlos antes de tocar nada.
| Cambio en la fuente | Síntoma si no se propaga | Gravedad |
|---|---|---|
| Documento nuevo | El sistema dice que no sabe algo que sí está documentado | Baja: se nota y se reporta |
| Documento editado | Dos versiones conviven en el top-k y el modelo mezcla | Alta: contradicción invisible |
| Documento borrado | Cita fantasma con enlace roto | Alta: respuesta segura y falsa |
| Permisos cambiados | Alguien recupera lo que ya no debería ver | Crítica |
El primero se descubre solo: alguien se queja. Los otros tres no generan queja, generan confianza mal puesta. Por eso las prioridades del backlog suelen estar justo al revés de esta tabla.
El de permisos es harina de otro costal y merece su propio tratamiento, porque la respuesta correcta casi nunca es reindexar: es filtrar en consulta.
Antes de complicarlo: reconstruye entero
Hay una solución que funciona, que cabe en 20 líneas y que nadie escribe en artículos porque no luce: borrar la colección y reindexar el corpus completo cada noche.
Es la respuesta correcta más veces de lo que parece. Si tu corpus son 5.000 documentos, si reembeberlo entero cuesta unos pocos euros y si un retraso de 24 horas es aceptable para tu caso de uso, reconstruye entero y deja de leer aquí. Resuelve altas, ediciones y borrados de golpe, no tiene estado que se corrompa y no hay nada que depurar a las tres de la mañana.
Deja de servir cuando se cruza alguno de estos tres umbrales:
- El corpus no cabe en la ventana de proceso (varios cientos de miles de fragmentos y embeddings que tardan horas).
- El negocio necesita propagación en minutos, no en un día.
- La factura de reembeber lo que no ha cambiado empieza a doler, y en un corpus típico lo que no ha cambiado es el 99 %.
A partir de ahí toca sincronización incremental. Y la sincronización incremental tiene una trampa concreta.
El manifiesto de hashes
La pieza que hace incremental un pipeline no es el pipeline: es un manifiesto
que sabe qué versión de cada documento está indexada. Sin él, “lo que ha
cambiado” es una conjetura basada en fechas de modificación, y las fechas de
modificación mienten en cuanto hay un rsync o una migración de por medio.
import hashlib
import json
from pathlib import Path
MANIFIESTO = Path("indice_manifiesto.json")
def huella(texto: str) -> str:
# Hash del contenido normalizado, no del fichero: un cambio de saltos de
# línea o de metadatos de exportación no debe disparar un reembebido.
normalizado = " ".join(texto.split())
return hashlib.sha256(normalizado.encode("utf-8")).hexdigest()[:16]
Plan = dict[str, list[str]]
def planificar(documentos: dict[str, str]) -> tuple[Plan, dict[str, str]]:
"""documentos: {doc_id: texto}. Devuelve el plan de cambios y el manifiesto nuevo."""
previo = json.loads(MANIFIESTO.read_text()) if MANIFIESTO.exists() else {}
actual = {doc_id: huella(texto) for doc_id, texto in documentos.items()}
nuevos = [d for d in actual if d not in previo]
editados = [d for d in actual if d in previo and actual[d] != previo[d]]
# La diferencia de conjuntos es lo que detecta los borrados. Si tu pipeline
# solo itera sobre los ficheros que existen, esta línea no la escribes
# nunca y los borrados no se propagan jamás.
borrados = [d for d in previo if d not in actual]
return {"nuevos": nuevos, "editados": editados, "borrados": borrados}, actual
def sincronizar(documentos, coleccion):
plan, actual = planificar(documentos)
for doc_id in plan["borrados"] + plan["editados"]:
# Borrar por doc_id, no por chunk_id. Un documento editado que pasa de
# 12 fragmentos a 9 deja tres huérfanos si solo sobrescribes por
# posición, y esos tres siguen siendo recuperables.
coleccion.delete(where={"doc_id": doc_id})
for doc_id in plan["nuevos"] + plan["editados"]:
coleccion.upsert(fragmentar_y_embeber(documentos[doc_id], doc_id))
# El manifiesto se escribe al final, y solo si todo lo anterior fue bien.
# Escribirlo antes convierte un fallo de red en una desincronización
# silenciosa que nadie va a detectar.
MANIFIESTO.write_text(json.dumps(actual, ensure_ascii=False))
return plan
Los dos comentarios largos del código son los dos fallos que he visto en producción más de una vez. El de los fragmentos huérfanos es especialmente sucio: el documento está actualizado, la respuesta parece correcta la mayoría de las veces y de vez en cuando aparece un párrafo que ya no existe en ninguna parte.
Los borrados necesitan una decisión, no un delete
Borrar del índice vectorial tiene un coste operativo que conviene conocer antes de elegir. En HNSW, el borrado real implica reconstruir parte del grafo, así que la mayoría de motores marcan el vector como muerto y lo limpian después. Hasta esa limpieza, el vector sigue ocupando memoria y sigue participando en la búsqueda aunque no se devuelva.
Eso deja dos estrategias razonables:
Borrado lógico con filtro en consulta. Marcas vigente: false y añades el
filtro a todas las consultas. Ventaja: es instantáneo, reversible y te deja
responder “esto estuvo vigente hasta julio”, que a veces es justo lo que quiere
el usuario. Inconveniente: si a alguien se le olvida el filtro en un endpoint, la
protección desaparece sin avisar. El filtro va en la capa de repositorio, nunca
en cada llamada.
Borrado físico con barrido. Ejecutas el delete y programas la compactación.
Más simple de razonar, pero pierdes el histórico y dependes de que el barrido de
huérfanos corra de verdad. Ponlo en el mismo cron que la sincronización, no en
uno aparte que nadie vigila.
En documentación corporativa con requisitos de auditoría, el lógico gana casi siempre. En un corpus de producto que cambia rápido y no se audita, el físico ahorra disgustos.
Medir la frescura con dos números
Sin métrica, esto se degrada en silencio hasta que alguien se queja en una reunión. Dos indicadores bastan, y los dos son baratos:
Deriva de recuento. Documentos distintos en la fuente menos documentos distintos en el índice. El valor esperado es 0 y cualquier otra cosa es una alerta, no un aviso. Es una consulta de agregación por minuto y detecta el 90 % de los desajustes.
Retraso de propagación p95. Segundos entre el cambio en la fuente y el momento en que el fragmento nuevo es recuperable. Se mide con un canario: un documento sintético con una marca temporal que se actualiza cada hora y una consulta programada que pide esa marca. Si el canario devuelve una marca de hace seis horas, el pipeline lleva seis horas roto aunque no haya fallado ninguna tarea.
El canario es la pieza que más veces me ha avisado antes que un usuario. Cuesta media tarde y no necesita infraestructura nueva.
Por dónde empezar
Si tienes un RAG en producción y no sabes responder “¿cuánto tarda un cambio en llegar al índice?”, el orden es este. Primero cuenta: documentos en la fuente frente a documentos en el índice, hoy mismo, a mano. Ese número suele ser la sorpresa. Segundo, comprueba si tu pipeline tiene la línea que detecta borrados por diferencia de conjuntos; si no la tiene, tu índice solo ha crecido desde el día uno. Tercero, monta el canario.
El reindexado completo nocturno sigue siendo una respuesta válida y a menudo la mejor. Lo que no es válido es no tener respuesta, porque el modo de fallo por defecto de un índice desatendido no es dejar de contestar: es contestar con seguridad lo que era verdad en marzo.