Reparar documentos RAG sem sobrescrever: atualização correta para LLM
1. Visão geral
Em sistemas RAG (Retrieval-Augmented Generation), documentos de origem nunca ficam parados: políticas mudam, preços mudam, regulamentações mudam. Se você apenas “sobrescrever” chunks antigos com chunks novos no vector store, o sistema facilmente cai em um estado onde responde simultaneamente correto e incorreto à mesma pergunta. Este artigo explica por que sobrescrever simplesmente é um design perigoso e apresenta um processo de atualização mais seguro: preparar chunks novos, verificar, depois ativar a versão — com código Java ilustrando especificamente.
2. Problema: por que sobrescrever diretamente causa inconsistência
Suponha que sua empresa aumente o limite de reembolso de hotel de 5.000.000đ para 7.000.000đ. O documento de política é dividido em múltiplos chunks, cada chunk é embedado como vetor e armazenado no vector database. Quando a política muda, se você:
- Inserir chunk novo primeiro, deletar chunk antigo depois → existe um período de tempo onde ambos chunks coexistem e são considerados “válidos para a pergunta atual”. A busca semântica pode retornar o chunk antigo (5.000.000đ) porque a similaridade vetorial dele com a pergunta ainda é alta.
- Deletar chunk antigo primeiro, inserir chunk novo depois → existe um período de tempo onde nenhum chunk responde à pergunta sobre o limite de reembolso, o LLM é forçado a “inventar” ou responder que não há informação.
Ambas as formas têm problemas porque tratam “atualização” como uma ação única, quando na verdade são duas operações (adicionar novo + remover antigo) que não são atômicas.
// Forma ERRADA: sobrescrever implícito, sem controle de transição de estado
public void updatePolicyNaive(VectorStore store, String oldChunkId, PolicyChunk newChunk) {
store.delete(oldChunkId); // (1) há um intervalo vazio temporário
store.upsert(newChunk.id(), newChunk.embedding(), newChunk.metadata());
}
A linha (1) cria uma lacuna: enquanto delete foi executado mas upsert não terminou (ou falha no meio), a busca não encontrará nenhum chunk sobre limite de reembolso.
Nota: Em sistemas RAG rodando em produção, essa lacuna não é teoria — ela acontece de verdade toda vez que você faz deploy de um job de atualização de dados sem ter mecanismo de transação ou versionamento.
3. Quando precisa reembedar, quando não precisa
Nem toda mudança precisa recalcular o vetor. O vetor é gerado a partir do conteúdo textual do chunk. Se você apenas muda metadata (por exemplo: data de vigência, departamento aplicável) sem alterar o texto dentro do chunk, você não precisa reembedar — apenas atualize a metadata.
public record PolicyChunk(
String id,
String text, // conteúdo usado para gerar vetor
float[] embedding,
Map<String, String> metadata // NÃO está na parte textual usada para embedar
) {}
Se text muda (por exemplo “5.000.000đ” → “7.000.000đ”), o vetor antigo não reflete mais o conteúdo correto → precisa reembedar esse chunk. Mas os outros chunks no documento (por exemplo a parte sobre condições de aplicação, que não muda o valor) mantêm seu vetor — não precisa mexer.
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 e upsertMetadata em dois métodos permite que você escolha conscientemente: muda metadata sem reembedar, muda texto então reembeda. Alguns vector databases (Qdrant, Weaviate, Pinecone) suportam atualizar payload/metadata independentemente do vetor — confira a documentação do seu produto antes de escrever a lógica de atualização.
4. Design de versão e flag active-version
Em vez de corrigir o chunk no lugar, uma forma mais segura é tratar cada mudança de conteúdo como uma versão nova, com seu próprio versionId. O chunk antigo não é deletado imediatamente — apenas é marcado como não mais “ativo” para consultas de política atual.
public record PolicyVersion(
String versionId, // exemplo: "hotel-reimbursement-v2"
String policyKey, // exemplo: "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 apenas mantém um mapeamento: para cada policyKey, qual versão é considerada “vigente”. Quando busca, você filtra por metadata.versionId == activeVersion, em vez de confiar se o chunk ainda existe ou não. Assim, mudar de versão vira uma única operação de apontar para um novo endereço, rápida e quase atômica — não uma série de insert/delete espalhados em múltiplos chunks.
5. Processo de atualização seguro: preparar → verificar → ativar
O processo tem três etapas claras, evitando ambos os problemas da seção 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) {
// Etapa 1: preparar — embedar e escrever chunks novos, AINDA SEM mudar a versão ativa
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);
}
// Etapa 2: verificar — os chunks novos realmente podem ser recuperados, não estão vazios
boolean ready = newVersion.chunks().stream()
.allMatch(c -> vectorStore.findById(c.id()).isPresent());
if (!ready) {
throw new IllegalStateException("Chunks novos não estão prontos, não ativando versão nova");
}
// Etapa 3: ativar versão — operação rápida, quase atômica
registry.setActive(policyKey, newVersion.versionId());
}
}
Explicação do fluxo:
- Etapa 1 roda antes de qualquer consulta ser afetada — chunks novos existem em paralelo com chunks antigos, mas o chunk antigo continua ativo então usuários recebem respostas consistentes (5.000.000đ) durante toda a preparação.
- Etapa 2 garante que você não ativa a versão nova quando dados novos ainda não foram escritos completamente ou falharam parcialmente — evita cenários onde você “muda a bandeira mas os chunks não existem”.
- Etapa 3 apenas muda um valor no registry. Este é o único ponto de transição, portanto fácil de controlar e reverter (apenas reseta o
versionIdantigo).
Chunks antigos (v1) não são deletados imediatamente. Você pode limpá-los depois, em um job separado, fora do caminho crítico da atualização de política.
6. Manter capacidade de responder perguntas históricas
Um benefício colateral do design de versionamento: você ainda consegue responder perguntas como “Qual era o limite de reembolso de hotel no ano passado?”. Como chunk v1 não foi deletado, você apenas precisa de um filtro diferente na camada de recuperação.
public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
String activeVersion = registry.getActiveVersion(policyKey)
.orElseThrow(() -> new IllegalStateException("Nenhuma versão ativa 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 sempre filtra pela activeVersion obtida do registry — este é o único lugar que decide “qual versão está vigente”. searchHistoricalPolicy permite consultar explicitamente uma versão específica, atendendo perguntas de natureza histórica ou auditoria.
7. Teste para o comportamento de ativação de versão
Escreva testes para garantir que o processo nunca exponha um estado “meio terminado” para o 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", "Limite de 7.000.000đ", null, Map.of())),
Instant.now());
service.applyNewVersion("hotel-reimbursement", v2);
assertThat(registry.getActiveVersion("hotel-reimbursement"))
.contains("v2");
// chunk v1 ainda está na store, atendendo consultas históricas
assertThat(store.findById("chunk-v1-1")).isPresent();
}
}
Este teste afirma duas coisas: após applyNewVersion terminar, a versão ativa deve ser v2, e o chunk de v1 não deve ser perdido — você ainda consegue consultar histórico.
8. Mudar modelo de embedding: um problema maior
Tudo acima assume o modelo de embedding não muda. Se você mudar do modelo A para modelo B (por exemplo, mudar provedor, mudar número de dimensões), todos os chunks existentes precisam ser reembadados com o modelo novo — não apenas chunks com conteúdo alterado. Vetores do modelo A e modelo B não estão no mesmo espaço, comparar cosine similarity entre eles não faz 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);
}
// Após todos os chunks terem vetores novos, mude o entry point do pipeline de busca
// para que consultas também usem o newModel ao embedar a pergunta.
}
Ponto-chave: a pergunta do usuário também deve ser embedada com o modelo novo no momento da busca. Se você apenas mudar o vetor do documento mas esquecer de trocar o modelo de embedar a pergunta, resultados de busca ficarão completamente errados porque as duas partes não estão mais no mesmo espaço vetorial.
9. Erros comuns e quando não precisa complicar
- Erro comum: tratar atualização como um único
upsert(), sem separar “escrever dados novos” e “ativar” — resultando nas lacunas ou duplicatas mencionadas na seção 2. - Erro comum: colocar metadata dentro da parte textual usada para embedar (por exemplo, embutir a data de vigência na frase), forçando reembedar a cada mudança de metadata, desperdiçando recursos desnecessariamente.
- Erro comum: esquecer que chunks antigos precisam ser mantidos para consultas históricas — deletá-los imediatamente após ativar, perdendo a capacidade de responder “como era a política antes”.
Quando você NÃO precisa de todo esse processo de versionamento: se seus documentos são quase estáticos (poucas mudanças, sem necessidade de consultar histórico, sistema roda internamente com poucas dezenas de usuários), um job batch para re-indexar tudo conforme programação (por exemplo, toda noite) é mais simples e suficientemente seguro — sem registry, sem versionId complexo.
10. Conclusão
Atualizar documentos em sistemas RAG não é uma tarefa de “sobrescrever vetor antigo com vetor novo” — é gerenciar o estado de transição entre duas versões de dados sem deixar o sistema cair em lacunas ou inconsistências. Separe vetor e metadata para evitar reembedar desnecessariamente, use versionId junto com active-version registry para transições rápidas e reversíveis, e sempre mantenha dados antigos suficientemente tempo para consultas históricas. Ao mudar modelo de embedding, trate como uma migração completa, não uma atualização comum.