BM25 e TF-IDF: a math da busca clássica
Ao terminar: Você entende por que BM25 ainda vence em produção e quando combiná-lo com busca vetorial em vez de escolher só um dos dois.
Por que entender BM25 em 2026
Em 2026, busca virou sinônimo de embeddings densos. Toda startup quer "RAG com pgvector". E quase todas redescobrem a mesma verdade que a literatura de Information Retrieval (IR) tem desde os anos 90: BM25 é insuperável em matches lexicais exatos — códigos SKU, nomes próprios, error codes, acrônimos novos, jargão técnico que o encoder nunca viu. Por isso pipelines state-of-the-art (Elasticsearch, Vespa, Qdrant hybrid) combinam BM25 + dense + reranker.
Este módulo desmistifica a matemática: de onde vem TF-IDF (Spärck Jones 1972), por que BM25 (Robertson 1994 no TREC-3) ganhou, e o que k1 e b significam de verdade. Sem isso, você só copia "Elasticsearch defaults" sem saber tunar quando produção começar a doer.
Referências de cabeceira: Robertson & Walker (1994) "Some Simple Effective Approximations to the 2-Poisson Model"; Robertson & Zaragoza (2009) "The Probabilistic Relevance Framework: BM25 and Beyond"; Manning, Raghavan & Schütze, "Introduction to Information Retrieval" (Stanford, gratuito online).
Linha do tempo: TF-IDF → BM25 → híbrido
TF-IDF: a fórmula original
A intuição é simples e elegante. Um termo t num documento d tem peso proporcional a:
- TF (term frequency) — quantas vezes aparece em . Mais vezes → mais provável que o doc seja sobre o termo.
- IDF (inverse document frequency) — log(N / df), onde N é o total de docs e df é em quantos docs o termo aparece. Termos raros são mais informativos.
Funciona surpreendentemente bem como baseline. Mas tem dois problemas que BM25 resolve:
- TF linear é injusto — um doc com 30 ocorrências do termo não é 3× mais relevante que outro com 10.
- Não normaliza tamanho — documentos longos naturalmente acumulam TF maior sem necessariamente serem mais relevantes.
BM25: a evolução probabilística
Robertson e Sparck Jones derivaram BM25 do modelo probabilístico de relevância (Probabilistic Relevance Framework). A ideia: dado uma query, qual é a probabilidade de um documento ser relevante? A derivação completa envolve o modelo 2-Poisson e simplificações práticas. O resultado:
A peça mais elegante é TF · (k₁+1) / (TF + k₁). Para TF=0 dá 0; para TF→∞ tende a k₁+1. Curva côncava: primeiras ocorrências contam muito, repetições marginais quase nada. É exatamente como humanos julgam relevância.
Visualizando saturação de TF
import numpy as np
import matplotlib.pyplot as plt
def bm25_tf_component(tf, k1, b, dl, avgdl):
"""Componente de TF do BM25, sem o IDF."""
return (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl))
tf_range = np.arange(0, 30)
plt.figure(figsize=(8, 5))
for k1 in [0.0, 0.5, 1.2, 2.0, 5.0]:
y = [bm25_tf_component(tf, k1, 0.75, 100, 100) for tf in tf_range]
plt.plot(tf_range, y, label=f"k1={k1}")
plt.xlabel("TF (term frequency)")
plt.ylabel("Componente BM25 (sem IDF)")
plt.title("Saturação de TF para diferentes k1")
plt.legend(); plt.grid(True); plt.show()
# Observações:
# k1=0 → função binária (TF importa só se ≥1)
# k1=0.5 → satura muito rápido, basicamente binária
# k1=1.2 → default Lucene, saturação suave
# k1=2.0 → mais peso para TF alto
# k1=5.0 → quase linear (TF puro) Esse gráfico explica visualmente por que k1=1.2 virou default: ele dá peso a primeiras ocorrências, mas após ~5 menções o ganho marginal é insignificante. Isso bate com a intuição humana de relevância.
Length normalization: por que b importa
Sem normalizar tamanho, um livro de 800 páginas sobre "Postgres" sempre venceria um post de blog focado no tópico — só porque o livro tem TF maior em absoluto. Mas o post pode ser mais útil para o usuário. O parâmetro b ajusta isso:
| b | Comportamento | Quando usar |
|---|---|---|
| 0 | Sem normalização — favorece docs longos | Corpora homogêneo em tamanho (logs, tweets) |
| 0.25 | Penalização leve | Documentos curtos a médios (FAQs, descrições) |
| 0.75 | Default Lucene/ES — sweet spot empírico | Corpora natural mista (Wikipedia, blogs, artigos) |
| 1.0 | Penalização total — TF normalizado por avgdl | Quando você quer ranking quase TF-relativo |
Em corpora muito heterogêneo (mistura de docs de 50 tokens e 50.000), considere indexar em campos separados (title, body, abstract) com BM25F — cada campo com seu próprio b — em vez de tunar um b global no escuro.
Por que entender a pontuação lexical clássica ainda importa numa era de embeddings?
IDF probabilístico do BM25
A variante de IDF do BM25 não é o log(N/df) clássico. É:
Note o detalhe perigoso: se um termo aparece em mais de N/2 documentos, esse IDF fica negativo. Isso significaria que conter o termo diminui o score do doc. Em Lucene moderno, isso é clampado a zero (via log(1 + (N − df + 0.5) / (df + 0.5))) para evitar comportamento contraintuitivo. É um detalhe que pega muita gente desavisada que tenta "reimplementar BM25 do zero" sem ler o source do Lucene.
BM25 em código Python (didático)
from collections import Counter
from math import log
from typing import Sequence
class BM25:
"""
Implementação didática de BM25. Não use em produção —
use Lucene/Elasticsearch/OpenSearch, que tem otimizações
de índice invertido, posting lists comprimidas, skip lists.
"""
def __init__(self, corpus: Sequence[Sequence[str]], k1: float = 1.2, b: float = 0.75):
self.corpus = corpus
self.k1 = k1
self.b = b
self.N = len(corpus)
self.avgdl = sum(len(d) for d in corpus) / self.N
# document frequency por termo
self.df: dict[str, int] = {}
for doc in corpus:
for term in set(doc):
self.df[term] = self.df.get(term, 0) + 1
# IDF pré-calculado (com clamp ≥ 0)
self.idf = {
t: max(log((self.N - df + 0.5) / (df + 0.5) + 1.0), 0.0)
for t, df in self.df.items()
}
def score(self, query: Sequence[str], doc_idx: int) -> float:
doc = self.corpus[doc_idx]
tf = Counter(doc)
dl = len(doc)
score = 0.0
for t in query:
if t not in tf:
continue
idf = self.idf.get(t, 0.0)
tf_t = tf[t]
norm = 1 - self.b + self.b * dl / self.avgdl
score += idf * (tf_t * (self.k1 + 1)) / (tf_t + self.k1 * norm)
return score
def search(self, query: Sequence[str], top_k: int = 10):
scores = [(i, self.score(query, i)) for i in range(self.N)]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
# Exemplo de uso
corpus = [
["postgres", "mvcc", "vacuum", "vacuum", "tuple"],
["mysql", "innodb", "redo", "log"],
["postgres", "wal", "wal", "checkpoint"],
["sqlite", "wal", "journal"],
]
bm25 = BM25(corpus)
print(bm25.search(["postgres", "wal"], top_k=3))Por que BM25 + BGE-M3 ainda combinam em 2026
Quando NÃO usar BM25
📋 Busca em produção
BM25 sozinho falha em paráfrase e sinônimo; dense sozinho falha em out-of-vocabulary e exact match. Pipeline híbrido é estado da arte — Elasticsearch 8, Vespa, Qdrant, Weaviate todos suportam.
Alt: Só BM25 — ok para busca interna em logs/tickets onde tokens são exatos
Alt: Só dense — ok para Q&A puramente conversacional em domínio fechado e bem coberto pelo encoder
Alt: Sem busca — às vezes basta filtrar por metadados (data, tag, autor) — não force retrieval onde não há query
Perguntas frequentes
❓ Posso treinar o BM25 como um modelo?
❓ BM25 lida com stemming, stop words, lowercase?
❓ E sobre busca em outras línguas?
❓ Como BM25 trata frases ('exact match')?
Resumo executivo
BM25 é uma fórmula de ~6 termos calibrada empiricamente desde 1994 e ainda é o estado da arte em retrieval lexical. Tem 2 hiperparâmetros (k1, b), defaults sensatos (1.2, 0.75), e funciona out-of-the-box em Lucene, Elasticsearch, OpenSearch, Vespa, Solr, MeiliSearch (em parte) e Postgres tsvector (parcialmente).
Em 2026, o piso de qualquer pipeline de busca sério é: BM25 + dense embeddings + RRF + cross-encoder rerank. Quem ignora BM25 reaprende na pele que embeddings densos falham em out-of-vocabulary. Próximo módulo: como o Lucene/Elasticsearch implementa BM25 internamente — segments, inverted index, posting lists.
Fixando
Qual limitação da fórmula original a evolução probabilística corrigiu?
Por que a frequência inversa nos documentos é essencial?
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…