Blog

Migrar de embeddings sem reindexar tudo de uma vez

O modelo de embedding novo saiu, é mais barato, tem mais dimensões e pontua melhor nos benchmarks públicos. Trocar parece uma linha de configuração, e é aí que a migração vira incidente: vetor gerado por um modelo não é comparável com vetor gerado por outro. O espaço é diferente, o cosseno entre um vetor novo e um antigo não significa nada, e no instante em que a sua aplicação passa a embutir a pergunta com o modelo novo contra um índice que ainda tem os vetores antigos, o retrieval não degrada aos poucos: ele vira ruído. A saída óbvia é reindexar tudo antes de trocar, mas reindexar um acervo de milhões de chunks é uma janela longa de custo, throughput e rate limit do provedor, e o acervo não fica parado esperando: documentos entram e mudam enquanto o backfill roda. Este artigo trata da migração incremental: por que os dois espaços não se misturam, como versionar o vetor para os dois conviverem, como fazer o backfill retomável sem duplicar custo, como escrever nas duas versões enquanto o índice ainda está pela metade, como decidir a virada por paridade medida em vez de por sensação e como voltar atrás sem reindexar de novo.

2026-08-03 / IA Aplicada / 14 min

01

Dois espaços vetoriais não se misturam

A primeira coisa a internalizar é que um embedding não é uma propriedade do texto, é uma função do par texto e modelo. Cada modelo aprende uma geometria própria: a direção que codifica "prazo de reembolso" no modelo antigo pode ser uma direção sem significado nenhum no modelo novo. Quando você calcula o cosseno entre a pergunta embutida pelo modelo B e um chunk embutido pelo modelo A, a conta roda, retorna um número entre menos um e um, e esse número não tem relação com similaridade semântica. Não existe erro, exceção nem log: o índice devolve os k vizinhos mais próximos de acordo com uma métrica que perdeu o sentido, e o bot passa a responder com trechos aleatoriamente plausíveis. É o pior tipo de falha, a que não aparece em nenhum painel de erro.

Isso vale mesmo quando os dois modelos têm a mesma dimensionalidade, e vale entre versões do mesmo provedor. Dimensão igual só significa que a operação aritmética é possível, não que ela é significativa. Também não existe matriz de tradução confiável entre espaços de propósito geral: até dá para aprender uma projeção aproximada com pares alinhados, mas a perda de qualidade costuma ser maior do que o custo de reindexar direito. A consequência prática é dura e simples: durante toda a migração, a consulta precisa ser embutida com exatamente o mesmo modelo que gerou os vetores que ela vai comparar. Não há meio termo, e é essa regra que dita todo o desenho a seguir.

CombinaçãoA conta roda?O resultado tem sentido?Consequência
Consulta A contra índice ASimSimComportamento normal, é a linha de base
Consulta B contra índice BSimSimEstado final desejado da migração
Consulta B contra índice A, mesma dimensãoSimNãoRetrieval vira ruído sem levantar erro algum
Consulta B contra índice A, dimensão diferenteNãoNão aplicávelErro explícito do banco, falha barulhenta e portanto menos perigosa
Índice misto com vetores de A e B juntosSimParcialmenteO pior caso, resultado bom e ruim intercalado sem sinal de qual é qual

02

Versionar o vetor para os dois conviverem

Se os espaços não se misturam, o índice precisa saber a qual espaço cada vetor pertence. A modelagem que resolve isso é tratar a versão do embedding como parte da chave, e não como um metadado informativo. A unidade deixa de ser "o vetor do chunk X" e passa a ser "o vetor do chunk X na versão v2", com o identificador da versão derivado do nome do modelo, da dimensão e de qualquer parâmetro que altere o resultado, como normalização ou o prefixo de instrução que alguns modelos exigem. Duas linhas do mesmo chunk em versões diferentes coexistem sem se atrapalhar, e toda busca passa a filtrar obrigatoriamente por uma versão.

