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
Query
│
├────────────┬────────────────┬─────────────┐
▼ ▼ ▼ ▼
BM25 Dense vector Sparse (SPLADE) Metadata filter
(lexical) (semantic) (learned sparse) (date, type, acl)
│ │ │ │
└────────────┴────────────────┴─────────────┘
│
▼
┌──────────────────────┐
│ Rank fusion (RRF) │ → top-50/100
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Cross-encoder │ → top-5/10
│ rerank (Cohere/Jina)│
└──────────┬───────────┘
│
▼
Contexto final
p/ LLM gerar
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]]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]]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.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 |
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)?
Quiz rápido
4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito