#busca #cross-encoder #ia #ml #reranking

Reranking com cross-encoder: a cereja que separa busca boa de busca excelente

RD
Rodolfo De Bonis07/08/2026 · 19 min · 17 views
Reranking com cross-encoder: a cereja que separa busca boa de busca excelente

Esse post fecha uma série. O primeiro tratou de busca semântica, o segundo de busca híbrida com Reciprocal Rank Fusion. Se você caiu aqui direto, o resumo é que BM25 acerta o literal e erra o significado, a busca vetorial faz o inverso, e o RRF junta os dois rankings usando só posição, sem calibrar peso nenhum.

O post 2 terminou com um problema em aberto. No exemplo daquele artigo, a query era tênis pra correr no calor e a camiseta dry-fit ficou em segundo lugar depois da fusão. Ela não era o que o usuário queria, mas as duas buscas acharam ela razoável, e o RRF ordena por consenso de posição. Consenso não é relevância.

O motivo de fundo é que, até esse ponto do pipeline, ninguém leu a query e o documento juntos.

O que um bi-encoder nunca chega a fazer

Vale ser preciso sobre o mecanismo, porque a diferença entre os dois arranjos é a coisa toda.

Na busca vetorial, o modelo de embedding processa o documento sozinho, no momento da indexação, e cospe um vetor. Meses depois chega uma query, o modelo processa a query sozinha, cospe outro vetor, e o sistema compara os dois com cosseno. Esse arranjo tem nome: bi-encoder. Dois encodes independentes, um encontro tardio e barato entre dois pontos no espaço.

Isso é o que torna a busca vetorial viável. O trabalho pesado acontece offline, uma vez por documento, e sobra apenas uma comparação geométrica no caminho da requisição. É também o que torna o HNSW possível: você só consegue construir um índice de vizinhança se o vetor do documento existir antes da query chegar.

E é exatamente aí que mora a limitação. O vetor de “Camiseta dry-fit para correr no calor” foi calculado sem que ninguém soubesse que a pergunta seria sobre tênis. Ele precisa representar o documento inteiro, para toda query concebível, em 1024 números fixos. É uma compressão com perda, e a informação que se perde é justamente a interação: quais partes desse documento importam para essa pergunta.

Um cross-encoder desfaz esse arranjo. Ele recebe o par (query, documento) concatenado numa única sequência e roda um forward pass sobre os dois ao mesmo tempo. Cada token da query pode atender a cada token do documento, e vice-versa, em todas as camadas. A saída não é vetor, é um número: o quanto esse documento responde essa query.

A implementação original, no paper de Nogueira e Cho de 2019, é quase constrangedoramente direta. Query entra como sentença A, passagem entra como sentença B, o vetor do token [CLS] vai para uma única camada densa, e o que sai é a probabilidade da passagem ser relevante. A query é truncada em 64 tokens, o conjunto todo em 512. Nenhuma arquitetura nova. Só BERT lendo as duas coisas juntas.

O tamanho do ganho

Esse mesmo paper tem o número que, na minha opinião, é o argumento mais forte a favor de reranking, e ele é forte porque isola a variável.

No MS MARCO, o conjunto de candidatos foi fixo: o top 1000 do BM25 para cada query. O BM25 sozinho, ordenando esses mesmos mil documentos, entrega MRR@10 de 16.7 no dev set. Um BERT Large reordenando exatamente a mesma lista entrega 36.5.

Nenhum documento novo entrou. Nenhum índice mudou. Nenhum embedding foi gerado. A recuperação era idêntica nos dois casos, e a métrica mais que dobrou só porque alguém leu os pares.

Vale registrar o que esse número não diz. É um benchmark de 2019, em inglês, num dataset de perguntas do Bing, com uma passagem relevante por query em média. Seu domínio não é esse. Mas a assimetria entre “recuperar” e “ordenar” que ele expõe é estrutural, e ela aparece de novo em todo benchmark posterior. A Elastic, medindo o próprio modelo em 21 datasets do BEIR, reporta em média 40% de melhoria em qualidade de ranking ao reranquear resultados de BM25.

Por que você não pode simplesmente usar isso em tudo

Se cross-encoder é tão melhor, a pergunta óbvia é por que não jogar fora o índice inteiro e pontuar todos os documentos direto.

