#busca #elasticsearch #embeddings #ia #rrf

Busca Híbrida com RRF: por que os melhores sistemas combinam BM25 e vetorial

RD
Rodolfo De Bonis28/07/2026 · 16 min · 28 views
Busca Híbrida com RRF: por que os melhores sistemas combinam BM25 e vetorial

Esse post assume que você leu Busca Semântica: como ensinar máquinas a entender intenção. Recapitulando em duas linhas: BM25 acerta a superfície do texto e erra o significado; a busca vetorial acerta o significado e erra o literal. Os buracos são complementares, e a conclusão natural é somar as duas.

O problema é que “somar” esconde uma decisão bem mais espinhosa do que parece.

Duas listas na tela, uma página de resultados

Imagine que você rodou as duas buscas. O BM25 devolveu dez documentos ordenados. A busca vetorial devolveu outros dez, com alguma sobreposição. Agora você precisa entregar uma lista só pro usuário.

Qual documento vai na primeira posição?

Essa é literalmente a única pergunta que a busca híbrida precisa responder. E é onde a maioria das implementações caseiras começa a desandar, porque a resposta intuitiva é a errada.

Somar os scores parece óbvio e quase sempre dá errado

A primeira tentativa de todo mundo é combinação linear:

score_final = α × score_bm25 + (1 - α) × score_cosseno

Elegante no papel. Você ajusta o α, dá mais peso pra um lado ou pro outro, pronto. Só que as duas grandezas não vivem na mesma régua.

A similaridade do cosseno é limitada: em texto, na prática, fica entre 0 e 1. O BM25 não tem teto. O score depende do IDF dos termos, do tamanho do documento, do tamanho da query e do tamanho do corpus. Uma query de uma palavra rara num índice de um milhão de documentos pode produzir um score que é dezenas de vezes maior que o cosseno máximo possível. Some os dois crus e o BM25 engole a busca vetorial inteira. Você não configurou pesos, você configurou uma escala.

A correção óbvia é normalizar antes de somar. Min-max por query, por exemplo: o melhor resultado de cada lista vira 1.0, o pior vira 0.0. E aqui mora a armadilha que quase ninguém enxerga na primeira leitura.

Min-max é relativo à query. Se a busca vetorial não encontrou nada de bom, o melhor dos resultados ruins ainda vira 1.0. A normalização apagou exatamente a informação que importava: essa lista não tem nada de útil. Você acabou de dar peso máximo a um resultado péssimo por causa de uma conta de escala.

Dá pra contornar com normalização por distribuição, z-score, calibração por domínio. Tudo isso funciona até certo ponto. Mas repare no que aconteceu: você entrou pra resolver ranking e saiu fazendo estatística de score. O α virou um hiperparâmetro que muda quando você troca o modelo de embedding, quando o corpus cresce, quando o perfil de query muda. É manutenção permanente.

Rank é universal, score não é

O Reciprocal Rank Fusion resolve isso ignorando os scores.

A ideia, publicada por Cormack, Clarke e Büttcher na SIGIR 2009, cabe numa linha:

RRF(d) = Σ  1 / (k + rank_r(d))
        r∈R

Para cada ranking r em que o documento d aparece, some o inverso da sua posição, deslocada por uma constante k. Ordene pela soma. Fim.

Não tem normalização. Não tem peso pra calibrar. Não tem treinamento. As duas listas entram como sequências de posições, e posição é uma unidade que qualquer ranker do mundo produz do mesmo jeito. O primeiro colocado do BM25 e o primeiro colocado do vetorial são a mesma coisa: primeiro colocado. É isso que torna a fusão possível sem que ninguém precise perguntar quanto vale um score 14.7.

A consequência mais importante vem de graça: como cada lista contribui com uma parcela pequena e parecida, o documento que sobe é aquele que as duas buscas concordam em achar razoável. Não o favorito absoluto de uma delas. RRF premia consenso.

Cinco documentos, duas listas, um vencedor

Vale ver isso acontecer com números. Query: tênis pra correr no calor, num catálogo de e-commerce esportivo.

O BM25 casa os tokens literais e devolve:

Posição Documento
1 C: Camiseta dry-fit para correr no calor
2 A: Tênis de corrida Ventus, mesh com ventilação alta
3 B: Tênis de corrida trail Rocha 3, cabedal reforçado
4 E: Meia técnica de corrida para dias quentes
5 D: Tênis leve para clima quente e úmido

A camiseta ganha o primeiro lugar porque casa dois termos discriminativos, “correr” e “calor”. Ela é irrelevante pra intenção do usuário, mas o BM25 não sabe disso. E o D, que é quase uma paráfrase perfeita da query, afunda: “quente” não é “calor” pro índice invertido.