Onde esse filtro vive é uma decisão de infraestrutura, não de semântica. Em banco vetorial com filtro por metadado, a versão é uma coluna indexada e a busca carrega o predicado. Em bancos onde o filtro custa caro ou o índice aproximado se degrada com predicados, a alternativa mais limpa é uma coleção por versão, o que também simplifica o descarte: promover é apontar a leitura para a coleção nova, e limpar é derrubar a antiga inteira. O que não funciona é guardar a versão num campo solto sem obrigar o filtro, porque basta uma consulta esquecer o predicado uma vez para o índice misto entregar o pior caso da tabela anterior. A defesa barata é fazer a camada de acesso exigir a versão como argumento e nunca ter um valor padrão implícito.

// embeddings/version.js
// A versao do embedding e derivada de tudo que muda o vetor resultante.
// Se algum desses campos mudar, o vetor gerado deixa de ser comparavel
// com os anteriores, entao a versao precisa mudar junto.

import { createHash } from 'node:crypto';

export function embeddingVersion(config) {
  const canonical = JSON.stringify({
    model: config.model,
    dimensions: config.dimensions,
    normalized: Boolean(config.normalized),
    // Alguns modelos exigem prefixo distinto para consulta e documento.
    queryPrefix: config.queryPrefix ?? '',
    documentPrefix: config.documentPrefix ?? '',
  });

  const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 8);
  return `${config.model}@${digest}`;
}

// A camada de acesso nunca aceita busca sem versao explicita.
// Sem esse guarda, uma unica consulta esquecida devolve o indice misto.
export async function search(store, { queryVector, version, topK = 8 }) {
  if (!version) {
    throw new Error('search requer version explicita do embedding');
  }

  return store.query({
    vector: queryVector,
    topK,
    filter: { embedding_version: version },
  });
}

03

Backfill retomável em vez de reindexação monolítica

Com as duas versões podendo coexistir, o backfill deixa de ser um evento e vira um processo em segundo plano que pode parar e continuar. A diferença entre um script que roda uma vez e um backfill de verdade está em três propriedades. A primeira é o checkpoint durável: o progresso precisa estar gravado fora do processo, não numa variável de laço, porque o job vai cair no meio, o pod vai ser reciclado no deploy e o rate limit do provedor vai forçar uma pausa longa. A segunda é a idempotência da escrita: reprocessar um lote já feito deve sobrescrever a mesma linha, nunca criar uma segunda cópia, porque o backfill sempre reprocessa a fronteira do último checkpoint. A terceira é o controle de vazão, já que o gargalo real quase nunca é o seu banco, é a cota de tokens do provedor de embedding.

A ordem de processamento tem impacto direto no valor entregue por hora de backfill. Processar por identificador crescente é simples e previsível, mas trata igualmente o documento que responde metade das consultas e aquele que ninguém abre desde a ingestão. Priorizar por frequência de acesso, usando o log de retrieval dos últimos trinta dias, faz a maior parte do tráfego real ficar coberta pela versão nova bem antes do backfill terminar, e é isso que permite começar a comparar as duas versões cedo em vez de esperar o acervo inteiro. Vale medir o progresso em duas escalas: a cobertura bruta, que é a fração de chunks migrados, e a cobertura ponderada por tráfego, que é a que realmente diz se já dá para avaliar a virada.

// embeddings/backfill.js
// Backfill em lotes, retomavel por checkpoint durável e idempotente na escrita.

export async function runBackfill({
  chunks,       // { listPending(version, cursor, limit), }
  embedder,     // { embedDocuments(texts) }
  store,        // { upsertMany(rows) }
  checkpoints,  // { read(job), write(job, cursor) }
  version,
  batchSize = 128,
  onProgress = () => {},
}) {
  const job = `backfill:${version}`;
  let cursor = await checkpoints.read(job);
  let migrated = 0;

  for (;;) {
    const batch = await chunks.listPending(version, cursor, batchSize);
    if (batch.length === 0) break;

    const vectors = await embedder.embedDocuments(batch.map((c) => c.text));

    // Chave composta por chunk e versao: reprocessar o mesmo lote sobrescreve
    // a mesma linha em vez de duplicar, entao retomar do checkpoint e seguro.
    await store.upsertMany(
      batch.map((chunk, i) => ({
        id: `${chunk.id}::${version}`,
        chunkId: chunk.id,
        vector: vectors[i],
        embedding_version: version,
        documentId: chunk.documentId,
      })),
    );

    cursor = batch[batch.length - 1].id;
    migrated += batch.length;

    // Checkpoint depois da escrita: se cair antes daqui, o pior caso
    // e reprocessar o ultimo lote, nunca pular chunk.
    await checkpoints.write(job, cursor);
    onProgress({ migrated, cursor });
  }

  return { migrated };
}