A resposta está na palavra “par”. O bi-encoder faz N encodes de documento uma vez na vida e depois faz um encode por query. O cross-encoder faz um forward pass por par (query, documento), toda vez, porque o score depende dos dois. Não existe nada para pré-computar. Trocou a query, todo o trabalho anterior virou lixo.

Dá pra colocar número nisso com a tabela publicada do sentence-transformers, que mede throughput dos cross-encoders treinados em MS MARCO numa GPU V100:

Modelo nDCG@10 (TREC DL 19) MRR@10 (MS MARCO Dev) Docs/s
ms-marco-TinyBERT-L2-v2 69.84 32.56 9000
ms-marco-MiniLM-L4-v2 73.04 37.70 2500
ms-marco-MiniLM-L6-v2 74.30 39.01 1800
ms-marco-MiniLM-L12-v2 74.31 39.02 960
ms-marco-electra-base 71.99 36.41 340

Pegue o MiniLM-L6-v2, que é o meio de campo dessa lista. A 1800 documentos por segundo, pontuar 100 candidatos custa 56 ms. Pontuar um catálogo de um milhão de documentos custa 9 minutos e meio por query, numa GPU dedicada, para um usuário. O L12, que entrega meio ponto a mais de nDCG, leva 17 minutos.

Não é uma questão de escalar horizontalmente até resolver. É uma diferença de ordem de grandeza que muda a categoria do problema.

Repare também na terceira coluna comparada com a primeira. Entre o L6 e o L12 a qualidade é praticamente idêntica e o custo dobra. Entre o TinyBERT-L2 e o L6 você ganha 4.5 pontos de nDCG pagando 5× mais tempo. A curva de retorno decrescente já está desenhada dentro da própria família de modelos, antes mesmo de você escolher a profundidade.

Daí a arquitetura que todo mundo converge: a recuperação barata seleciona um punhado de candidatos, e o modelo caro só olha esse punhado.

image

O primeiro estágio existe pra não deixar nada relevante de fora. O segundo existe pra acertar a ordem. São objetivos diferentes, e é por isso que faz sentido usar modelos diferentes.

A camiseta finalmente cai

Voltando ao exemplo do post 2. Cinco documentos, a query tênis pra correr no calor, e o ranking que o RRF produziu:

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

Agora o cross-encoder recebe os cinco pares e devolve um score por par. Os valores abaixo são ilustrativos, mas a ordem é o que qualquer reranker decente produz aqui:

Documento Score Posição final
A: Tênis Ventus, mesh 0.94
D: Tênis leve, clima quente 0.91
B: Tênis trail Rocha 3 0.38
C: Camiseta dry-fit 0.07
E: Meia técnica 0.04

A camiseta desabou da segunda posição para a quarta, e o motivo é banal quando você olha o mecanismo: o modelo processou o token “tênis” da query no mesmo forward pass que o token “camiseta” do documento. Ele não está comparando dois resumos de significado, ele está lendo uma pergunta sobre calçado e um documento sobre vestuário e concluindo que a categoria está errada, apesar de “correr” e “calor” baterem literalmente.

O B subiu para terceiro por um raciocínio complementar. É um tênis de corrida, então acerta a intenção principal, mas “cabedal reforçado” é o oposto de ventilação, e o modelo penaliza sem descartar. Nenhum dos dois estágios anteriores tem como fazer essa distinção, porque nenhum dos dois leu as duas coisas ao mesmo tempo.

Repare no salto entre 0.91 e 0.38. Não é ruído de escala como nos scores do RRF. É o modelo separando duas populações.

Escolher o modelo

O ecossistema se divide em três caminhos, e a escolha entre eles quase nunca é sobre qualidade de ranking.

API gerenciada. O Cohere Rerank é a referência da categoria. A linha atual são o rerank-v4.0-pro e o rerank-v4.0-fast, ambos multilíngues com mais de 100 idiomas, 32 mil tokens de contexto, e suporte a documentos semiestruturados em JSON. O rerank-v3.5 continua disponível com 4 mil tokens. Voyage e Jina jogam no mesmo campo. Você adiciona uma chamada HTTP e pronto, sem GPU, sem deploy, sem reindexação.

