Hybrid search + reranking: BM25 + dense + cross-encoder
- ⬜⚖️ OpenSearch vs Meilisearch vs Typesense: qual escolher(Search & Information Retrieval Profundo)
Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.
Por que hybrid + rerank é o padrão ouro em 2026
Em 2026, qualquer pipeline de busca sério (RAG, knowledge base, suporte, e-commerce) usa três estágios: retrieval híbrido (BM25 + dense), fusão de rankings (RRF), e reranker cross-encoder no topo. Esse padrão ganhou os benchmarks MTEB-Retrieval e BEIR consistentemente, e é o default em Elasticsearch 8.x, Vespa, Qdrant hybrid, Weaviate.
- → consulta
- → vizinhos próximos
- → ranking A
- → ranking B
- → top-50
- → top-5
- Conceito de arquitetura
Duas fases com objetivos distintos: recall barato e amplo, depois precisão caro e estreito. Quando o resultado vem ruim, meça recall@k antes de mexer no reranker — se o documento não entrou na lista, nenhum reordenamento o traz.
- Léxico e semântico falham em coisas opostas. BM25 acha `parseUserToken` e não acha "onde validamos autenticação". O vetor acha o segundo e erra o primeiro, porque embedding não representa identificador como literal. Usar um só significa aceitar a cegueira dele.
- Os dois rodam em paralelo, não em cascata. A consulta vai simultaneamente para os dois índices. Encadear seria pior: o filtro do primeiro descartaria o que o segundo acharia. Recall é união, não interseção.
- RRF combina rankings sem calibrar score. Score de BM25 e distância de cosseno não são comparáveis em escala. Reciprocal Rank Fusion usa apenas a POSIÇÃO em cada lista, o que dispensa normalização e é robusto — é por isso que virou o padrão.
- O rerank é onde a precisão aparece. O cross-encoder olha consulta e documento JUNTOS, não como vetores independentes — muito mais preciso e muito mais caro. Aplicá-lo a 50 candidatos é viável; a 50 mil documentos, não. Daí a arquitetura em duas fases.
- Cada fase otimiza uma métrica diferente. As buscas maximizam recall@50 — trazer o documento certo para dentro da lista. O rerank maximiza precisão no topo. Medir as duas separadamente é o que permite saber qual fase quebrou quando o resultado piora.
A intuição: cada estágio cobre uma limitação do anterior. BM25 acerta matches lexicais exatos. Dense acerta paráfrase e semântica. Cross-encoder corrige ambiguidades no topo. Compor os três é mais robusto que qualquer um sozinho.
Referências: Cormack, Clarke & Buettcher (SIGIR 2009) "Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods"; Thakur et al. (2021) "BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models"; Cohere Rerank docs; BGE-reranker v2 (BAAI/bge-reranker-v2-m3).
Arquitetura: 3 estágios
Reciprocal Rank Fusion: a fórmula que ganhou
Por que k=60? Cormack et al. testaram k ∈ {1, 10, 60, 100} em TREC tasks. k=60 produziu melhor MAP. Intuitivamente: k=60 significa que rank=1 vale ~1.6× mais que rank=10, mas só ~1.06× mais que rank=2. Suaviza o topo sem dar peso desproporcional ao primeiro lugar.
from collections import defaultdict
from typing import Sequence
def rrf_fusion(rankings: Sequence[Sequence[str]], k: int = 60, top_n: int = 100):
"""
rankings: lista de rankings, cada ranking é lista de doc_ids ordenada (rank 1 = posição 0)
Retorna: lista de (doc_id, fused_score) ordenada.
"""
scores: dict[str, float] = defaultdict(float)
for ranking in rankings:
for rank0, doc_id in enumerate(ranking):
scores[doc_id] += 1.0 / (k + rank0 + 1) # rank é 1-indexed
return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n]
# Exemplo
bm25_ranking = ["doc_42", "doc_3", "doc_17", "doc_91", "doc_8"] # top-5 BM25
vector_ranking = ["doc_3", "doc_91", "doc_42", "doc_7", "doc_17"] # top-5 dense
fused = rrf_fusion([bm25_ranking, vector_ranking], k=60, top_n=5)
# Resultado:
# [('doc_3', 0.0326), ('doc_42', 0.0323),
# ('doc_17', 0.0320), ('doc_91', 0.0322), ('doc_7', 0.0156)]
# doc_3 e doc_42 dominam porque aparecem alto em AMBOS os rankingsWeighted RRF e variantes (quando tunar paga)
O RRF puro trata todos os rankings com peso igual. Em alguns casos, BM25 ou dense devem pesar mais. A variante weighted é simples:
def weighted_rrf(rankings_with_weights, k=60, top_n=100):
"""
rankings_with_weights: [(ranking, peso), ...]
"""
scores = defaultdict(float)
for ranking, w in rankings_with_weights:
for rank0, doc_id in enumerate(ranking):
scores[doc_id] += w / (k + rank0 + 1)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n]
# Caso: domínio técnico, BM25 é mais valioso para SKUs e códigos
fused = weighted_rrf([
(bm25_ranking, 0.7),
(vector_ranking, 0.3),
], k=60, top_n=20)
# Pesos devem ser calibrados via golden set!Não chute pesos. Calibrá-los exige golden set de 50-200 queries anotadas. Grid search rápido em {0.1, 0.3, 0.5, 0.7, 0.9} por ranking. Métrica: NDCG@10 ou MRR. Sem isso, weighted RRF pode piorar em vez de melhorar. RRF default (peso uniforme) já é um excelente baseline.
Cross-encoder reranker: o que faz
Bi-encoders (modelos de embedding) codificam query e doc separadamente. Cross-encoders processam (query, doc) juntos numa única passada de transformer, com atenção cruzada full. O resultado é um score muito mais contextualizado — entende melhor quando o doc responde à query, não só quando tem palavras similares.
| Aspecto | Bi-encoder (retrieval) | Cross-encoder (rerank) |
|---|---|---|
| Como funciona | Embed(query) e embed(doc) separados, similaridade via produto escalar | (query, doc) → transformer com cross-attention → score |
| Pré-computa embeddings? | Sim — embed o corpus uma vez | Não — depende da query, recomputa sempre |
| Latência por par | ~1ms (lookup ANN) | ~30-100ms em GPU, ~200-500ms em CPU |
| Escala | Milhões+ via HNSW/IVF | Top-50 a top-200 só |
| Precisão (NDCG@10) | ~0.60-0.70 | ~0.75-0.85 |
| Exemplos | BGE-M3, e5, Voyage-3, OpenAI text-embedding-3 | BGE-reranker-v2-m3, Cohere Rerank, Qwen reranker |
Reranker em código: BGE-reranker e Cohere
# Opção 1: Cohere Rerank (API gerenciada, multilingual)
import cohere
co = cohere.Client() # COHERE_API_KEY no env
candidates = [{"id": d, "text": doc_texts[d]} for d in fused[:50]]
response = co.rerank(
model="rerank-multilingual-v3.0",
query=query,
documents=[c["text"] for c in candidates],
top_n=10,
)
final_ids = [candidates[r.index]["id"] for r in response.results]
# Opção 2: BGE-reranker local (GPU, sem custo por query)
from sentence_transformers import CrossEncoder
model = CrossEncoder("BAAI/bge-reranker-v2-m3", device="cuda")
pairs = [(query, doc_texts[d]) for d in fused[:50]]
scores = model.predict(pairs, batch_size=32)
ranked = sorted(zip(fused[:50], scores), key=lambda x: x[1], reverse=True)
final_ids = [d for d, _ in ranked[:10]]
# Opção 3: Qwen reranker (multilingual, ótimo PT-BR em 2026)
from FlagEmbedding import FlagReranker
reranker = FlagReranker("Qwen/Qwen2.5-Reranker-0.5B", use_fp16=True)
scores = reranker.compute_score([[query, doc_texts[d]] for d in fused[:50]])Custo prático 2026: Cohere Rerank ~$2/1k queries. BGE-reranker em GPU A10/L4 amortizado ~$0.5/h, processa ~30 queries/segundo (top-50 cada). Para tráfego > 100 QPS, GPU local geralmente vence. Para tráfego baixo ou variável, API é mais simples.
Qual é a arquitetura em três estágios do padrão atual de busca?
Pipeline completo (production-ready)
from elasticsearch import Elasticsearch
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer, CrossEncoder
from collections import defaultdict
# 1. Inicialização
es = Elasticsearch(["https://es.internal:9200"])
qdrant = QdrantClient(url="https://qdrant.internal")
embedder = SentenceTransformer("BAAI/bge-m3")
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", device="cuda")
def hybrid_search(query: str, top_k: int = 10, retrieval_k: int = 100, rerank_k: int = 50):
# 2a. BM25 via Elasticsearch
es_resp = es.search(
index="docs",
body={"query": {"match": {"content": query}}, "size": retrieval_k},
timeout="2s",
)
bm25_ids = [hit["_id"] for hit in es_resp["hits"]["hits"]]
# 2b. Dense via Qdrant
q_emb = embedder.encode(query, normalize_embeddings=True).tolist()
dense_resp = qdrant.search(
collection_name="docs",
query_vector=q_emb,
limit=retrieval_k,
)
dense_ids = [hit.id for hit in dense_resp]
# 3. RRF fusion
fused_scores: dict[str, float] = defaultdict(float)
for ranking in [bm25_ids, dense_ids]:
for rank0, doc_id in enumerate(ranking):
fused_scores[doc_id] += 1.0 / (60 + rank0 + 1)
fused = [d for d, _ in sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)][:rerank_k]
# 4. Cross-encoder rerank dos top-K fundidos
doc_texts = fetch_doc_texts(fused) # batch fetch do storage
pairs = [(query, doc_texts[d]) for d in fused]
rerank_scores = reranker.predict(pairs, batch_size=32, show_progress_bar=False)
final = sorted(zip(fused, rerank_scores), key=lambda x: x[1], reverse=True)[:top_k]
return [{"id": d, "score": float(s)} for d, s in final]
# Latência típica em produção:
# - ES BM25: 5-15 ms
# - Qdrant ANN: 5-20 ms (em paralelo com ES, mesmo tempo total)
# - RRF (in-memory): <1 ms
# - Reranker (GPU): 100-200 ms (top-50 em batch)
# - Total: ~120-230 ms p95Quando reranker vale (e quando não)
📋 RAG para Q&A, suporte, knowledge base, customer service
Queries conversacionais ganham muito de cross-encoder; Custo de errar é alto (LLM gera resposta baseada nos contextos); Ganho típico NDCG@10: +20-40%; Latência total ~200ms ainda é OK para Q&A
📋 Busca em e-commerce com SKUs, códigos, marcas
Queries são específicas, BM25 já entrega top-1 correto frequentemente; Latência sub-50ms é mandatória para instant search; Reranker adiciona 100-300ms que quebra a UX; Faceted search e filtros importam mais que reranker
Alt: Se o catálogo tiver queries de descoberta longas ("notebook leve para programação"), aí reranker volta a fazer sentido
📋 Busca em código fonte (repo enterprise)
Busca em código é dominada por token literal: nome de função, variável, path, mensagem de erro, assinatura. Quem procura `parseUserToken` quer aquele símbolo, não "algo relacionado a autenticação de usuário" — e é exatamente isso que o vetor devolve. BM25 acerta o identificador exato, e o componente semântico entra como rede para busca em linguagem natural sobre o repo ("onde a gente valida CPF?"). O rerank só se paga quando o candidato de topo ainda vem embaralhado; em busca por símbolo, o BM25 já ordena bem e o rerank adiciona latência sem ganho.
Alt: Só busca vetorial — Falha no caso mais comum: procurar um identificador exato. O embedding não representa `parseUserToken` como literal, e sim como um ponto num espaço semântico onde dezenas de funções de auth são vizinhas.
Alt: Só BM25 — Resolve símbolo e falha em pergunta conceitual sobre o repo, que é justamente onde a busca em código é mais útil para quem chegou agora no projeto.
Alt: Híbrido com rerank sempre ligado — Adiciona uma chamada de modelo e latência em toda consulta, inclusive nas de símbolo exato, em que o BM25 já entrega a ordem certa. Custo sem retorno.
Métricas: como saber se o pipeline melhorou
Benchmark típico em corpora reais: BM25 sozinho NDCG@10 ~0.55. Dense sozinho ~0.60. Hybrid RRF ~0.68. Hybrid + rerank ~0.78. Ganhos compostos reais — mas variam por domínio. Sempre meça no seu golden set, não confie em benchmark alheio.
Perguntas frequentes
❓ Posso usar mais de 2 rankings em RRF?
❓ RRF vs CombSUM vs CombMNZ?
❓ Posso treinar um reranker no meu domínio?
❓ Reranker pode atrapalhar?
Resumo executivo
Hybrid retrieval + RRF + cross-encoder rerank é o padrão ouro de 2026. RRF resolve o problema de combinar scores incomparáveis. Cross-encoder corrige top-K com precisão muito superior. Pipeline disponível out-of-the-box em ES 8, Qdrant, Weaviate, Vespa.
Próximo: o lado "dense" do pipeline — quais embeddings de busca são state-of-the-art em 2026 (BGE-M3, e5, Voyage, Cohere v3) e como escolher.
Fixando
Por que a fusão por posição é preferida a somar pontuações normalizadas?
Quando afinar os pesos entre os dois métodos de recuperação vale o esforço?
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…