Actualizar documentos RAG sin sobrescribir: forma correcta para LLM
1. Descripción general
En un sistema RAG (Retrieval-Augmented Generation), los documentos fuente nunca permanecen quietos: las políticas cambian, los precios cambian, las regulaciones cambian. Si simplemente “sobrescribes” chunks antiguos con nuevos chunks en el vector store, el sistema fácilmente puede terminar respondiendo correctamente e incorrectamente la misma pregunta. Este artículo explica por qué la sobrescritura simple es un diseño peligroso, y presenta un proceso de actualización más seguro: preparar nuevos chunks, verificar, y luego cambiar a la versión activa — con código Java de ejemplo concreto.
2. El problema: por qué la sobrescritura directa causa conflictos
Supón que tu empresa aumenta el límite de reembolso de hotel de 5.000.000 VND a 7.000.000 VND. El documento de política se divide en múltiples chunks, cada chunk se incrusta como un vector y se almacena en la base de datos de vectores. Cuando la política cambia, si:
- Insertar el nuevo chunk primero, eliminar el chunk antiguo después → hay un período de tiempo donde ambos chunks coexisten y ambos se consideran “válidos para la pregunta actual”. La búsqueda semántica puede devolver el chunk antiguo (5.000.000 VND) porque la similitud vectorial con la pregunta sigue siendo alta.
- Eliminar el chunk antiguo primero, insertar el nuevo chunk después → hay un período de tiempo donde ningún chunk puede responder la pregunta sobre el límite, el LLM se ve obligado a “inventar” o responder que no hay información.
Ambas formas tienen problemas porque consideran la “actualización” como una acción singular, cuando en realidad es dos operaciones (añadir + eliminar) que no son atómicas.
// Forma INCORRECTA: sobrescritura implícita, sin control del estado de transición
public void updatePolicyNaive(VectorStore store, String oldChunkId, PolicyChunk newChunk) {
store.delete(oldChunkId); // (1) hay un vacío temporal
store.upsert(newChunk.id(), newChunk.embedding(), newChunk.metadata());
}
La línea (1) crea un vacío: mientras que delete se ha ejecutado pero upsert no está completo (o hay un error en el camino), la búsqueda no encontrará ningún chunk sobre el límite de reembolso.
Nota: Con un sistema RAG en producción, este vacío no es teoría — sucede realmente cada vez que despliegas un trabajo de actualización de datos sin un mecanismo de transacción o versionado.
3. Cuándo necesitas re-incrustar, cuándo no
No todo cambio requiere recalcular el vector. Los vectores se generan a partir del contenido de texto del chunk. Si solo cambias los metadatos (por ejemplo: fecha de vigencia, departamento aplicable) pero no cambias el texto del chunk, no necesitas re-incrustar — solo necesitas actualizar los metadatos.
public record PolicyChunk(
String id,
String text, // contenido utilizado para generar el vector
float[] embedding,
Map<String, String> metadata // NO está incluido en el texto utilizado para incrustar
) {}
Si text cambia (por ejemplo “5.000.000 VND” → “7.000.000 VND”), el vector antiguo ya no refleja el contenido correctamente → debes re-incrustar ese chunk. Pero los otros chunks en el documento (por ejemplo la sección sobre condiciones de aplicabilidad, que no cambian) mantienen su vector — no necesita ser tocado.
public interface VectorStore {
void upsertVector(String chunkId, float[] embedding);
void upsertMetadata(String chunkId, Map<String, String> metadata);
Optional<PolicyChunk> findById(String chunkId);
void delete(String chunkId);
}
Separar upsertVector y upsertMetadata en dos métodos distintos te permite elegir activamente: cambiar metadatos sin re-incrustar, cambiar texto y entonces re-incrustar. Algunas bases de datos vectoriales (Qdrant, Weaviate, Pinecone) soportan actualizar payload/metadatos independientemente del vector — verifica la documentación del producto que estés usando antes de escribir la lógica de actualización.
4. Diseño de versiones y bandera de versión activa
En lugar de editar el chunk en su lugar, una forma más segura es tratar cada cambio de contenido como una nueva versión, con su propio versionId. El chunk antiguo no se elimina inmediatamente — simplemente se marca como no “activo” para las consultas de política actuales.
public record PolicyVersion(
String versionId, // ejemplo: "hotel-reimbursement-v2"
String policyKey, // ejemplo: "hotel-reimbursement"
List<PolicyChunk> chunks,
Instant effectiveFrom
) {}
public class ActiveVersionRegistry {
private final Map<String, String> activeVersionByPolicyKey = new ConcurrentHashMap<>();
public void setActive(String policyKey, String versionId) {
activeVersionByPolicyKey.put(policyKey, versionId);
}
public Optional<String> getActiveVersion(String policyKey) {
return Optional.ofNullable(activeVersionByPolicyKey.get(policyKey));
}
}
ActiveVersionRegistry solo mantiene un mapeo: para cada policyKey, qué versión se considera “activa”. Cuando buscas, filtras por metadata.versionId == activeVersion, en lugar de basarse en si el chunk aún existe. Gracias a esto, cambiar de versión se convierte en una operación de cambiar un puntero, rápida y casi atómica — no una serie de operaciones insert/delete dispersas en múltiples chunks.
5. Proceso de actualización segura: preparar → verificar → cambiar activo
El proceso consta de tres pasos claros, evitando ambos errores de la sección 2.
@Service
public class PolicyUpdateService {
private final VectorStore vectorStore;
private final EmbeddingClient embeddingClient;
private final ActiveVersionRegistry registry;
public PolicyUpdateService(VectorStore vectorStore,
EmbeddingClient embeddingClient,
ActiveVersionRegistry registry) {
this.vectorStore = vectorStore;
this.embeddingClient = embeddingClient;
this.registry = registry;
}
public void applyNewVersion(String policyKey, PolicyVersion newVersion) {
// Paso 1: preparar — incrustar y escribir nuevos chunks, SIN cambiar aún la versión activa
for (PolicyChunk chunk : newVersion.chunks()) {
float[] vector = embeddingClient.embed(chunk.text());
Map<String, String> metadata = new HashMap<>(chunk.metadata());
metadata.put("versionId", newVersion.versionId());
metadata.put("policyKey", policyKey);
vectorStore.upsertVector(chunk.id(), vector);
vectorStore.upsertMetadata(chunk.id(), metadata);
}
// Paso 2: verificar — los nuevos chunks realmente están listos para leer, no vacíos
boolean ready = newVersion.chunks().stream()
.allMatch(c -> vectorStore.findById(c.id()).isPresent());
if (!ready) {
throw new IllegalStateException("Los nuevos chunks no están listos, no cambiar versión activa");
}
// Paso 3: cambiar versión activa — operación rápida, casi atómica
registry.setActive(policyKey, newVersion.versionId());
}
}
Explicación del flujo:
- Paso 1 se ejecuta antes de que ninguna consulta se vea afectada — los nuevos chunks existen en paralelo con los antiguos, pero los chunks antiguos siguen siendo activos, por lo que los usuarios seguirán recibiendo respuestas consistentes (5.000.000 VND) durante todo el proceso de preparación.
- Paso 2 asegura que no cambies la versión activa si los datos nuevos aún no se han escrito por completo o hay un error parcial — evita la situación “cambiar bandera pero los chunks aún no existen”.
- Paso 3 solo cambia un valor en el registro. Este es el único punto de conmutación, por lo que es fácil de controlar y fácil de revertir (solo necesitas volver a establecer el
versionIdanterior).
El chunk antiguo (v1) no se elimina inmediatamente. Puedes limpiarlo después, en un trabajo separado, fuera del camino crítico de la actualización de políticas.
6. Mantener la capacidad de responder preguntas históricas
Un beneficio secundario del diseño de versionado: aún puedes responder preguntas como “¿Cuál era el límite de reembolso del hotel el año pasado?”. Porque el chunk v1 aún no se ha eliminado, solo necesitas un filtro diferente en la capa de recuperación.
public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
String activeVersion = registry.getActiveVersion(policyKey)
.orElseThrow(() -> new IllegalStateException("No hay versión activa para " + policyKey));
return vectorStore.searchSimilar(queryVector, Map.of(
"policyKey", policyKey,
"versionId", activeVersion
));
}
public List<PolicyChunk> searchHistoricalPolicy(String policyKey, String versionId, float[] queryVector) {
return vectorStore.searchSimilar(queryVector, Map.of(
"policyKey", policyKey,
"versionId", versionId
));
}
searchCurrentPolicy siempre filtra según el activeVersion obtenido del registro — este es el único lugar que decide “qué versión está activa actualmente”. searchHistoricalPolicy permite consultar explícitamente una versión específica, sirviendo preguntas de naturaleza histórica o de auditoría.
7. Pruebas para el comportamiento de cambio de versión activa
Escribe pruebas para asegurar que el proceso nunca expone un estado “a mitad de camino” al exterior.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
class PolicyUpdateServiceTest {
@Test
void shouldKeepOldVersionActiveUntilNewVersionVerified() {
InMemoryVectorStore store = new InMemoryVectorStore();
ActiveVersionRegistry registry = new ActiveVersionRegistry();
registry.setActive("hotel-reimbursement", "v1");
PolicyUpdateService service = new PolicyUpdateService(
store, text -> new float[]{0.1f, 0.2f}, registry);
PolicyVersion v2 = new PolicyVersion(
"v2", "hotel-reimbursement",
List.of(new PolicyChunk("chunk-v2-1", "Límite 7.000.000 VND", null, Map.of())),
Instant.now());
service.applyNewVersion("hotel-reimbursement", v2);
assertThat(registry.getActiveVersion("hotel-reimbursement"))
.contains("v2");
// chunk v1 aún está en el store, sirviendo consultas históricas
assertThat(store.findById("chunk-v1-1")).isPresent();
}
}
Esta prueba afirma dos cosas: después de que applyNewVersion se complete, la versión activa debe ser v2, y el chunk de v1 no debe perderse — aún puedes consultar el historial.
8. Cambiar modelo de incrustación: un problema mayor
Todo lo anterior asume que el modelo de incrustación no cambia. Si cambias del modelo A al modelo B (por ejemplo, cambiar proveedor, cambiar dimensión del vector), todos los chunks existentes necesitan ser incrustados nuevamente con el nuevo modelo — no solo los chunks cuyo contenido fue editado. Los vectores del modelo A y del modelo B no están en el mismo espacio, la comparación de similitud coseno entre ellos no tiene sentido.
public void migrateEmbeddingModel(EmbeddingClient newModel, List<PolicyChunk> allChunks) {
for (PolicyChunk chunk : allChunks) {
float[] newVector = newModel.embed(chunk.text());
vectorStore.upsertVector(chunk.id() + ":model-v2", newVector);
}
// Después de que todos los chunks tengan vectores nuevos, cambia el punto de entrada del query pipeline
// para que las consultas también usen newModel cuando incrusten la pregunta.
}
Nota fundamental: la consulta del usuario también debe ser incrustada con el modelo nuevo correcto en el momento de la búsqueda. Si solo cambias el vector del documento pero olvidas cambiar el modelo de incrustación de la pregunta, los resultados de búsqueda serán completamente incorrectos porque ambos lados ya no están en el mismo espacio vectorial.
9. Errores comunes y cuándo no necesitas complejidad
- Error común: tratar la actualización como un
upsert()único, sin separar “escribir datos nuevos” y “cambiar activo” — causando los vacíos o duplicados mencionados en la sección 2. - Error común: dejar que los metadatos estén incluidos en el texto utilizado para incrustar (por ejemplo, incrustar la fecha de vigencia en la oración), lo que obliga a cada cambio de metadatos a re-incrustar, desperdiciando recursos innecesariamente.
- Error común: olvidar que los chunks antiguos deben retenerse para consultas históricas — eliminarlos inmediatamente después de cambiar activo, perdiendo la capacidad de responder “¿cómo era la política antes?”.
Cuándo NO necesitas todo este proceso de versionado: si tus documentos son casi estáticos (pocos cambios, sin requisitos de consultas históricas, el sistema solo se ejecuta internamente con decenas de usuarios), un trabajo batch de re-indexación completa según horario (por ejemplo cada noche) es más simple y suficientemente seguro — sin necesidad de registro, sin necesidad de versionId complejo.
10. Conclusión
Actualizar documentos en un sistema RAG no es el problema de “sobrescribir vectores antiguos con vectores nuevos” — es el problema de gestionar el estado de transición entre dos versiones de datos sin dejar que el sistema caiga en vacíos o contradicciones. Separa vectores de metadatos para evitar re-incrustaciones innecesarias, usa versionId junto con el registro de versión activa para cambios rápidos y reversibles, y siempre mantén los datos antiguos el tiempo suficiente para servir consultas históricas. Cuando cambies todo el modelo de incrustación, considéralo una migración completa, no una actualización normal.