Hybrid Search + Reranking: do BM25 ao cross-encoder
- ⬜🔪 Chunking e Embeddings: as decisões que fazem ou quebram seu RAG(Engenharia AI-Native)
Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.
Naive RAG (embed + top-k + prompt) bate em ~50-60% de precisão em bases reais. Para chegar nos 85%+ que produção exige, o retrieval deixa de ser uma busca e vira um pipeline de três estágios: recuperação por múltiplos métodos, fusão de rankings, e reranking fino. Este módulo mostra cada peça, quando ela entra e quanto ela custa.
A anatomia de um retrieval de produção
- → top ~50
- → top ~5
- Fora da AWS
- Conceito de arquitetura
O desenho mostra um funil: barato em cima de muitos, caro em cima de poucos. Essa é a forma de todo sistema de recuperação sério — e a razão pela qual "aumentar o k" quase nunca é a resposta.
- 1 · As duas buscas erram em situações opostas. A vetorial não acha um código de erro exato; a léxica não acha a paráfrase. Usar só uma deixa um buraco previsível.
- 2 · As notas das duas não são comparáveis. Similaridade e relevância léxica vivem em escalas diferentes. Somá-las é somar unidades distintas — e é o erro que faz a fusão piorar o resultado.
- 3 · Fundir por POSIÇÃO resolve sem calibrar nada. Usar a ordem em vez da nota dispensa normalização e funciona surpreendentemente bem. É uma linha de código para um ganho consistente.
- 4 · O reordenador é caro porque lê os dois juntos. Ele processa pergunta e trecho no mesmo passe, o que capta relação que a comparação de vetores independentes perde. Por isso só se aplica ao topo.
- 5 · Mais trechos não é melhor. Passar vinte trechos ao gerador dilui o relevante e gasta contexto. Depois do reordenador, poucos e bons superam muitos e mornos.
- 6 · Transformar a consulta rende antes de qualquer modelo novo. Reescrever a pergunta, gerar variações ou buscar por uma resposta hipotética melhora a recuperação sem trocar índice nem modelo.
Três estágios não é overkill — é o que separa "funciona na demo" de "funciona no cliente". Cada estágio tem um trade-off claro: recall (BM25+vector), diversidade (MMR/RRF), precisão (rerank).
BM25: o clássico que se recusa a morrer
BM25 (Best Matching 25) é um refinamento de TF-IDF dos anos 90. Pontua documento pela frequência dos termos da query, com saturação (mais ocorrências não crescem linear) e normalização por tamanho do doc. Em 2026 ainda é o sparse baseline contra o qual todos os denses são comparados — e em muitos casos ganha.
| Cenário | BM25 | Dense (embedding) |
|---|---|---|
| Match por sigla/ID/SKU | Excelente — casa literalmente | Ruim — token raro some no embedding |
| Sinônimos ("carro" vs "automóvel") | Falha (lexical) | Excelente (semântico) |
| Domínio técnico com jargão raro | Forte | Depende do treino do embedder |
| Paráfrase ("como faço" vs "processo de") | Fraco | Forte |
| Custo de indexação | Quase zero (Lucene/tantivy) | Alto (embed cada chunk) |
| Latência de busca | <5ms em milhões de docs | <10ms com HNSW bem tunado |
| Multilíngue | OK com stemmer/tokenizer correto | Depende do modelo |
# BM25 com PostgreSQL full-text search (ts_rank_cd é BM25-like)
import psycopg
def bm25_search(conn, query: str, k: int = 50) -> list[dict]:
with conn.cursor() as cur:
cur.execute("""
SELECT id, chunk, ts_rank_cd(
to_tsvector('portuguese', chunk),
plainto_tsquery('portuguese', %s)
) AS score
FROM chunks
WHERE to_tsvector('portuguese', chunk)
@@ plainto_tsquery('portuguese', %s)
ORDER BY score DESC
LIMIT %s
""", (query, query, k))
return [{"id": r[0], "chunk": r[1], "score": r[2]} for r in cur.fetchall()]
# Para controle maior, use a extensão pg_bm25 (ParadeDB) ou
# um engine dedicado: Elasticsearch, OpenSearch, Tantivy, MeilisearchReciprocal Rank Fusion (RRF): o truque de uma linha
Depois de ter dois (ou mais) rankings — um do BM25, outro do vetor — você precisa fundir. Normalizar scores é frágil: a escala do BM25 muda com o corpus, a do cosine já é limitada [0,1]. RRF resolve ignorando o score e usando só a posição.
# RRF: uma função de 10 linhas que bate esquemas elaborados
from collections import defaultdict
def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""rankings: lista de listas de doc_ids ordenados por relevância."""
scores: dict[str, float] = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
# Uso
bm25_ids = [r["id"] for r in bm25_search(conn, query, k=50)]
vector_ids = [r["id"] for r in vector_search(conn, query, k=50)]
fused = rrf([bm25_ids, vector_ids], k=60)
top_candidates = [doc_id for doc_id, _ in fused[:50]]k=60 é o default empírico (do paper original de Cormack, Clarke e Büttcher, 2009). Valores menores privilegiam top positions; maiores diluem. Na prática, 40-80 funcionam; não perca tempo tunando fino sem eval harness.
Cross-encoder rerank: o ganho de 10-20% em recall@5
Embedders (bi-encoders) codificam query e doc separadamente — isso permite pré-computar embeddings e buscar rápido, mas perde informação de interação. Cross-encoder lê [query, doc] juntos, com atenção cruzada, e emite um score de relevância fino. É caro (chamada de rede por par) — por isso só roda em cima de top-50/100, reordenando para top-5/10.
| API | Modelo | Multilíngue | Preço aproximado |
|---|---|---|---|
| Cohere | rerank-v3.5 | Sim (100+ idiomas) | US$2/1k docs reordenados |
| Jina | jina-reranker-v2-base-multilingual | Sim | US$0.02/1M tokens (mais barato) |
| Voyage | rerank-2 | Sim | US$0.05/1M tokens |
| BAAI/bge-reranker-v2-m3 (open) | Self-host | Sim | Só infra (GPU small OK) |
| mixedbread-ai/mxbai-rerank-large (open) | Self-host | Limitado | Só infra |
# Cohere rerank — simples, multilíngue, produção-ready
import cohere
co = cohere.ClientV2()
def rerank(query: str, docs: list[dict], top_n: int = 10) -> list[dict]:
r = co.rerank(
model="rerank-v3.5",
query=query,
documents=[d["chunk"] for d in docs],
top_n=top_n,
)
# r.results: lista ordenada com índice do doc original + score
return [
{**docs[item.index], "rerank_score": item.relevance_score}
for item in r.results
]
# Self-host com sentence-transformers
from sentence_transformers import CrossEncoder
ce = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
def rerank_local(query: str, docs: list[dict], top_n: int = 10) -> list[dict]:
pairs = [(query, d["chunk"]) for d in docs]
scores = ce.predict(pairs, batch_size=32)
ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
return [{**d, "rerank_score": float(s)} for d, s in ranked[:top_n]]Cross-encoder não substitui retrieval — ele refina. Se top-100 do primeiro estágio não contém o doc certo, nenhum rerank salva. Recall no primeiro estágio é condição necessária.
Transformações de query: HyDE, expansion, multi-query
Query curta + doc longo = descompasso de estilo, e embedder sofre. Três técnicas atacam isso em tempo de query (antes do retrieval).
| Técnica | Como funciona | Quando vale |
|---|---|---|
| HyDE | LLM gera um "doc hipotético" respondendo à query; você embeda esse doc em vez da query | Queries curtas/ambíguas, FAQ, help docs |
| Query expansion | LLM expande a query em sinônimos e termos relacionados, concatenados | Domínio com jargão variado |
| Multi-query | LLM gera N versões diferentes da query; você busca com cada uma e funde com RRF | Queries ambíguas com múltiplas interpretações |
| Step-back | LLM gera uma pergunta mais genérica (contexto) + original; busca as duas e une | Queries muito específicas em base esparsa |
| Decomposição | LLM quebra query complexa em sub-perguntas; busca cada uma | Queries analíticas/multi-hop |
# HyDE: Hypothetical Document Embedding
from anthropic import Anthropic
client = Anthropic()
def hyde_query(user_query: str) -> str:
r = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=300,
messages=[{
"role": "user",
"content": (
"Escreva um parágrafo que responderia a esta pergunta como se "
"fosse um trecho de documentação interna. 100-150 palavras, tom "
"técnico e direto.\n\nPergunta: " + user_query
),
}],
)
return r.content[0].text
# Pipeline com HyDE
def retrieve_with_hyde(query: str, k: int = 50) -> list[dict]:
hypothetical_doc = hyde_query(query) # LLM barato
q_vec = embedder.encode(hypothetical_doc) # embeda o doc, não a query
return vector_search_by_vec(q_vec, k=k)
# Custo: +1 chamada Haiku (~US$0.0003 por query). Em alto volume,
# cacheie o HyDE por hash da query normalizada.MMR: diversidade no top-k
Problema comum: 5 chunks quase idênticos do mesmo parágrafo dominam o top-5. O LLM recebe redundância em vez de cobertura. MMR (Maximal Marginal Relevance) resolve re-rankeando por λ·relevância - (1-λ)·similaridade com já selecionados.
import numpy as np
def mmr(query_vec, doc_vecs, doc_ids, k: int = 5, lambda_: float = 0.7):
"""Maximal Marginal Relevance: rerank por relevância + diversidade."""
sim_q = doc_vecs @ query_vec # similaridade com query
selected: list[int] = []
candidates = list(range(len(doc_ids)))
for _ in range(min(k, len(candidates))):
if not selected:
best = int(np.argmax(sim_q[candidates]))
else:
sim_sel = doc_vecs[candidates] @ doc_vecs[selected].T # vs já escolhidos
max_sim_sel = sim_sel.max(axis=1)
score = lambda_ * sim_q[candidates] - (1 - lambda_) * max_sim_sel
best = int(np.argmax(score))
selected.append(candidates.pop(best))
return [doc_ids[i] for i in selected]
# lambda=1 → recall puro (sem diversificar). lambda=0 → só diversidade (sem relevância).
# 0.6-0.8 é o range útil.Por que combinar busca lexical e busca por embedding supera cada uma isolada?
Pipeline completo: código de produção
# Retrieval de 3 estágios: BM25 + vector → RRF → cross-encoder rerank
from collections import defaultdict
def retrieve(query: str, k_final: int = 8) -> list[dict]:
# 1) Query transform (opcional — HyDE se query curta)
search_query = query if len(query.split()) > 6 else hyde_query(query)
# 2) Multi-method retrieve (paralelize com asyncio em prod)
bm25_hits = bm25_search(conn, search_query, k=50)
vector_hits = vector_search(conn, search_query, k=50)
by_id = {h["id"]: h for h in (*bm25_hits, *vector_hits)}
# 3) RRF fusion
fused = rrf(
[[h["id"] for h in bm25_hits], [h["id"] for h in vector_hits]],
k=60,
)
candidates = [by_id[doc_id] for doc_id, _ in fused[:50]]
# 4) Cross-encoder rerank (top-50 → top-8)
reranked = rerank(query, candidates, top_n=k_final) # usa query original
return reranked📋 Base pequena (<100k chunks), orçamento apertado
RRF de BM25 + dense já tira 80% do ganho. Cross-encoder rerank custa dinheiro por query e em base pequena o ganho absoluto pode não compensar. Meça antes de adicionar.
Alt: Só vector + top-k — não recomendado, vai ficar em 50-60% de precisão
Alt: 3 estágios completos — se latência e custo permitirem, sempre melhor
📋 Base grande (>1M chunks), suporte/help center em PT-BR
Volume alto justifica o custo marginal do rerank. Cohere v3.5 é multilíngue de alto nível em PT-BR. HyDE costuma ajudar porque queries de suporte são curtas.
Alt: Jina reranker-v2 — mais barato, performance similar em pt-BR
Alt: bge-reranker-v2-m3 self-host — se tiver infra GPU e quiser zerar custo recorrente
Latência e orçamento de tokens
| Estágio | Latência típica | Custo por query |
|---|---|---|
| BM25 (Postgres FTS) | 5-20 ms | ~0 |
| Dense vector (pgvector HNSW) | 10-30 ms | +custo do embed (~US$0.00002) |
| RRF fusion | <1 ms | 0 |
| HyDE (Haiku 4.5) | 300-600 ms | ~US$0.0003 |
| Cohere rerank top-50 | 100-300 ms | ~US$0.0001 (50 docs) |
| Pipeline total | ~500-900 ms | ~US$0.0005-0.001 |
Paralelize BM25 e vector search (são I/O independentes). HyDE é sequencial à query — se for crítico em p95, use só quando a query tiver <6 palavras. Cache de query normalizada (hash → resultado) elimina 30-50% de custo em bases com cauda longa repetitiva (FAQ).
Metadata filtering: o filtro barato que todo mundo esquece
Antes de qualquer ML, filtre por metadata estruturada. WHERE tenant_id = ? AND lang = 'pt' AND created_at > now() - interval '90 days' reduz o espaço de busca em 90%+ e aumenta recall efetivo gratuitamente.
-- pgvector com filtro ANTES da busca vetorial
SELECT id, chunk,
1 - (embedding <=> $1::vector) AS sim
FROM chunks
WHERE tenant_id = $2
AND lang = 'pt'
AND doc_type = ANY($3::text[])
AND created_at > now() - interval '90 days'
ORDER BY embedding <=> $1::vector
LIMIT 50;
-- Para HNSW pré-filtrar bem, use índice parcial ou partitioning.
-- Em Pinecone/Weaviate, metadata filter é campo estruturado no payload.Perguntas típicas
❓ RRF vence sempre uma soma ponderada calibrada?
❓ Posso rodar cross-encoder no top-500 em vez de top-50?
❓ SPLADE e outros sparse learned valem a complicação?
❓ Reranker multilíngue tem perda em PT-BR?
❓ Hybrid search funciona em vector DB dedicado (Pinecone, Weaviate)?
Take-aways. Retrieval de produção é três estágios: multi-method retrieve (BM25+dense), RRF fusion, cross-encoder rerank. RRF é uma função de 10 linhas que substitui calibração frágil. Reranker (Cohere, Jina, Voyage ou bge self-host) entrega o ganho final de 10-20% em recall@5. HyDE e query expansion ajudam em queries curtas. Metadata filter é de graça e dobra a qualidade efetiva. Próximo: como medir tudo isso com eval harness sério — recall@k, nDCG, faithfulness e LLM-as-judge.
Perguntas frequentes
❓ Por que somar BM25 e busca vetorial?
❓ Reordenar por cross-encoder vale o custo?
❓ O que é fusão de rankings e por que ela venceu?
Fixando
A fusão por posição recíproca combina duas listas ordenadas somando o inverso da posição de cada item. Qual é a vantagem dessa abordagem?
Por que a reordenação com um modelo cruzado é aplicada só ao topo da lista, e não ao índice inteiro?
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…