Pular para o conteúdo
Rogerio Lima
ContatoEnglish

· IA, Backend · 3 min de leitura

RRF com peso e a armadilha do vector_top_k

Com RRF puro, achar a frase exata quase não muda a posição do resultado. Explico por que dei peso 2 pra essa busca e o filtro do vector_top_k que me devolvia lista vazia.

brown and beige weighing scale
Foto: Piret Ilver · Unsplash

Reciprocal Rank Fusion é o jeito mais simples de juntar resultados de buscas cujas notas não se comparam, como cosseno e BM25. Cada busca diz em que posição colocou cada item, e a nota final é a soma de 1 / (k + posição). Com k = 60 funciona bem quase sempre. Aqui vão os dois casos em que não funcionou pra mim, num motor de RAG no Turso com três buscas: vetor, termos (FTS5 com OR) e frase exata (FTS5 com a frase entre aspas).

O RRF achata a frase exata

O RRF só olha posição, e é por isso que ele serve pra cosseno e BM25, que estão em escalas diferentes. Mas faz a conta com k = 60:

  • 1º lugar vale 1/61 e 2º vale 1/62, uma diferença de 1,6%.
  • Ficar em 40º em duas buscas (1/100 + 1/100 = 2/100) ganha de ficar em 1º numa busca só (1/61).

Pra vetor e termos soltos, tudo bem. O problema é a busca da frase: o documento ter exatamente a frase que a pessoa digitou, na ordem, é o sinal mais forte que eu tenho, e o RRF puro trata ele igual aos outros.

Resolvi com um peso:

const RRF_K = 60;
const PHRASE_WEIGHT = 2;

const raw =
  (vectorRank === null ? 0 : 1 / (RRF_K + vectorRank)) +
  (textRank === null ? 0 : 1 / (RRF_K + textRank)) +
  (phraseRank === null ? 0 : PHRASE_WEIGHT / (RRF_K + phraseRank));

Por que 2? É o menor peso que resolve. Com ele, a frase em 1º vale 2/61, e o máximo que as outras duas buscas conseguem juntas é 1/61 + 1/61, um empate. Na prática elas nunca colocam o mesmo item em 1º ao mesmo tempo, então a frase ganha. E uma frase mal colocada (em 30º, digamos) não passa sozinha por cima do que as outras duas concordam. Com peso maior, passaria.

Normalizando pelas buscas que responderam

Nem toda pergunta ativa as três buscas. A da frase só roda com duas palavras ou mais, e a de termos pode voltar vazia. Pra nota final ficar entre 0 e 1, eu divido pelo máximo possível com as buscas que de fato responderam:

const activeLegs =
  (vectorHits.length > 0 ? 1 : 0) +
  (textHits.length > 0 ? 1 : 0) +
  (phraseHits.length > 0 ? PHRASE_WEIGHT : 0);
const maxRrf = Math.max(activeLegs, 1) / (RRF_K + 1);
const score = raw / maxRrf;

Sem isso, o mesmo resultado ganharia notas diferentes dependendo de quantas buscas rodaram, e qualquer nota de corte ("só mostra acima de 0,4") ficaria errada.

A pegadinha do vector_top_k

Com mais documentos, troquei a varredura completa pelo índice vetorial do libSQL:

CREATE INDEX chunk_emb_idx ON chunk (libsql_vector_idx(emb));
SELECT c.id, 1 - vector_distance_cos(c.emb, vector32(?)) AS similarity
  FROM vector_top_k('chunk_emb_idx', vector32(?), ?) AS v
  JOIN chunk c ON c.rowid = v.id
  JOIN document d ON d.doc_id = c.doc_id
 WHERE d.doc_id IN (?, ?, ?)
 ORDER BY similarity DESC;

Parece certo, mas volta vazio. O vector_top_k pega os k vizinhos mais próximos no índice inteiro, e só depois o WHERE filtra. Se a busca é restrita a poucos documentos e o k é pequeno, os k melhores do banco todo são de outros documentos, o filtro tira todos, e você recebe zero linhas sem erro nenhum.

A saída foi alargar a janela quando tem filtro:

const MAX_CANDIDATES = 200;

export function candidateWidth(limit: number, scoped: boolean): number {
  if (scoped) return MAX_CANDIDATES;
  return Math.min(Math.max(limit * 8, 1), MAX_CANDIDATES);
}

Sem filtro, oito vezes o que eu preciso, até o teto. Com filtro, vai direto pro teto. Não é perfeito: um escopo muito pequeno num banco muito grande ainda pode voltar vazio, e aí o certo é varrer só aquele escopo. Mas resolveu o caso que eu tinha, e a sonda que uso pra conferir roda exatamente o cenário que falhava: busca com escopo e limit de 6.

Se você vai usar RRF, faz a conta com as posições de verdade antes de confiar nele. E na documentação do índice vetorial, procura se o filtro roda antes ou depois da busca.

Travou em alguma etapa? Chamar no WhatsApp

← Todos os posts