04

Escrever nas duas versões enquanto o acervo muda

O erro mais comum da migração incremental não está no backfill, está no que acontece com o documento que entra durante ele. Se a ingestão continua gerando só a versão antiga, o backfill nunca converge, porque a fronteira de conteúdo não migrado se renova sozinha. Se a ingestão passa a gerar só a versão nova, o índice antigo apodrece justamente enquanto ainda é ele que serve a produção, e o rollback deixa de existir. A resposta é a escrita dupla: enquanto a migração estiver aberta, todo chunk novo ou alterado é embutido nas duas versões e escrito nas duas. Custa o dobro de embedding na ingestão, e esse custo é temporário e proporcional ao delta, não ao acervo.

A escrita dupla precisa de uma decisão explícita sobre falha parcial. Se a versão nova falhar ao embutir e a antiga tiver sido gravada, o documento fica inconsistente entre os espaços, e a pergunta é qual das duas versões é obrigatória. Enquanto a produção lê da antiga, ela é a que precisa falhar a operação inteira; a nova pode falhar de forma tolerada e ser recuperada pelo próprio backfill, desde que o chunk volte para a fila de pendentes em vez de sumir. Depois da virada, essa relação se inverte. Modelar isso como uma versão primária e uma secundária, com a secundária sempre retomável pelo backfill, evita tanto o índice mudo quanto o incidente de ingestão travada por causa do espaço que ainda nem está em uso.

INGESTAO (durante a migracao)
  documento novo/alterado
        |
        +--> embed v1 (primaria) --> upsert indice v1 --> falha aqui aborta a operacao
        |
        +--> embed v2 (secundaria) -> upsert indice v2 --> falha aqui apenas
                                                          reenfileira o chunk

BACKFILL (segundo plano)
  chunks sem v2 --> lotes --> embed v2 --> upsert v2 --> checkpoint

LEITURA
  fase 1  consulta -> embed v1 -> busca filtrada por v1        (100% do trafego)
  fase 2  consulta -> embed v1 e v2 -> compara em sombra       (v1 responde)
  fase 3  consulta -> embed v2 -> busca filtrada por v2        (fatia crescente)
  fase 4  v1 congelada, so leitura, pronta para descarte

05

Decidir a virada por paridade medida, não por sensação

Backfill completo não é critério de virada. O que decide é se o retrieval na versão nova é pelo menos tão bom quanto o da antiga para as consultas que o seu sistema realmente recebe, e isso se mede antes de qualquer cliente ser exposto. O instrumento é um conjunto congelado de consultas reais, amostradas do log de produção e cobrindo as intenções principais, com o resultado da versão antiga guardado como linha de base. Para cada consulta, embuta nas duas versões, busque nas duas e compare. Duas métricas bastam para a decisão: a sobreposição do top-k, que diz o quanto os conjuntos recuperados coincidem, e a mudança de posição do trecho que era o correto, quando você tem essa anotação.

Sobreposição baixa não é automaticamente ruim. O modelo novo pode estar recuperando trechos diferentes e melhores, e é por isso que a comparação puramente estatística não fecha a decisão sozinha. O caminho prático é usar a sobreposição como filtro barato para achar as consultas que mais divergiram e revisar essas manualmente ou com um juiz automático, porque é ali que a regressão real aparece. Um detalhe operacional que evita falso alarme: rode a comparação apenas nas consultas cujos documentos relevantes já estejam cobertos pelo backfill, senão você vai medir cobertura incompleta achando que está medindo qualidade do modelo.

// embeddings/parity.js
// Compara o retrieval das duas versoes sobre um conjunto congelado de consultas.
// Roda em sombra: nenhum cliente e exposto a versao nova nesta etapa.

