🧠FFVAcademy
🎯

Hybrid Search + Reranking: do BM25 ao cross-encoder

18 min de leitura·+90 XP
Pré-requisitos (0/1)0%

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

🗺️ Pipeline RAG 2025-2026 (three-stage retrieval)

   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
💡
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árioBM25Dense (embedding)
Match por sigla/ID/SKUExcelente — casa literalmenteRuim — token raro some no embedding
Sinônimos ("carro" vs "automóvel")Falha (lexical)Excelente (semântico)
Domínio técnico com jargão raroForteDepende do treino do embedder
Paráfrase ("como faço" vs "processo de")FracoForte
Custo de indexaçãoQuase zero (Lucene/tantivy)Alto (embed cada chunk)
Latência de busca<5ms em milhões de docs<10ms com HNSW bem tunado
MultilíngueOK com stemmer/tokenizer corretoDepende do modelo
python
# 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, Meilisearch

Reciprocal 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.

python
# 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.

APIModeloMultilínguePreço aproximado
Coherererank-v3.5Sim (100+ idiomas)US$2/1k docs reordenados
Jinajina-reranker-v2-base-multilingualSimUS$0.02/1M tokens (mais barato)
Voyagererank-2SimUS$0.05/1M tokens
BAAI/bge-reranker-v2-m3 (open)Self-hostSimSó infra (GPU small OK)
mixedbread-ai/mxbai-rerank-large (open)Self-hostLimitadoSó infra
python
# 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écnicaComo funcionaQuando vale
HyDELLM gera um "doc hipotético" respondendo à query; você embeda esse doc em vez da queryQueries curtas/ambíguas, FAQ, help docs
Query expansionLLM expande a query em sinônimos e termos relacionados, concatenadosDomínio com jargão variado
Multi-queryLLM gera N versões diferentes da query; você busca com cada uma e funde com RRFQueries ambíguas com múltiplas interpretações
Step-backLLM gera uma pergunta mais genérica (contexto) + original; busca as duas e uneQueries muito específicas em base esparsa
DecomposiçãoLLM quebra query complexa em sub-perguntas; busca cada umaQueries analíticas/multi-hop
python
# 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.

python
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

python
# 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

BM25 + vector + RRF (sem rerank)

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-knão recomendado, vai ficar em 50-60% de precisão

Alt: 3 estágios completosse latência e custo permitirem, sempre melhor

📋 Base grande (>1M chunks), suporte/help center em PT-BR

3 estágios completos com Cohere rerank-v3.5

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-v2mais barato, performance similar em pt-BR

Alt: bge-reranker-v2-m3 self-hostse tiver infra GPU e quiser zerar custo recorrente

Latência e orçamento de tokens

EstágioLatência típicaCusto 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 ms0
HyDE (Haiku 4.5)300-600 ms~US$0.0003
Cohere rerank top-50100-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.

sql
-- 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?

Não, mas a soma ponderada exige calibração por corpus (normalizar BM25 entre 0-1, por exemplo) e re-calibra toda vez que o corpus muda. RRF é zero-config e robusto — vale a pequena perda de ótimo teórico pela estabilidade operacional.

Posso rodar cross-encoder no top-500 em vez de top-50?

Pode, mas a curva de ganho achata rápido. Cohere/Jina cobram por doc reordenado. Em benchmarks públicos, top-100 captura ~95% do ganho que top-500 daria. Use top-100 como default; só suba se eval mostrar que o doc certo vive além disso.

SPLADE e outros sparse learned valem a complicação?

Em alguns domínios, sim — SPLADE combina match lexical com expansão semântica aprendida. Mas custo de indexação e integração é alto. Para 95% dos casos, BM25 + dense + RRF cobre bem. Considere SPLADE só depois de ter eval harness e identificar gap específico.

Reranker multilíngue tem perda em PT-BR?

Pequena. Cohere rerank-v3.5 e Jina reranker-v2 são fortes em PT-BR. bge-reranker-v2-m3 open-source também. Sempre valide no seu domínio — um golden set de 100 queries pt-BR é suficiente para decidir.

Hybrid search funciona em vector DB dedicado (Pinecone, Weaviate)?

Depende. Weaviate e Qdrant têm hybrid nativo (BM25 + vetor + RRF built-in). Pinecone historicamente é só vetor — hybrid exige sparse vector separado ou engine externo. Em pgvector, BM25 vem do próprio Postgres FTS, por isso é o combo mais simples e completo.
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.
🧩

Quiz rápido

4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito

Continue lendo