A busca vetorial devolve outra ordem:

Posição Documento
1 D: Tênis leve para clima quente
2 A: Tênis de corrida Ventus, mesh
3 E: Meia técnica para dias quentes
4 C: Camiseta dry-fit
5 B: Tênis trail Rocha 3

Aqui o D sobe pro topo, como esperado. Mas a meia também sobe, porque “corrida” e “calor” dominam o vetor e a categoria do produto pesa menos do que deveria.

Cada lista tem um primeiro lugar errado, por motivos opostos. Agora o RRF com k = 60:

Documento rank BM25 rank vetorial RRF Final
A: Tênis Ventus 2 2 1/62 + 1/62 = 0.032258
C: Camiseta 1 4 1/61 + 1/64 = 0.032018
D: Tênis leve 5 1 1/65 + 1/61 = 0.031778
E: Meia técnica 4 3 1/64 + 1/63 = 0.031498
B: Tênis trail 3 5 1/63 + 1/65 = 0.031258

O A ganha sem ter sido o primeiro de ninguém. Ele é o único documento que as duas buscas colocaram no topo por razões diferentes: o BM25 viu “tênis” e “corrida”, o modelo de embedding viu “mesh com ventilação” e entendeu calor. Isso é o consenso funcionando.

Repare também no que o RRF não fez: a camiseta continua em segundo. Fusão de rank não é filtro de relevância. Se um documento errado aparece bem posicionado nas duas listas, ele continua bem posicionado depois. Guarde isso, vai voltar mais pra frente.

O que o k faz de verdade

O k = 60 circula por aí como número mágico. A origem é bem menos mística do que parece: no paper, os autores dizem que o valor foi fixado num experimento piloto e não foi alterado nas validações seguintes.

O que dá pra afirmar com base nos dados publicados é o comportamento da curva. Na Tabela 1 do artigo, o MAP para k variando de 20 a 100 fica entre 0.2134 e 0.2147. Menos de 1% de variação. O pico nominal está em k = 80, não em 60. Só nos extremos a coisa muda: k = 0 cai pra 0.2072 e k = 500 pra 0.2098.

Traduzindo pro seu sistema: k controla o quanto o primeiro lugar de uma lista individual pesa contra o consenso.

Pegue o mesmo exemplo dos tênis com k = 0, ou seja, score igual a 1/rank:

Documento RRF com k = 0 Final
C: Camiseta 1/1 + 1/4 = 1.25
D: Tênis leve 1/5 + 1/1 = 1.20
A: Tênis Ventus 1/2 + 1/2 = 1.00

A ordem inverteu. Com k pequeno, o intervalo entre a primeira e a segunda posição é enorme, então cada ranker praticamente arrasta seu favorito pro pódio e o consenso perde. Com k grande, as posições ficam quase indistinguíveis entre si e o que sobra é contagem de votos.

k = 60 é um ponto de equilíbrio razoável nesse espectro, não um ótimo global do seu domínio. Se você tem conjunto de avaliação, varra o intervalo. Se não tem, mexer em k no chute é a última coisa que vai melhorar seu resultado.

Como isso fica na arquitetura

O fluxo é mais simples do que a maioria imagina: image

Três pontos que mudam o resultado na prática.

As duas buscas rodam em paralelo, então a latência é o máximo entre elas, não a soma. Se o BM25 leva 12 ms e o vetorial 25 ms, a busca custa 25 ms mais a fusão, que é irrelevante: ordenar algumas dezenas de itens em memória não aparece no p99.

O gargalo real costuma ser gerar o embedding da query. Se você usa API externa, cada busca vira uma chamada de rede antes mesmo de tocar no índice, e isso normalmente domina o tempo total. Duas saídas: cache de embedding de query, que funciona muito bem porque a distribuição de queries é extremamente concentrada na cauda curta (as mesmas frases se repetem o dia inteiro), ou rodar o modelo localmente. Um modelo pequeno de embedding em CPU já muda essa conta.

O tamanho da janela de fusão importa mais que o k. Se cada lado devolve só 10 candidatos, um documento que está na posição 11 das duas listas simplesmente não existe pro RRF. O recall da híbrida é limitado pela união dos dois conjuntos de candidatos. No Elasticsearch esse parâmetro é o rank_window_size e o default é 10, que é baixo demais pra quase todo caso real. Buscar 50 ou 100 de cada lado e cortar depois costuma ser a diferença entre “funciona” e “por que esse resultado óbvio não aparece?”.

Quinze linhas de Python, ou uma linha de JSON

Implementar RRF do zero é quase constrangedoramente simples:

from collections import defaultdict