const overlapAt = (a, b, k) => {
  const top = new Set(a.slice(0, k).map((hit) => hit.chunkId));
  const hits = b.slice(0, k).filter((hit) => top.has(hit.chunkId)).length;
  return hits / k;
};

export async function measureParity({
  queries,      // [{ id, text, expectedChunkId? }]
  embedder,     // { embedQuery(text, version) }
  search,       // (vector, version) => hits
  from,
  to,
  k = 8,
}) {
  const rows = [];

  for (const query of queries) {
    const [oldVector, newVector] = await Promise.all([
      embedder.embedQuery(query.text, from),
      embedder.embedQuery(query.text, to),
    ]);

    const [oldHits, newHits] = await Promise.all([
      search(oldVector, from),
      search(newVector, to),
    ]);

    const rankOf = (hits) => {
      const index = hits.findIndex((hit) => hit.chunkId === query.expectedChunkId);
      return index === -1 ? null : index + 1;
    };

    rows.push({
      queryId: query.id,
      overlap: overlapAt(oldHits, newHits, k),
      rankBefore: query.expectedChunkId ? rankOf(oldHits) : null,
      rankAfter: query.expectedChunkId ? rankOf(newHits) : null,
    });
  }

  const mean = (values) => values.reduce((sum, v) => sum + v, 0) / (values.length || 1);
  const annotated = rows.filter((row) => row.rankBefore !== null);

  return {
    rows,
    meanOverlap: mean(rows.map((row) => row.overlap)),
    // Regressoes sao o sinal que barra a virada: o trecho correto piorou de posicao.
    regressions: annotated.filter(
      (row) => row.rankAfter === null || row.rankAfter > row.rankBefore,
    ),
    // As maiores divergencias sao a fila de revisao manual.
    mostDivergent: [...rows].sort((a, b) => a.overlap - b.overlap).slice(0, 20),
  };
}
Sinal medidoLeituraDecisão
Sobreposição alta e nenhuma regressão anotadaVersão nova é equivalente na práticaLiberar rollout gradual com segurança
Sobreposição baixa e regressões anotadasVersão nova perdeu trechos que importavamBarrar a virada e revisar chunking ou prefixo de consulta
Sobreposição baixa e nenhuma regressão anotadaRecuperou trechos diferentes, possivelmente melhoresRevisar manualmente as consultas mais divergentes antes de decidir
Divergência concentrada em um tipo de documentoProblema de formatação ou prefixo, não do modeloCorrigir a ingestão daquele tipo e refazer só aquele recorte
Cobertura ponderada por tráfego ainda baixaMétrica está medindo backfill, não qualidadeAguardar cobertura antes de interpretar a paridade

06

Virar, manter o rollback e descartar no fim

A virada é uma mudança de configuração de leitura, não um deploy de código, e é isso que torna o rollback instantâneo. A versão de leitura fica num ponteiro consultado a cada requisição, com granularidade por fatia de tráfego, e o rollout sobe em degraus, comparando as métricas de negócio da fatia nova contra a antiga na mesma janela de tempo. O que se observa aqui não é mais sobreposição de top-k, é o efeito no atendimento: taxa de transbordo para humano, reformulação da pergunta pelo cliente e resolução na primeira resposta. Se algum degrau piorar esses números, o ponteiro volta para a versão antiga em segundos, sem redeploy e sem reindexação, porque o índice antigo continua íntegro e alimentado pela escrita dupla.

O descarte é a última etapa e a mais fácil de fazer cedo demais. Enquanto o índice antigo existir e estiver atualizado, a migração é reversível; no momento em que ele for apagado, voltar significa reindexar o acervo inteiro de novo. Vale manter a versão antiga viva por um período que cubra pelo menos um ciclo completo de sazonalidade do seu atendimento, com a escrita dupla ligada, porque regressão de retrieval costuma aparecer em intenção rara, não no caminho feliz. Depois desse período, desligue primeiro a escrita dupla, observe, e só então apague os vetores da versão antiga. E, se o custo de armazenamento pesar antes disso, prefira reduzir a retenção a acelerar o descarte: o vetor antigo é o seu backup de comportamento.

  1. Definir a versão do embedding a partir de modelo, dimensão, normalização e prefixos, e exigir esse filtro em toda busca.
  2. Ligar a escrita dupla na ingestão, com a versão em produção como primária e a nova como secundária retomável.
  3. Rodar o backfill em lotes com checkpoint durável, priorizando os documentos mais acessados pelo log de retrieval.
  4. Medir paridade em sombra sobre um conjunto congelado de consultas, restrito ao que já está coberto pelo backfill.
  5. Revisar manualmente as consultas mais divergentes e corrigir ingestão ou prefixo antes de expor cliente.
  6. Subir o rollout em degraus por fatia de tráfego, comparando transbordo e resolução na mesma janela.
  7. Manter a versão antiga íntegra e alimentada até fechar um ciclo de sazonalidade, e só então desligar a escrita dupla e descartar.