O detalhe de planejamento é a unidade de cobrança. Uma “search unit” é uma query com até 100 documentos, e documentos longos são fatiados automaticamente, com cada pedaço contando como um documento separado. Ou seja, seus 100 candidatos podem virar várias search units se os textos forem grandes. Na listagem do Rerank v3.5 no AWS Marketplace (edição Bedrock) o preço é US$ 0,002 por search unit. Isso dá cerca de US$ 200 num mês com 100 mil buscas reranqueadas, e US$ 2 mil num mês com um milhão. Preço muda, então confira antes de fechar conta, mas a ordem de grandeza é essa: reranking em API é barato por busca e caro por tráfego.

Open-source auto-hospedado. A família cross-encoder/ms-marco-* do sentence-transformers é o ponto de partida clássico, e a tabela lá em cima já mostra o trade-off inteiro. Um alerta que importa muito pra quem escreve em português: esses modelos são treinados em MS MARCO, que é inglês. Se seu corpus é PT-BR, eles vão te decepcionar. Para multilíngue, o caminho é o BAAI/bge-reranker-v2-m3, construído em cima do bge-m3, com 512 tokens de janela e score que vira 0 a 1 com uma sigmoide. A própria documentação do BGE recomenda o óbvio e o correto: teste no seu caso real e escolha pelo equilíbrio velocidade/qualidade, não pela tabela deles.

Embutido no motor. O Elasticsearch expõe reranking pelo retriever text_similarity_reranker, que pode apontar para um endpoint de inference externo (Cohere, por exemplo) ou para o modelo próprio da Elastic, o Elastic Rerank, um DeBERTa v3 de 184 milhões de parâmetros. Vale ler as limitações antes de se animar: só inglês, janela de 512 tokens, disponível a partir do Stack 8.17, exige nível de assinatura adequado, e ainda está marcado como technical preview. A própria Elastic escreve na documentação que a versão preview pode ser proibitivamente cara para alto volume de queries com requisito de baixa latência, e recomenda não passar de top-30 quando a inferência é em CPU.

A opção do meio. O ColBERT ocupa um lugar próprio. Em vez de um score por par, ele guarda um vetor por token do documento e faz uma interação tardia entre os tokens da query e os do documento no momento da busca. O paper original mede efetividade competitiva com os modelos BERT da época executando duas ordens de grandeza mais rápido e com quatro ordens de grandeza menos FLOPs por query. O preço é o índice: guardar um vetor por token infla o armazenamento de forma agressiva, e o ColBERTv2 nasceu justamente pra reduzir isso em 5 a 8 vezes. Se seu problema é latência e você tem disco sobrando, vale olhar.

A profundidade é o parâmetro que importa

Aqui está o erro mais caro dessa etapa, e ele é contraintuitivo.

A crença padrão é que reranquear mais candidatos só pode melhorar, porque o modelo bom teria mais material para trabalhar. A Elastic mediu isso de forma sistemática, variando profundidade de reranking em vários modelos e datasets do BEIR, e encontrou três padrões:

  • Subida rápida e depois saturação, em 72.6% dos casos. É o comportamento que todo mundo espera.
  • Subida até um pico e depois queda, em 20.2% dos casos.
  • Queda contínua com qualquer reranking, em 7.1% dos casos. O reranker piora o resultado do BM25 em toda profundidade testada. A explicação é mecânica e boa. O nDCG@10 só muda quando o reranker promove alguém de baixo para o top 10. Se o promovido é relevante, a métrica sobe. Se é um falso positivo, ele expulsa um documento relevante e a métrica cai. Conforme você desce na lista, a densidade de documentos relevantes despenca e a de irrelevantes cresce. Existe uma profundidade em que a taxa de falso positivo do modelo passa a dominar, e depois dela você está pagando inferência para piorar o próprio ranking.

O paper Drowning in Documents chega ao mesmo lugar por outro caminho, e observa algo desconfortável: rerankers frequentemente atribuem score alto a documentos sem nenhuma sobreposição léxica ou semântica com a query.

As três consequências práticas, na ordem em que você vai precisar delas:

Primeiro, o número. Aplicando a regra de pegar a profundidade que atinge 90% do ganho máximo, a Elastic chegou a algo em torno de top 100 em cima de BM25, com um terço do custo computacional de ir até o máximo. Quando custo aperta, eles recomendam top 30 com o modelo deles, e mesmo assim mediram uplift acima de 40% em nDCG@10 na parte de QA do benchmark.

Segundo, a direção do ajuste. Quanto melhor o primeiro estágio, menos candidatos você precisa reranquear, porque o recall satura antes. E quanto melhor o reranker, mais fundo compensa ir, porque ele erra menos ao descer. Como você já tem híbrida com RRF, seu primeiro estágio é bom, então comece raso.