def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    """Funde listas ordenadas de IDs. Cada lista já vem do mais relevante pro menos."""
    scores = defaultdict(float)
    for ranking in rankings:
        for position, doc_id in enumerate(ranking, start=1):
            scores[doc_id] += 1 / (k + position)
    return sorted(scores.items(), key=lambda item: item[1], reverse=True)


bm25   = ["C", "A", "B", "E", "D"]
vector = ["D", "A", "E", "C", "B"]

rrf([bm25, vector])
# [('A', 0.032258), ('C', 0.032018), ('D', 0.031778), ('E', 0.031498), ('B', 0.031258)]

Repare que a função não sabe nada sobre busca. Ela recebe listas de identificadores. Isso é uma propriedade e não um acidente: você pode fundir três, quatro, cinco rankers, incluindo regras de negócio e ordenação por popularidade, sem mudar uma linha.

Na prática, provavelmente você não vai escrever isso, porque os motores já trazem RRF pronto:

GET /produtos/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        { "standard": { "query": { "multi_match": {
            "query": "tênis pra correr no calor",
            "fields": ["titulo", "descricao"]
        }}}},
        { "knn": {
            "field": "embedding",
            "query_vector": [0.21, -0.05, "..."],
            "k": 50,
            "num_candidates": 200
        }}
      ],
      "rank_constant": 60,
      "rank_window_size": 100
    }
  },
  "size": 20
}

No Elasticsearch o rank_constant já tem default 60 e o rank_window_size default 10, então os dois campos acima são explícitos de propósito. Um detalhe de planejamento que costuma pegar equipe de surpresa: o RRF no Elasticsearch é recurso de assinatura paga, listado pela própria Elastic entre as capacidades de Platinum e Enterprise. Em cluster self-hosted com licença Basic a query volta com erro de licença.

O OpenSearch ganhou fusão por rank na versão 2.19, através do score-ranker-processor com técnica rrf, também com rank constant 60 por padrão, e é open source. O Qdrant expõe Fusion.RRF direto na Query API. O Weaviate oferece rankedFusion, que é RRF, e relativeScoreFusion, que é normalização de score, e este último virou o default a partir da 1.24. Se você está no Postgres com pgvector, não existe nada nativo: são duas CTEs com row_number() e um full outer join, o que também funciona bem.

Onde a híbrida ganha sem esforço nenhum

Dois padrões de query aparecem em praticamente todo domínio.

Descrição de intenção, no e-commerce. Alguém digita “vestido pra casamento na praia”. O BM25 vai perseguir “vestido”, “casamento” e “praia” e trazer vestido de festa pesado, saída de praia, e talvez um vestido infantil de daminha. O vetorial entende o contexto (leve, fluido, tecido respirável, comprimento longo, evento formal ao ar livre) e traz a categoria certa, mas erra na hora que a pessoa complementa com “marca Zara” ou um modelo específico. Fundidos, o vetorial define o conjunto certo e o BM25 garante que o termo literal continue ancorando o topo.

Suporte técnico e FAQ. Alguém busca “erro 401”. Um artigo da sua base explica autenticação falha por token expirado, fala de Bearer, de JWT, de refresh token, e nunca escreve o número 401. O vetorial acha esse artigo. Ao mesmo tempo, você tem um documento de troubleshooting que lista literalmente HTTP 401 na primeira linha, e é ele que o usuário provavelmente quer ver primeiro. Vetorial sozinho enterra o segundo, BM25 sozinho nunca acha o primeiro. RRF entrega os dois na primeira página.

O que os dois casos têm em comum é o que torna a híbrida boa: os erros são independentes. Quando um ranker falha, o outro falha de um jeito diferente.

Onde o RRF quebra

Aqui é onde eu discordo do entusiasmo padrão da internet com essa técnica.

RRF é cego a magnitude, e isso corta pros dois lados. Ignorar score é exatamente o que resolve o problema de escala, e também é o que impede o algoritmo de saber que a lista vetorial inteira é lixo. Se a melhor similaridade da lista foi 0.31, o RRF ainda dá a essa lista poder de voto integral, porque só olha “primeiro colocado”. Métodos baseados em score, como o relativeScoreFusion do Weaviate ou o DBSF do Qdrant, existem exatamente por isso. Se no seu domínio uma das buscas frequentemente não tem nada de bom pra oferecer, vale medir as duas abordagens.

Os scores de saída não significam nada. Olhe a tabela do exemplo: 0.032258 contra 0.031258. Todos os resultados vivem espremidos numa faixa minúscula, e o valor não é comparável entre queries. Isso derruba qualquer coisa que dependa de threshold. Não dá pra dizer “só mostro resultado acima de X” nem “se o score for baixo, mostro a tela de nenhum resultado encontrado”. Se seu produto precisa dessa decisão, você vai precisar de outro sinal, geralmente o score bruto do ranker antes da fusão.