FAQ

Perguntas frequentes

Dá para converter os vetores antigos para o espaço novo em vez de reindexar?

Na prática, não de forma confiável. É possível aprender uma projeção linear entre dois espaços usando pares de textos embutidos nos dois modelos, e a literatura mostra que isso funciona parcialmente, mas a perda é distribuída de forma desigual: os casos fáceis continuam funcionando e os difíceis, que são justamente os que o retrieval precisa acertar, degradam. Você acaba com um índice que parece bom na média e falha nas consultas específicas, e o pior é que essa falha não se distingue de um problema de chunking ou de prompt no diagnóstico. Some a isso que a projeção precisa ser recalculada a cada novo modelo e que o custo de embutir de novo costuma ser menor do que o de manter e validar essa camada de tradução. A conversão só se justifica quando reindexar é impossível por restrição de licença ou de dado que não existe mais em texto, e mesmo aí exige a mesma medição de paridade.

Quanto tempo manter as duas versões escrevendo antes de descartar a antiga?

O critério não é tempo de calendário, é cobertura de cenários. Enquanto a versão antiga estiver íntegra e atualizada, o rollback custa uma mudança de ponteiro; depois do descarte, custa uma reindexação completa. O período mínimo razoável é o que cobre um ciclo inteiro de sazonalidade do seu atendimento, porque regressão de retrieval raramente aparece na intenção mais comum, que é a mais testada, e sim naquela que só acontece no fechamento do mês ou na campanha trimestral. Um bom marcador operacional é ter visto a versão nova responder com qualidade estável a todas as intenções que aparecem no seu catálogo de casos, não apenas às frequentes. Quando esse marcador for atingido, desligue primeiro a escrita dupla, observe por mais um intervalo, e só então apague os vetores antigos.

A migração de embedding exige refazer o chunking também?

São decisões independentes e é melhor mantê-las assim. Modelos novos costumam ter janela de contexto maior, o que tenta a fazer chunks maiores no mesmo movimento, e essa combinação destrói a capacidade de diagnóstico: se a qualidade cair, você não sabe se o culpado é o modelo ou o corte. Migre primeiro o embedding com o chunking congelado, meça a paridade contra a linha de base e conclua a virada. Depois, se houver hipótese de que chunks maiores ajudam, trate como uma segunda migração com o mesmo processo, agora com a versão do chunking fazendo parte da chave de versionamento. A exceção é quando a mudança de modelo exige um formato de entrada diferente, como prefixo de instrução distinto para consulta e documento: isso não é rechunking, é ajuste de ingestão, e precisa entrar na definição da versão.

Migração de embedding é convivência, não substituição

Trocar o modelo de embedding parece configuração e é migração de dado: dois espaços vetoriais incompatíveis que precisam conviver enquanto o acervo continua mudando. Versionar o vetor e exigir o filtro em toda busca, ligar a escrita dupla para o delta não renovar a fronteira, rodar o backfill retomável priorizando o que o tráfego realmente consulta, decidir a virada por paridade medida em sombra e manter o índice antigo íntegro até fechar um ciclo de sazonalidade transforma uma janela de risco num processo reversível a qualquer momento. Posso conduzir essa migração no seu pipeline de RAG, desenhando o versionamento, o backfill e o harness de paridade com o seu acervo e as suas consultas reais, para que a troca de modelo aconteça sem uma única resposta pior chegando ao cliente.