Terceiro, e esse me surpreendeu: sob restrição de latência, reranquear fundo com um modelo pequeno costuma ganhar de reranquear raso com um modelo grande. No benchmark da Elastic, o MiniLM-L12-v2 processando 80 candidatos bateu modelos mais fortes limitados a 10 ou 20 pelo mesmo orçamento de tempo. A relação se inverte conforme você afrouxa a restrição.

Sobre latência, um número concreto para calibrar expectativa: medindo em duas GPUs T4, a Elastic reporta 0,0869 segundo para pontuar 10 pares com o Elastic Rerank no HotpotQA. Linearizando, top-30 sai em torno de 0,26 s e top-100 em torno de 0,87 s. Não são os 50 ms que costumam circular em posts sobre o assunto. Meça no seu hardware, com seus documentos, antes de prometer SLA.

O score volta a significar alguma coisa

Uma limitação que eu levantei no post 2 se resolve aqui, e vale fechar o ciclo.

O score do RRF não significa nada. Ele vive espremido numa faixa minúscula, não é comparável entre queries, e por isso não dá pra usar em threshold. Você não consegue dizer “se o melhor resultado for ruim, mostro a tela de nada encontrado”.

O score de um cross-encoder é diferente em natureza. Ele é uma estimativa de relevância daquele par, produzida por um modelo treinado com esse objetivo, e não depende da posição de nada. Os modelos do sentence-transformers devolvem logits que ficam grosso modo entre -10 e 10, e você aplica uma sigmoide pra ter algo entre 0 e 1. O bge-reranker-v2-m3 funciona igual. O Cohere devolve relevance_score já normalizado. O Elasticsearch expõe min_score no próprio retriever justamente pra você cortar.

O cuidado é não confundir “tem significado” com “é calibrado”. A escala é específica do modelo e do domínio. Um 0.6 do Cohere não é um 0.6 do BGE, e o 0.6 do BGE no seu corpus jurídico não é o mesmo 0.6 no seu FAQ de suporte. O threshold precisa sair dos seus dados. Mas ele existe, e isso é uma capacidade nova no pipeline.

Como isso fica no código

Auto-hospedado, com sentence-transformers. O ponto do trecho é que reranking é uma função pura sobre a lista de candidatos, sem estado e sem índice:

from sentence_transformers import CrossEncoder
import torch
 
# Sigmoid força o score pra faixa 0..1. Sem isso, o retorno é logit cru.
reranker = CrossEncoder(
    "BAAI/bge-reranker-v2-m3",   # multilíngue; para inglês, ms-marco-MiniLM-L6-v2
    activation_fn=torch.nn.Sigmoid(),
    max_length=512,
)
 
def rerank(query: str, candidatos: list[dict], top_k: int = 10) -> list[dict]:
    """Recebe a saída do RRF, devolve o top_k reordenado."""
    if not candidatos:
        return []
 
    pares = [(query, doc["texto"]) for doc in candidatos]
    scores = reranker.predict(pares, batch_size=32)
 
    for doc, score in zip(candidatos, scores):
        doc["rerank_score"] = float(score)
 
    ordenado = sorted(candidatos, key=lambda d: d["rerank_score"], reverse=True)
    return ordenado[:top_k]

Duas decisões valem comentário. O batch_size é o que controla se você satura a GPU ou desperdiça ela, e é o primeiro botão a mexer quando a latência não fecha. E max_length=512 não é cosmético: documento maior que isso é truncado, então o modelo pontua o começo do texto e ignora o resto. Se seus documentos são longos, você precisa decidir conscientemente qual pedaço vai ser pontuado, em vez de deixar o tokenizer decidir por você.

Via API, o mesmo pipeline com Cohere:

import cohere
 
co = cohere.ClientV2(api_key="...")
 
resposta = co.rerank(
    model="rerank-v4.0-fast",
    query="tênis pra correr no calor",
    documents=[doc["texto"] for doc in candidatos],
    top_n=10,
)
 
# results traz index (posição na lista original) e relevance_score
top10 = [candidatos[r.index] for r in resposta.results]

E dentro do Elasticsearch, encadeando no retriever RRF do post anterior:

