A mudança estrutural que torna rollback trivial é parar de escrever no índice em produção e passar a construir um índice novo a cada build, identificado por uma versão, com o serviço de leitura apontando para uma versão através de um ponteiro. Publicar passa a ser mover o ponteiro; voltar passa a ser mover o ponteiro de volta. Essa é exatamente a ideia de release imutável aplicada a dados de recuperação, e ela transforma uma operação de horas numa operação de segundos.
O identificador da versão precisa carregar tudo que muda o significado do vetor. Na prática, o que funciona é um hash determinístico sobre a tupla que define a build: revisão do conteúdo de origem, identificador exato do modelo de embedding, dimensão, versão da estratégia de chunking e versão do esquema de metadados. Com esse identificador, duas builds com o mesmo conteúdo mas parsers diferentes são versões diferentes, o que é o comportamento correto, e o sistema ganha de graça a capacidade de recusar uma consulta cujo embedding foi gerado por um modelo diferente do índice apontado.
Publicacao com ponteiro (rollback = mover a seta)
fonte de conteudo --> build v3 --> indice kb_a1b2c3 (ativo)
build v2 --> indice kb_9f8e7d (retido, quente)
build v1 --> indice kb_44c1aa (retido, frio)
ponteiro "producao" ---------------> kb_a1b2c3
rollback --> kb_9f8e7d (segundos)
regra: consulta so e servida se
embedding_model(consulta) == embedding_model(indice apontado)// rag/index-pointer.js
// Publicacao e rollback de base de conhecimento por ponteiro.
// O indice nunca e sobrescrito: cada build cria uma colecao nova e
// publicar/voltar e uma troca atomica de ponteiro no armazenamento de estado.
import { createHash } from 'node:crypto';
// A versao precisa cobrir tudo que muda o significado do vetor.
// Trocar o parser sem trocar o modelo ainda produz um indice incompativel
// com o anterior para fins de comparacao, entao entra no hash.
export function buildIndexVersion({
contentRevision,
embeddingModel,
embeddingDimensions,
chunkingVersion,
metadataSchemaVersion,
}) {
const payload = [
contentRevision,
embeddingModel,
String(embeddingDimensions),
chunkingVersion,
metadataSchemaVersion,
].join('|');
return `kb_${createHash('sha256').update(payload).digest('hex').slice(0, 12)}`;
}
export function createIndexPointer({ store, vectorDb, metrics, logger }) {
// store: chave-valor com compare-and-set. Sem CAS, duas publicacoes
// simultaneas podem deixar o ponteiro apontando para um indice incompleto.
async function publish({ version, expectedCurrent, manifest }) {
const health = await vectorDb.describe(version);
if (!health.exists) {
throw new Error(`indice ${version} nao existe`);
}
// Guarda contra a falha classica: publicar um indice que a build
// deixou pela metade porque o job morreu no meio do upsert.
if (health.vectorCount < manifest.expectedVectorCount) {
throw new Error(
`indice ${version} incompleto: ${health.vectorCount}/${manifest.expectedVectorCount}`,
);
}
if (health.dimensions !== manifest.embeddingDimensions) {
throw new Error(`dimensao divergente em ${version}`);
}
const swapped = await store.compareAndSet('kb:pointer:production', expectedCurrent, {
version,
embeddingModel: manifest.embeddingModel,
publishedAt: new Date().toISOString(),
previousVersion: expectedCurrent?.version ?? null,
});
if (!swapped) {
throw new Error('ponteiro mudou durante a publicacao, refaca a leitura');
}
metrics.increment('kb.pointer.publish', { version });
logger.info({ version, from: expectedCurrent?.version }, 'indice publicado');
return version;
}
// Rollback nao reindexa nada: ele volta para a versao anterior registrada
// no proprio ponteiro. Se essa versao ja foi coletada, falha alto em vez
// de degradar silenciosamente para um indice qualquer.
async function rollback({ reason }) {
const current = await store.get('kb:pointer:production');
const target = current?.previousVersion;
if (!target) {
throw new Error('sem versao anterior registrada para rollback');
}
const health = await vectorDb.describe(target);
if (!health.exists) {
throw new Error(`versao anterior ${target} nao esta mais retida`);
}
await store.compareAndSet('kb:pointer:production', current, {
version: target,
embeddingModel: health.embeddingModel,
publishedAt: new Date().toISOString(),
previousVersion: current.version,
rolledBackFrom: current.version,
reason,
});
metrics.increment('kb.pointer.rollback', { from: current.version, to: target });
logger.warn({ from: current.version, to: target, reason }, 'rollback de indice');
return target;
}
// A leitura carrega o modelo junto com a versao: quem gera o embedding da
// consulta precisa usar exatamente o mesmo modelo do indice apontado.
async function resolveForQuery() {
const pointer = await store.get('kb:pointer:production');
if (!pointer) throw new Error('ponteiro de producao ausente');
return pointer;
}
return { publish, rollback, resolveForQuery };
}
Dois detalhes desse código merecem atenção porque são os que separam um mecanismo que funciona de um que dá a impressão de funcionar. O primeiro é a verificação de contagem de vetores antes de publicar: o modo de falha mais comum de um pipeline de indexação não é gerar vetores errados, é morrer no meio e deixar um índice com setenta por cento do material, o que produz um agente que responde bem para alguns assuntos e alucina para outros. O segundo é o compare-and-set: sem ele, uma publicação manual disparada durante o job noturno pode deixar o ponteiro apontando para um índice que já foi substituído, e o rollback vai levar o sistema para um lugar que ninguém previu.