Documento errado com consenso continua no topo. Foi o que aconteceu com a camiseta dry-fit ficando em segundo lugar. RRF ordena, não julga relevância.

Identificador exato pode ser diluído. Se o usuário digita SKU-A4729, o comportamento desejado não é fusão, é match exato dominando tudo. A híbrida pode empurrar pra cima um documento medíocre que aparece razoavelmente nas duas listas. A solução não é ajustar o k, é detectar o padrão da query e desviar do pipeline híbrido antes dele começar.

Se os dois rankers concordam demais, você está pagando duas buscas pelo preço de uma. Fusão só agrega valor quando as listas divergem. Vale medir a sobreposição entre os dois top 20 no seu tráfego real. Se ficar muito alta, o vetorial provavelmente está só reproduzindo o BM25 e o problema está no modelo de embedding ou no que você indexou.

O custo operacional é real. Dois índices sobre o mesmo corpus, dois caminhos de escrita que precisam ficar consistentes, e um detalhe fácil de esquecer: os filtros (categoria, estoque, permissão) precisam ser aplicados nos dois lados, com o mesmo critério, antes da fusão. Filtrar depois de fundir estraga a paginação e a contagem de resultados. Trocar de modelo de embedding significa reindexar tudo, e enquanto a reindexação roda você tem duas gerações de vetor no mesmo índice.

Como começar e o que medir

A sequência que evita retrabalho:

  1. Ligue a híbrida com pesos iguais e k = 60. Não toque em nada ainda.
  2. Monte um conjunto de avaliação pequeno e honesto. Entre 50 e 200 queries reais do seu log, com os documentos relevantes marcados à mão. É trabalho chato de meio dia e é o único jeito de saber se qualquer mudança seguinte melhorou alguma coisa.
  3. Meça três coisas contra os baselines: nDCG@10, Recall@50 e MRR. Compare híbrida contra BM25 puro e contra vetorial puro.
  4. Se a híbrida não ganhar das duas, pare e investiga. Normalmente é janela de fusão pequena demais, embedding ruim pro seu vocabulário, ou campo errado indexado. Não é o k.
  5. Só depois disso mexa em rank_window_size, depois em pesos por ranker, e por último em k.

A ordem importa. Peso e k são os botões mais visíveis e os que menos entregam.

Existem bibliotecas prontas pra essa parte de avaliação, como a ranx, que implementa RRF junto com outros métodos de fusão e as métricas todas. Usar isso pra comparar offline sai mais rápido do que escrever seus próprios cálculos de nDCG.

Os últimos 10%

Busca híbrida com RRF é, na minha opinião, o melhor retorno sobre esforço em busca hoje. Você liga, não calibra nada, e o resultado é consistentemente melhor que qualquer um dos dois lados sozinho. Para a maior parte dos produtos, é aqui que dá pra parar.

Mas repare no limite que apareceu no meio do caminho: RRF ordena por consenso de posição, e consenso não é a mesma coisa que relevância. A camiseta continuou em segundo lugar porque as duas buscas acharam ela razoável, e nenhum dos dois rankers jamais leu a query e o documento juntos pra decidir se aquilo respondia a pergunta.

É isso que um cross-encoder faz. Ele pega os 50 ou 100 candidatos que a híbrida trouxe, processa query e documento no mesmo forward pass, e reordena com um entendimento que nenhuma busca de primeiro estágio consegue ter. Custa caro demais pra rodar no catálogo inteiro, e é exatamente por isso que ele só faz sentido depois de uma boa recuperação.

Quando você quer top 10 perfeito, e não só top 100 decente, é o próximo passo. Fica pro terceiro post.

Antes disso, se você já tem BM25 e vetorial rodando separados, o experimento de hoje é curto: pegue vinte queries reais, salve os dois rankings, funda com as quinze linhas de Python lá de cima e olhe manualmente o que mudou no top 5. Você vai descobrir em uma tarde se o seu domínio precisa disso, e provavelmente vai descobrir onde cada uma das duas buscas está errando feio.

Referências


Segundo post da série sobre busca moderna. O primeiro é Busca Semântica. O próximo fecha com reranking usando cross-encoder. Se você está montando busca híbrida em algum lugar, me conta como está indo.

Continue lendo

// share
share Twitter / X share LinkedIn

Comentários

Sem cadastro. Suporta markdown básico. Validamos com Cloudflare Turnstile pra evitar spam.

*itálico*, **negrito**, [link](url) — sem imagens.
Carregando comentários…