GET /produtos/_search
{
  "retriever": {
    "text_similarity_reranker": {
      "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": 100,
                "num_candidates": 300
            }}
          ],
          "rank_window_size": 100
        }
      },
      "field": "descricao",
      "inference_id": "meu-reranker",
      "inference_text": "tênis pra correr no calor",
      "rank_window_size": 100,
      "min_score": 0.4
    }
  },
  "size": 10
}

Repare que existem dois rank_window_size na query, e eles são coisas diferentes. O de dentro é quantos candidatos a fusão considera. O de fora é quantos desses vão para o modelo. Igualar os dois é o default razoável; deixar o de fora maior não faz nada, porque não existe candidato além da janela de fusão.

Onde reranking não resolve

Alguns casos em que a resposta honesta é não ligar isso.

Quando o recall do primeiro estágio é o problema. Reranking não recupera nada. Se o documento certo não está entre os 100 que chegaram, nenhum modelo vai inventá-lo. Antes de investir na etapa cara, meça Recall@100 da sua híbrida. Se estiver baixo, o problema é o embedding, o que você indexou, ou a janela de fusão, e reranking só vai reordenar melhor um conjunto ruim.

Quando o valor por busca é baixo e o volume é alto. É a situação típica de e-commerce de cauda longa. Milhões de buscas, margem por transação apertada, e um ganho de relevância que pode não pagar nem a conta de GPU nem a de API. Aqui o cálculo é de negócio, não de engenharia.

Quando o SLA de latência é apertado. Se seu orçamento de p95 é 100 ms e a busca já consome 40, você não tem espaço para top-100. Você tem espaço para top-20 com um modelo pequeno, e talvez nem isso.

Quando a query é um identificador. SKU-A4729 não precisa de compreensão semântica, precisa de match exato. Detecte o padrão antes do pipeline e desvie, como já valia para a híbrida.

Quando o corpus é homogêneo demais. Se seus 100 candidatos são quase idênticos entre si, não existe ordem certa a descobrir e o modelo vai desempatar ruído.

O que medir antes e depois

Você já construiu a peça mais importante no post 2: aquele conjunto de 50 a 200 queries reais com os documentos relevantes marcados à mão. É ele que decide isso.

Meça nDCG@10 e MRR da híbrida sozinha, depois da híbrida com reranking, varrendo a profundidade em 10, 20, 30, 50 e 100. O que você quer enxergar é a forma da curva, não um número isolado. Se ela satura em 30, ir até 100 é dinheiro jogado fora. Se ela cai depois de 50, você acabou de descobrir que o modelo não serve para o seu domínio, e descobriu offline, o que é bem melhor do que descobrir em produção.

Junto disso, e não depois, registre o delta de p95 e o custo por query. Ganho de relevância sem número de latência ao lado não é um resultado, é metade de um resultado.

Depois vem o A/B em produção, porque ganho offline em reranking quase sempre parece ótimo e nem sempre o usuário percebe. As métricas que respondem isso são CTR nas primeiras posições, taxa de reformulação de query e conversão. Se o CTR do top 3 sobe e a reformulação cai, o reranking está funcionando de verdade. Se nada se move, você pagou por precisão que ninguém estava esperando.

Fechando a série

Cinco coisas que eu gostaria de ter sabido antes de começar com tudo isso:

BM25 não é legado. Ele é o baseline forte que você vai passar o resto do projeto tentando bater, e em algumas tarefas não vai conseguir.

Embedding transforma texto em geometria, e é a partir dessa mudança de representação que todo o resto acontece. Mas o vetor é uma compressão com perda, e as perdas aparecem no lugar mais chato possível: identificadores, negações, queries curtas.

RRF é o melhor retorno sobre esforço da lista. Sem calibração, sem treino, consistentemente melhor que qualquer um dos dois lados sozinho.

Reranking é o único estágio que lê os dois textos juntos, e é por isso que ele ganha onde os outros não têm como ganhar. Também é o único que não pré-computa nada, e é por isso que ele só roda em cima de dezenas de candidatos, nunca do catálogo.

E o que amarra tudo: nada disso se decide por leitura de benchmark. Se decide com um conjunto de avaliação do seu próprio corpus, que dá meio dia de trabalho chato para montar e depois responde toda pergunta que vier.

Essa série cobriu o que eu queria ter sabido antes de começar com busca semântica. Se você está implementando algo parecido, me conta em que contexto. Adoraria saber.

Referências


Terceiro e último post da série sobre busca moderna. Os anteriores são Busca Semântica e Busca Híbrida com RRF.

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…