Busca híbrida: quando a busca por significado erra siglas e nomes
O que fazer quando a busca por significado erra siglas, códigos e nomes próprios, combinando-a com a busca por palavras.
Publicada em · 61 min de leitura
A busca por significado é ótima para “quero meu dinheiro de volta” e mais frágil quando a resposta depende de um token raro, como um código ou uma sigla. Esta aula combina a busca por palavras (lexical) com a busca por significado e explica como fundir as duas listas. Ao terminar, você deve entender quando cada busca erra sozinha, como o BM25 e o vetor se complementam, como fundir listas com RRF, como medir a combinação e por que os dois índices precisam ser atualizados juntos.
A aula parte do zero, mas pressupõe o que é um embedding (Embeddings) e um banco vetorial (Banco de dados vetorial). A escada vai de uma analogia com a lista telefônica até uma consulta híbrida em SQL e no Qdrant, passando por um experimento com 72 perguntas sobre 89 pedaços. Todo o código das seções 3 a 15 foi executado por mim, numa máquina com macOS e Python 3.12, e as saídas coladas são as que os programas imprimiram. O que muda com o tempo (parâmetros, versões, nomes de campos) vem da documentação consultada em 15 de outubro de 2026, e o que eu não consegui verificar está declarado na seção de fontes. As figuras são animadas e foram geradas com o ArchFlow.
Mapa da aula
| Parte | Seções | Pergunta que responde |
|---|---|---|
| 1. O problema | 1 a 4 | Por que duas buscas, onde a vetorial falha e onde a lexical falha? |
| 2. Busca lexical | 5 a 8 | Como funciona o índice invertido, a tokenização em português, o TF-IDF e o BM25? |
| 3. Combinar | 9 a 13 | Como juntar duas listas: ranking recíproco, pontos normalizados, pesos e profundidade? |
| 4. Esparsos e bancos | 14 e 15 | O que são vetores esparsos e o que cada banco oferece? |
| 5. Decidir e fechar | 16 a 21 | Quando não vale a pena, como medir, que erros evitar, exercícios e glossário? |
Pré-requisitos: Busca vetorial e Ingestão de documentos. Os diagramas seguem um mapa de cores fixo, com duas cores a mais: lima (o tema neon do ArchFlow) para a busca lexical, que é metade do assunto desta aula, e índigo (o tema stripe) para a fusão e a busca híbrida, que é a outra metade. Laranja continua sendo o que a pessoa controla (as perguntas), terracota é a ingestão, roxo é o corte, azul são vetores, índices e decisões, verde são pedaços e resultados bons, cinza é o que é neutro e rosa é o que dá errado ou ainda não foi ensinado (o reranking aparece em rosa tracejado na primeira figura).
Parte 1: O problema
1. O bibliotecário e a lista telefônica
Imagine dois funcionários de uma empresa. O primeiro é um bibliotecário que leu tudo: se você diz “quero meu dinheiro de volta”, ele entende que você fala de reembolso, de estorno, de devolução, mesmo que nenhuma dessas palavras tenha saído da sua boca. O segundo só sabe usar uma lista telefônica: se você diz “ERR-4018”, ele vai direto à página onde está escrito exatamente ERR-4018, e se você diz “dinheiro de volta” ele não encontra nada, porque essa expressão não está na lista. Cada um resolve um tipo de pergunta que o outro resolve mal, e quem monta um RAG costuma só contratar o primeiro.
A busca por significado é o bibliotecário: transforma o texto em vetor e procura vizinhos. A busca lexical, também chamada busca por palavras ou por palavras-chave, é a lista telefônica: procura os termos da pergunta dentro dos termos dos pedaços. A busca híbrida é pôr os dois para trabalhar na mesma pergunta e juntar as duas listas de respostas numa só.
A figura mostra onde isso se encaixa no que você já construiu. Em cima, a escrita: a ingestão (terracota) entrega texto limpo ao chunking (roxo), que passa os pedaços por dois caminhos. No azul, o embedding vira vetor e entra no índice vetorial. No lima, a tokenização vira termos e entra no índice invertido (a estrutura da seção 5). Embaixo, a leitura: a pergunta laranja é processada pelos mesmos dois caminhos, cada busca devolve os seus 20 melhores, e o losango índigo, que pulsa porque é o assunto de hoje, funde as duas listas. Os pedaços finais (verde) seguem para o reranking (rosa tracejado), tema de Reranking. As linhas pontilhadas entre os índices e as buscas dizem que a busca só consulta o que a escrita preparou antes.
2. Para que serve
Antes dos embeddings, toda busca de texto era lexical. Mecanismos de busca, bancos de dados com full text search e ferramentas como o grep contam e comparam palavras, e fazem isso há décadas, de forma rápida e previsível. O problema delas é o vocabulário: quem pergunta “desistir da compra” não encontra o pedaço que diz “prazo de arrependimento”, porque as palavras são outras. Os embeddings foram uma resposta a isso: aproximam textos que falam da mesma coisa com palavras diferentes.
Só que os embeddings trouxeram um problema novo, e ele aparece sempre que a pergunta depende de um símbolo e não de um assunto. Os exemplos desta aula vêm do e-commerce fictício Mercado Aurora, o mesmo de Chunking e Ingestão de documentos. Um código de erro (ERR-4018 é “cartão vencido”, ERR-4017 é “pagamento recusado pelo emissor”) tem aparência quase idêntica ao vizinho e significados diferentes. Um código de produto (AUR-TV55-QLED contra AUR-TV55-OLED) difere por uma letra. Uma sigla (P1 é loja fora do ar, P2 é funcionalidade degradada) diz muito pouco ao modelo sobre qual é qual. Um nome próprio raro, como a Transportadora Rapidão Tupã, pode nem existir no vocabulário em que o modelo foi treinado, e um valor como R$ 18,90 é só um número entre vários. Em todos esses casos a resposta está numa sequência exata de caracteres, e comparar sequências exatas é o que a busca lexical faz melhor.
O outro lado também existe, e é o que justifica ter as duas. “Posso devolver uma cueca?” quer chegar ao pedaço sobre “roupas íntimas”, “minha encomenda atrasou” quer chegar ao crédito por atraso, e nenhuma dessas perguntas tem palavras em comum suficientes com a resposta. A seção 4 mede quanto cada busca erra de cada lado.
3. Onde a busca vetorial falha, medido
Para não discutir no escuro, montei um laboratório pequeno. O corpus tem 89 pedaços do Mercado Aurora: 60 escritos por mim (políticas de troca e entrega, erros de pagamento, produtos, siglas do suporte, cabeçalhos e endpoints da API) e 29 gerados por molde, que reproduzem o que há em qualquer documentação real, tabelas de códigos de erro (5 linhas por pedaço) e catálogos de produtos (4 linhas por pedaço). Sem essas tabelas, o corpus pequeno deixaria a busca vetorial confortável demais. As perguntas são 72, cada uma com um único pedaço certo, o pedaço-ouro, e separadas em três categorias: 36 de código (dependem de um código, sigla, nome ou número exato, como “o que significa o erro ERR-4018?” ou “garantia do AUR-NB14-I7-A”), 20 de paráfrase (quase sem palavras em comum com a resposta, como “a máquina de café apaga sozinha?”) e 16 mistas (palavras comuns com um termo raro, como “a TV OLED de 55 polegadas tem garantia de quanto tempo?”). Escrevi as 58 primeiras antes de rodar qualquer método e acrescentei as 14 perguntas sobre as tabelas geradas depois, sorteadas por seed. O corpus e as perguntas são meus e pequenos, e isso limita o que as medidas provam, como a seção 17 detalha.
As métricas são estas: hit@k é a fração das perguntas em que o pedaço certo aparece entre os k primeiros resultados, e o MRR é a média do inverso da posição do pedaço certo. Quando digo “acerto” sem k, falo de hit@1. O modelo de embedding é o multilingual-e5-small, com os prefixos query: e passage: , e comparei também com o paraphrase-multilingual-MiniLM-L12-v2.
A primeira surpresa foi que o e5-small se saiu bem nos códigos. A segunda foi que o MiniLM se saiu muito mal. A tabela completa vem na seção 4, mas vale olhar já o caso de código:
== multilingual-e5-small
'ERR-4018' -> 3 peças: ['▁ERR', '-40', '18']
'AUR-TV55-QLED' -> 8 peças: ['▁A', 'UR', '-', 'TV', '55', '-', 'Q', 'LED']
'X-Aurora-Idempotencia' -> 10 peças: ['▁X', '-', 'A', 'ur', 'ora', '-', 'I', 'dem', 'pot', 'encia']
'Rapidão Tupã' -> 5 peças: ['▁Rapid', 'ão', '▁Tu', 'p', 'ã']
consultas de código: margem (cosseno do ouro menos o do melhor concorrente) mediana +0.018, mínima -0.007; ouro negativo em 3 de 36
cosseno do ouro: mediana 0.898; do melhor concorrente: mediana 0.878
== paraphrase-multilingual-MiniLM-L12-v2
consultas de código: margem (cosseno do ouro menos o do melhor concorrente) mediana -0.032, mínima -0.273; ouro negativo em 20 de 36
cosseno do ouro: mediana 0.543; do melhor concorrente: mediana 0.543
O que essa saída mostra, passo a passo. Um modelo de embedding não lê “ERR-4018” como uma palavra: o tokenizador dele, que foi treinado para cobrir muitas línguas, corta o texto em subpalavras, e um código vira três peças (▁ERR, -40, 18) e um nome como Rapidão Tupã vira cinco, em que só o ▁Rapid e o ão lembram português. Os dois modelos que usei dividem os exemplos do mesmo jeito, então o corte em si não explica a diferença entre eles. O que muda é o que a rede aprendeu a fazer com essas peças. O e5 mantém alguma sensibilidade à sequência exata, e por isso o pedaço certo ganha na maioria das consultas de código, mas a vantagem é pequena: o cosseno do pedaço certo é 0,898 em mediana e o do melhor concorrente é 0,878, uma folga de 0,018. O MiniLM, treinado para paráfrase, parece tratar ERR-4018 e ERR-4021 como quase a mesma coisa (é uma interpretação minha das medidas), e o pedaço certo perde para um concorrente em 20 das 36 consultas de código.
A figura mostra uma das três consultas de código que o e5 erra, “o que significa o erro ERR-5017?”. Na faixa de cima, a pergunta (laranja) passa pelo tokenizador do modelo, que a parte em ERR, -50 e 17, e vira um vetor azul que pulsa. Os três pedaços mais próximos têm cossenos de 0,904, 0,899 e 0,892, uma diferença de 0,012 entre o primeiro e o último. O primeiro lugar (rosa, seta grossa) é o pedaço do erro ERR-4017, que tem outra causa. O pedaço certo, a tabela que contém ERR-5017, fica em segundo. Na faixa de baixo, a busca por palavras parte dos termos da pergunta (erro, err, 5017, err-5017, que a seção 6 explica) e dá 8,6 pontos à tabela certa contra 5,0 para o segundo colocado, que só casa com a palavra “erro”. Repare nas escalas: o vetor distingue os candidatos em milésimos, e a busca por palavras os distingue com uma razão de quase dois para um. Medi essa razão em todas as consultas de código: o BM25 dá ao pedaço certo, em mediana, 2,29 vezes a pontuação do melhor concorrente, e no pior caso 1,48 vez.
O e5 acertou 92% das consultas de código, então embeddings servem para elas em parte. A margem, porém, é fina e depende do modelo (o MiniLM acertou 44%), e num acervo maior, com mais códigos parecidos, uma margem de 0,018 aguenta pouca coisa. Testei isso: acrescentei até 300 catálogos de códigos novos (que nenhuma pergunta usa) e o acerto do e5 nas consultas de código foi 92%, 92%, 86% e 92% com 89, 129, 209 e 389 pedaços. Oscilou, sem tendência clara. Eu esperava uma queda, e ela não apareceu com esse modelo e esse corpus. Com outro modelo, outro acervo ou códigos mais parecidos, pode aparecer, e é exatamente por isso que a decisão se faz medindo (seção 17).
4. Onde a busca lexical falha, e por que as duas se complementam
A busca por palavras tem o defeito inverso. Ela não sabe nada sobre sentido: “carteiro” não é “entregador”, “desistir” não é “arrependimento”, “dinheiro de volta” não é “estorno”. E tem armadilhas de superfície que a seção 6 mostra, como singular e plural que o stemmer não junta ou uma palavra comum que casa com o pedaço errado.
Com as 72 perguntas, olhei quem acerta o topo (posição 1) em cada caso. A tabela resume todas as buscas e as duas fusões que as próximas seções constroem (hit@1 e MRR por categoria, hit@3 e hit@5 no total):
método | cod h1 mrr | par h1 mrr | mix h1 mrr | todas h1 mrr | h3 h5
vetorial e5 | 92 0.95 | 55 0.62 | 94 0.97 | 82 0.86 | 89 92
vetorial MiniLM | 44 0.54 | 60 0.69 | 81 0.89 | 57 0.66 | 67 76
tf-idf | 97 0.99 | 35 0.51 | 88 0.90 | 78 0.84 | 86 92
bm25 | 100 1.00 | 50 0.64 | 100 1.00 | 86 0.90 | 92 96
híbrida RRF | 97 0.99 | 50 0.66 | 100 1.00 | 85 0.90 | 94 97
híbrida pontos (min-max, 0,5) | 100 1.00 | 70 0.77 | 100 1.00 | 92 0.94 | 96 97
E o cruzamento dos erros do topo entre o vetorial (e5) e o BM25: só o vetorial erra em 6 perguntas, só o BM25 erra em 3, os dois erram em 7 e nenhum erra em 56.
A figura divide as 72 perguntas em quatro conjuntos. À esquerda, em lima, as que só o vetorial erra: três delas são de código (ERR-5017 e dois vizinhos, como a figura anterior mostrou), e as outras três são uma pergunta sobre assinantes e entrega, uma sobre devolver uma peça de roupa e uma sobre a TV OLED de 55 polegadas, em que o pedaço certo ficou em segundo. À direita, em azul, as que só o BM25 erra, e todas são paráfrases em que o pedaço certo usa outras palavras (parcelas e juros no lugar de prestações e acréscimo, exclusão no lugar de apagar). No meio, em rosa, as 7 em que os dois erram, como “posso desistir da compra?”, em que o pedaço fala de arrependimento e nenhum dos dois chega perto. A seta que sai dessa coluna é barrada com ✕ para dizer que nenhuma fusão salva uma pergunta que as duas listas erram. Embaixo, o losango índigo recebe as setas das duas colunas laterais, e dos 56 casos em que ninguém erra (verde) o ganho é zero.
A leitura honesta é esta. A fusão só tem trabalho a fazer nos 9 casos em que exatamente um dos dois acerta, e nesses casos ela precisa saber em quem confiar. Num corpus onde os dois acertam 78% das vezes e erram juntos em 10%, o teto de ganho é modesto, e o resto da aula é sobre como chegar perto desse teto e como saber se ele vale o trabalho.
Parte 2: Busca lexical
5. O índice invertido
Um índice invertido é a estrutura que torna a busca por palavras rápida. A ideia é a do índice remissivo no fim de um livro: em vez de percorrer todas as páginas procurando uma palavra, você vai à lista alfabética, acha a palavra e lê em que páginas ela aparece. Na prática, o índice é um dicionário de termo para lista de pedaços, e cada item da lista guarda quantas vezes o termo aparece naquele pedaço (a frequência do termo, o tf, de term frequency). Chama-se invertido porque o pedaço, que “contém” termos, é visto de trás para frente: o termo “contém” pedaços. A lista de um termo se chama lista de postagens.
A figura mostra três pedaços cinzas à esquerda (erros ERR-4017, ERR-4018 e ERR-4021), as listas de quatro termos em lima no meio e a consulta “cartão vencido” em laranja embaixo. Cada pedaço é ligado, por linhas finas, aos termos que contém (note que err está nos três, e 4018 só no p02). Os selos dão o idf de cada termo (a raridade: err aparece em 22 dos 89 pedaços e vale 1,39, 4018 aparece em 1 e vale 4,09). A consulta procura dois termos, carta (o radical de cartão) e venc, e só o p02 contém os dois: ele recebe o BM25 de 7,87 (verde), enquanto o p01, que só tem carta, recebe menos. A seta grossa que vem de venc mostra que o termo raro pesa mais. O código que constrói o índice e imprime essas contagens:
class Indice:
"""Índice invertido: termo -> {doc: tf}. Guarda também o tamanho de cada documento."""
def __init__(self, textos, tok):
self.tok = tok
self.docs = [tok(t) for t in textos]
self.N = len(textos)
self.tam = np.array([len(d) for d in self.docs], dtype=float)
self.media = self.tam.mean()
self.post = collections.defaultdict(dict)
for i, d in enumerate(self.docs):
for termo, tf in collections.Counter(d).items():
self.post[termo][i] = tf
def idf(self, termo): # idf do Lucene/Elasticsearch: sempre positivo
n = len(self.post.get(termo, ()))
return math.log(1 + (self.N - n + 0.5) / (n + 0.5))
== índice invertido (tok_stem): 89 pedaços, 578 termos, tamanho médio 24.7 tokens
'err' aparece em 22 pedaços idf=1.39
'4018' aparece em 1 pedaços idf=4.09
'pix' aparece em 10 pedaços idf=2.15
'carta' aparece em 6 pedaços idf=2.63
'rapida' aparece em 1 pedaços idf=4.09
O índice tem 578 termos distintos para 89 pedaços. A consulta não percorre os pedaços: para cada termo da pergunta, abre a lista e soma contribuições nos pedaços que aparecem nela. Um termo que não existe no índice não custa nada, e um termo raro tem uma lista curta. É isso que torna a busca lexical barata mesmo em milhões de pedaços, e é a razão de os bancos de busca terem sido construídos em volta dessa estrutura (a seção 14 mostra que mesmo vetores esparsos acabam num índice assim).
Um detalhe que o código esconde: o idf. A fórmula que usei, ln(1 + (N - df + 0,5) / (df + 0,5)), é a que o Lucene usa no BM25, segundo o que sei (não conferi na documentação deles); o df (document frequency) é o número de pedaços que contêm o termo. Ela dá valores altos a termos raros e valores baixos, mas sempre positivos, a termos que estão em quase todos os pedaços. A intuição: se todo pedaço tem a palavra “erro”, achar “erro” num pedaço não diz nada sobre ele, e se só um pedaço tem “4018”, achar “4018” diz quase tudo.
6. Tokenização e stemming em português
Antes de entrar no índice, o texto precisa virar termos, e a mesma transformação precisa ser aplicada à pergunta. Esse passo, a tokenização e a normalização, decide mais do que parece, porque um termo escrito de duas formas é, para o índice, dois termos sem relação.
A figura percorre a frase “Os erros ERR-4018 nas entregas atrasadas, em São Paulo?” por quatro etapas em lima. A primeira passa tudo para minúsculas, tira a acentuação e separa em palavras (sao, paulo, e o código partido em err e 4018). A segunda remove as stopwords, palavras tão comuns que não discriminam nada (os, nas, em). A terceira troca cada palavra pelo seu radical (entregas vira entreg). A quarta, que é opcional e que acrescentei, mantém o código inteiro (err-4018) como um termo a mais. À direita, em rosa tracejado, o caminho que não se deve seguir: separar só por espaço deixa paulo? e atrasadas, grudados na pontuação, e eles nunca casam com paulo e atrasadas do pedaço (a seta barrada com ✕). O código:
def tok_simples(t): # o que NÃO fazer: só minúsculas e espaços
return t.lower().split()
def tok_stem(t):
"""minúsculas, sem stopwords, radical (com acento, que o stemmer entende) e só então sem acento."""
palavras = [w for w in re.findall(r"\w+", t.lower()) if sem_acento(w) not in stop()]
return [sem_acento(stem(w)) for w in palavras]
def tok_codigo(t): # tok_stem + o código inteiro (ERR-4018) como um termo a mais
base = tok_stem(t)
inteiros = re.findall(r"[a-z0-9]+(?:[-./][a-z0-9]+)+", sem_acento(t.lower()))
return base + inteiros
split ['os', 'erros', 'err-4018', 'nas', 'entregas', 'atrasadas,', 'em', 'são', 'paulo?']
palavras ['os', 'erros', 'err', '4018', 'nas', 'entregas', 'atrasadas', 'em', 'sao', 'paulo']
stopwords ['erros', 'err', '4018', 'entregas', 'atrasadas', 'paulo']
stemming ['erros', 'err', '4018', 'entreg', 'atras', 'paul']
código ['erros', 'err', '4018', 'entreg', 'atras', 'paul', 'err-4018']
Stemming é a redução de uma palavra ao seu radical, para que “entrega”, “entregas” e “entregador” casem. Uso o stemmer Snowball para português do NLTK (versão 3.10.3), a mesma família de algoritmos que o Postgres usa na configuração portuguese, segundo a documentação. Um detalhe de ordem que me custou um erro: o stemmer entende acentos, então ele deve rodar antes de eliminar os acentos. Se você tira o acento de “cartão” para “cartao” e só depois aplica o stemmer, o radical sai carta, que é outra palavra (a carta, de cartas). Por isso tok_stem aplica o radical primeiro e só depois tira o acento. As stopwords vêm da lista de português do NLTK (207 palavras, baixadas dos dados do projeto).
Stemmers erram, e vale ver como. Rodei pares que deveriam coincidir:
entrega -> entreg | entregas -> entreg | iguais
pagar -> pag | pagando -> pag | iguais
pedido -> ped | pedir -> ped | iguais
erro -> erro | erros -> erros | diferentes
cupom -> cupom | cupons -> cupons | diferentes
item -> item | itens -> itens | diferentes
cartão -> cartã | cartões -> cartõ | diferentes
devolução -> devolu | devolver -> devolv | diferentes
carta -> cart | cartão -> cartã | diferentes
Os três primeiros pares funcionam. Os demais falham de formas diferentes: o plural de “erro” não perde o s, os plurais em -ns e -ões (cupons, itens, cartões) escapam, e “devolução” e “devolver” ficam em radicais distintos porque o algoritmo não conhece a relação entre o substantivo e o verbo. Há também o erro oposto, juntar o que não deveria: pedido e pedir viram ped, o que aqui é bom, mas em outro acervo ped também puxaria “pedra” e “pedagógico”. A conferência no Postgres, com a configuração portuguese, deu o mesmo padrão: erro e erros ficam separados, cupom e cupons também, e devolução vira devoluca enquanto devolver vira devolv.
Qual é o ganho dessas etapas? Medi o BM25 (k1 1,2 e b 0,75) com cada tokenizador, acumulando as etapas:
tokenizador | cod h1 mrr | par h1 mrr | mix h1 mrr | todas h1 mrr
split | 53 0.60 | 40 0.49 | 88 0.94 | 57 0.65
palavras | 100 1.00 | 35 0.52 | 100 1.00 | 82 0.87
stopwords | 100 1.00 | 45 0.57 | 100 1.00 | 85 0.88
stemming | 100 1.00 | 50 0.64 | 100 1.00 | 86 0.90
código | 100 1.00 | 50 0.64 | 100 1.00 | 86 0.90
O maior salto é o primeiro, de split para palavras (57 para 82 no total): separar a pontuação e tirar acentos resolve quase todas as consultas de código, porque “ERR-4018?” com interrogação colada nunca casaria com nada. Stopwords e stemming acrescentam alguns pontos, concentrados nas paráfrases (35 para 50), com a ressalva de que são 20 perguntas e cada uma vale 5 pontos. A quarta linha, o código inteiro como termo extra, não mudou nada aqui, porque err e 4018 já aparecem juntos e identificam o pedaço. Ela ajudaria se existissem códigos como AB-12 e 12-AB, em que as mesmas peças em outra ordem seriam outro código, mas esse caso não está no meu corpus e não o testei.
Duas observações sobre o tokenizador do BM25 e o do modelo de embedding. Os dois são independentes: o tokenizador de subpalavras da seção 3 existe dentro do modelo e você não o controla, enquanto o do índice lexical é seu e deve ser escolhido para o seu idioma e o seu tipo de texto. E a regra que não se quebra é que documento e pergunta passam pelo mesmo tokenizador. Se o índice foi feito com stemming e a pergunta chega sem, nada casa.
7. TF-IDF
O TF-IDF é a versão mais simples de pontuar um pedaço. Para cada termo da pergunta, multiplica-se o tf (quantas vezes o termo aparece no pedaço, em geral suavizado com um logaritmo) pelo idf (a raridade do termo no acervo), e somam-se os termos. A intuição é a de duas pistas que se combinam: um pedaço que repete bastante o termo da pergunta (tf alto) e um termo que é raro no acervo (idf alto) são sinais de relevância, e um termo que está em todo lugar (idf baixo) quase não conta. O código que usei:
def tfidf(ind, consulta):
s = np.zeros(ind.N)
for q in set(ind.tok(consulta)):
idf = math.log(ind.N / (1 + len(ind.post.get(q, ())))) + 1
for i, tf in ind.post.get(q, {}).items():
s[i] += (1 + math.log(tf)) * idf
return s
Funciona, e na tabela da seção 4 ele ficou em 78% no total, contra 86% do BM25. As fraquezas dele são duas, e são as que o BM25 corrige. A primeira é que não leva em conta o tamanho: um pedaço longo tem mais chances de conter o termo e de repeti-lo, e ganha pontos só por isso. A segunda é que o tf cresce sem teto (mesmo com o logaritmo), então um pedaço que repete uma palavra 20 vezes pode superar o que contém todas as palavras da pergunta uma vez. Nas consultas de código o efeito aparece: para “o que significa o erro ERR-4018?”, o TF-IDF dá 9,50 ao pedaço certo (p02) e 8,49 a duas tabelas de catálogo, que contêm err cinco vezes cada uma, enquanto o BM25 dá 8,69 ao p02 e 4,88 ao segundo colocado.
8. BM25
O BM25 é o refinamento do TF-IDF que virou padrão em motores de busca. Ele mantém as duas pistas, tf e idf, e acrescenta duas correções, cada uma controlada por um parâmetro. A pontuação de um pedaço para uma pergunta é a soma, sobre os termos da pergunta, de três fatores multiplicados:
idf(termo) x tf x (k1 + 1) / (tf + k1 x (1 - b + b x tam / média))
A figura desmonta a fórmula. À esquerda, em lima e pulsando, a soma. Ela se ramifica em três fatores: a raridade (idf), a saturação do tf (controlada por k1) e a correção de tamanho (controlada por b). À direita, o cálculo feito à mão para “cartão vencido” contra o pedaço p02: o termo venc (idf 3,58) rende 4,537 e o termo carta (idf 2,63) rende 3,328, e a soma é 7,865. As setas grossas azuis levam os resultados por termo à pontuação final (verde). O mesmo cálculo no código:
== conta de bm25 à mão: consulta 'cartão vencido' contra p02
termos da consulta: ['carta', 'venc'] | p02 tem 12 tokens; média 24.7
carta df=6 idf=2.628 tf=1 norm=0.738 -> 3.328
venc df=2 idf=3.584 tf=1 norm=0.738 -> 4.537
soma = 7.865 | função bm25(): 7.865
O k1 controla a saturação do tf. No TF-IDF puro, repetir uma palavra 20 vezes vale 20 vezes mais do que dizê-la uma. No BM25, o fator tf x (k1 + 1) / (tf + norma) sobe depressa nas primeiras ocorrências e depois achata, e o k1 diz quão depressa. Com k1 pequeno, a segunda ocorrência já quase não adiciona nada (o termo “ou aparece ou não aparece”); com k1 grande, o comportamento volta a se aproximar do tf bruto. A documentação do Elasticsearch define o k1 como o controle da “normalização não linear da frequência do termo (saturação)”, com padrão 1,2. O b controla o quanto o tamanho do pedaço desconta o tf: com b = 0 o tamanho é ignorado, com b = 1 a correção é total, e o padrão da documentação do Elasticsearch é 0,75. A razão de o tamanho importar é a mesma de antes: um pedaço de 40 tokens tem mais chance de conter um termo por acaso do que um de 5, e o mesmo tf deve valer menos no maior.
A figura é um gráfico de barras feito com retângulos. À esquerda, em lima, a contribuição de um termo com idf 1 para três valores de k1 e três valores de tf. Com k1 = 0,5, ir de 1 para 20 ocorrências sobe a contribuição de 1,0 para 1,46. Com k1 = 3,0, ela sobe de 1,0 para 3,48, bem mais perto de linear. À direita, em azul, o mesmo termo, com tf 2, em pedaços de 5, 12 e 40 tokens. Com b = 0 as três barras são iguais (1,38), com b = 0,75 a do pedaço curto (1,77) passa a ser bem maior que a do longo (1,17), e com b = 1 a diferença se acentua. Os números vêm da fórmula aplicada a um pedaço com o tamanho médio do corpus, 24,7 tokens.
Qual valor usar? Os padrões, 1,2 e 0,75, são razoáveis na maioria dos casos, e minha medida não encontrou nada melhor. Varri k1 e b no meu corpus (hit@1 em todas as consultas):
b=0 b=0.3 b=0.75 b=1
k1=0.0 81/0.85 81/0.85 81/0.85 81/0.85
k1=0.5 81/0.85 83/0.88 86/0.89 86/0.90
k1=1.2 81/0.85 83/0.88 86/0.90 86/0.90
k1=2.0 81/0.85 83/0.88 86/0.90 88/0.91
k1=3.0 81/0.85 82/0.87 85/0.89 86/0.90
A grade é quase plana: de 81% a 88%, e a diferença entre o canto de baixo (k1 = 0) e o padrão é de 5 pontos, uns 4 perguntas em 72. Com k1 = 0 o tf deixa de contar e sobra só a presença do termo, o que ainda chega a 81% porque o idf faz quase todo o trabalho em pedaços curtos. Não ajuste esses parâmetros sem um conjunto de perguntas que justifique, e mesmo com ele desconfie de ganhos dessa ordem.
Uma última verificação, de honestidade. Para ter certeza de que minha implementação não tem erro de conta, comparei com a biblioteca rank_bm25 (versão 0.2.2, classe BM25Okapi com k1 1,2 e b 0,75), cujo idf é de outra forma: o item de topo foi o mesmo nas 72 consultas. Isso confere a ordenação, não os valores absolutos, que diferem por causa do idf.
Parte 3: Combinar as duas buscas
9. O problema das duas escalas
Agora há duas listas: a vetorial, ordenada por cosseno, e a lexical, ordenada por BM25. A ideia ingênua é somar as pontuações e reordenar, e ela esbarra numa propriedade incômoda: as duas pontuações não estão na mesma escala. Nas minhas 72 consultas o cosseno do e5-small varia de 0,745 a 0,931 (uma faixa de menos de 0,2, em que os candidatos bons e os ruins ficam todos entre 0,8 e 0,9), e o BM25 vai de zero até 27,3. Somar 0,84 a 8,6 é somar uma faixa estreita a uma larga, e o BM25 dominaria sem que ninguém tivesse decidido isso. Além disso, as escalas mudam de pergunta para pergunta: o BM25 de uma consulta com um termo raro e o de uma consulta com cinco termos comuns não são comparáveis, e o cosseno depende do modelo.
A documentação do Qdrant avisa exatamente isso: combinações lineares com alfa de pontuações brutas de vetores densos e esparsos são pouco confiáveis sem normalização, porque as escalas diferem e mudam de consulta para consulta (resumo meu da página). Existem duas saídas. Uma é normalizar as pontuações antes de combinar. A outra é ignorar as pontuações e usar só as posições. As duas são o que as seções 10 e 11 mostram.
10. Reciprocal Rank Fusion
O Reciprocal Rank Fusion (RRF, fusão por ranking recíproco) foi proposto por Cormack, Clarke e Büttcher no SIGIR de 2009. A ideia é descartar as pontuações e olhar só a posição de cada pedaço em cada lista. Cada lista dá a cada pedaço o valor 1 / (k + posição), e a pontuação final é a soma sobre as listas em que o pedaço aparece. O artigo escreve a fórmula como a soma de 1 / (k + r(d)) e diz que k = 60 foi fixado numa investigação piloto e não alterado depois. A intuição que os autores dão é que as posições altas importam mais, mas a importância das baixas não desaparece, como desapareceria com uma exponencial, e que a fórmula soma uma constante k à posição. Um pedaço que está no topo de uma lista e no meio da outra soma duas parcelas, e um que está só numa soma uma, então quem é bem posicionado nas duas vence.
A figura acompanha uma consulta real, “fiquei trancado fora da minha conta, quando consigo entrar de novo?”, cujo pedaço certo (p42, “a conta fica bloqueada por 15 minutos depois de 5 senhas erradas”) é o único verde. A lista da esquerda é a vetorial, e nela o p42 está em sexto, com cosseno 0,841, atrás de um pedaço sobre token de acesso da API. A da direita é a do BM25, e ele está em segundo, atrás de um catálogo de erros. No meio, o RRF soma: o p42 recebe 1/66 + 1/62 = 0,03128, porque está nas duas listas (as setas grossas, azul vindo do vetorial e lima vindo do BM25), e vence o p29, que está nas duas mas mais abaixo (0,02947), o e07, que é primeiro no BM25 mas vigésimo no vetorial (0,02889), e o p25, que é primeiro no vetorial e não aparece no BM25 (0,01639). Nenhuma das duas buscas, sozinha, acertou o topo; a fusão acertou. Repare também que o cosseno de 0,841 e o BM25 de 2,71 nunca entram na conta, só a posição. O código:
def rrf(listas, k=60, pesos=None):
"""listas: rankings (listas de índices). Posição começa em 1."""
pesos = pesos or [1.0] * len(listas)
s = collections.defaultdict(float)
for w, lista in zip(pesos, listas):
for r, d in enumerate(lista, 1):
s[d] += w / (k + r)
return s
def fundir_rrf(sv, sl, n=20, k=60, pesos=(1.0, 1.0)):
ov, _ = topn(sv, n); ol, _ = topn(sl, n, True)
s = rrf([ov, ol], k, list(pesos))
out = np.full(len(sv), -1.0)
for d, x in s.items(): out[d] = x
return out
A grande vantagem do RRF é que ele dispensa calibração: funciona com quaisquer duas buscas, de qualquer escala, e por isso é o padrão de vários produtos. A documentação do Qdrant o chama de “o padrão seguro” quando não há conjunto de avaliação e não se confia nas pontuações brutas, o Elasticsearch o implementa como o retriever rrf com rank_constant padrão 60, e o OpenSearch o oferece no score-ranker-processor, também com rank_constant padrão 60. Detalhes dos nomes na seção 15.
O que o RRF não faz é usar a margem. Se uma busca está certa de que um pedaço é muito melhor que o segundo, e a outra está em dúvida entre vários, o RRF trata o primeiro lugar de uma e o primeiro lugar da outra com o mesmo peso. Testei o efeito de k (profundidade de 20 candidatos por busca): com k = 1, 5, 20, 60 e 200, o hit@1 no total ficou em 85% em todos, e o MRR variou entre 0,89 e 0,90. Nesse corpus pequeno, o k não faz diferença, o que é coerente com o artigo ter fixado um valor e seguido em frente.
11. Fusão por pontuação normalizada
A alternativa é combinar as pontuações depois de colocá-las na mesma escala. A normalização mais simples é a min-max: dentro de cada lista, o menor valor vira 0, o maior vira 1 e os demais ficam proporcionais. A pontuação final é alfa x vetorial + (1 - alfa) x lexical, em que alfa é o peso da busca vetorial. Outra normalização que a documentação do Qdrant descreve é a DBSF (distribution-based score fusion): em vez de usar o menor e o maior valores (que um único valor extremo distorce), usa a média e o desvio padrão da lista, e mapeia o intervalo de três desvios para cada lado da média para a faixa de 0 a 1, sem cortar o que sair dela. O código:
def norm_minmax(d):
v = np.array(list(d.values())); lo, hi = v.min(), v.max()
return {k: (0.0 if hi == lo else (x - lo) / (hi - lo)) for k, x in d.items()}
def norm_dbsf(d): # média ± 3 desvios viram 0 e 1, como a documentação do Qdrant descreve
v = np.array(list(d.values())); mu, sd = v.mean(), v.std(ddof=1) if len(v) > 1 else 0
return {k: (0.5 if sd == 0 else (x - (mu - 3 * sd)) / (6 * sd)) for k, x in d.items()}
def fundir_pontos(sv, sl, n=20, alfa=0.5, norm=norm_minmax):
"""sv e sl: pontuações completas (vetorial, lexical). alfa pesa a vetorial. Quem não está no topo-n de uma busca ganha 0 nela."""
_, dv = topn(sv, n); _, dl = topn(sl, n, True)
nv, nl = norm(dv), norm(dl)
out = np.full(len(sv), -1.0)
for d in set(nv) | set(nl):
out[d] = alfa * nv.get(d, 0.0) + (1 - alfa) * nl.get(d, 0.0)
return out
Dois detalhes do código importam. A normalização é feita sobre os n melhores de cada busca (aqui 20), que é o que se pode fazer numa busca de verdade, em que você nunca vê a pontuação do acervo inteiro, e quem não está no topo de uma busca ganha zero nela. E numa busca lexical só entram como candidatos os pedaços com pontuação positiva: um pedaço que não casou nenhum termo não pode ocupar uma posição na lista. Descobri a necessidade disso por erro: na primeira versão, os pedaços de pontuação zero entravam na lista do BM25 com posições arbitrárias, desempatadas por ordem de arquivo, e o RRF os tratava como evidência.
A figura mostra o que se perde quando se descarta a margem. A pergunta (laranja) “com quantas prestações posso dividir sem pagar acréscimo?” tem como pedaço certo o p48, o do parcelamento sem juros (verde). A busca vetorial o põe em primeiro (cosseno 0,835) e o BM25 nem o encontra, porque o pedaço fala em “parcelamento” e “juros”. Já o p47 (desconto no Pix) e o p31 (frete do Prime) casam com a palavra “pagar” e empatam no topo do BM25, com 4,72 (o p31 em primeiro e o p47 em segundo, pelo desempate), mas são o 16º e o 14º no vetorial. O RRF (índigo, em cima) soma só posições e entrega p47 (0,02964), p31 (0,02955) e depois o p48 (0,01639), que fica em terceiro: a seta barrada diz que a margem se perdeu. A fusão por pontos (índigo, embaixo, pulsando) enxerga que, no vetorial, o p47 e o p31 estão colados no fim da faixa (recebem 0,13 e 0,11 na fusão) e o p48 está no topo (0,50), e coloca o certo em primeiro. Esse é o ganho de usar as pontuações, e o seu preço é o que a seção 9 anunciou: ela depende da normalização funcionar bem.
As medidas, no corpus de 89 pedaços, favorecem a fusão por pontos: 92% de hit@1 contra 85% do RRF, 86% do BM25 e 82% do vetorial. O bootstrap pareado, que a seção 17 explica, dá para “pontos menos vetorial” +9,7 pontos (intervalo de 95% de +2,8 a +18,1) e para “pontos menos BM25” +5,6 pontos (+1,4 a +11,1), e para “RRF menos pontos” −6,9 (−13,9 a −1,4). A DBSF, com o mesmo alfa, deu 90%. Mas dois avisos pesam sobre esse resultado. O primeiro é que o alfa de 0,5 foi o melhor valor da varredura que fiz no mesmo conjunto, então a vantagem está levemente inflada. O segundo é a figura do tamanho do acervo, que repeti acrescentando catálogos de códigos novos:
pedaços | dense e5 cod/todas h1 | bm25 cod/todas h1 | RRF cod/todas h1 | pontos cod/todas h1
89 | 92/ 82 | 100/ 86 | 97/ 85 | 100/ 92
129 | 92/ 82 | 100/ 86 | 97/ 85 | 100/ 90
209 | 86/ 78 | 100/ 85 | 97/ 83 | 100/ 85
389 | 92/ 81 | 100/ 85 | 97/ 83 | 100/ 83
A vantagem da fusão por pontos encolhe conforme o acervo cresce: 92, 90, 85 e 83% de acerto geral, contra 85, 85, 83 e 83% do RRF. Com 389 pedaços, os dois empatam e ficam abaixo do BM25 sozinho (85%). Não sei dizer se isso é efeito do tamanho em si ou do jeito como diluí o corpus (os pedaços acrescentados são todos tabelas de códigos, que competem com as consultas de código e de paráfrase de modos diferentes). O que sei é que a ordem entre RRF e pontos não é estável, e que não se deve concluir de um experimento pequeno que uma fusão é sempre melhor que a outra.
12. O peso entre as buscas
Tanto o RRF quanto a fusão por pontos aceitam pesos: no RRF, multiplica-se a parcela de cada lista por um peso, e na fusão por pontos o alfa faz esse papel. A documentação do Qdrant descreve o RRF ponderado (weights, a partir da versão 1.17, com um peso por prefetch na ordem definida) e recomenda ajustar os pesos num conjunto de avaliação, com divisão entre treino e validação, e, sem conjunto, deixar o padrão 1,0 e 1,0. O OpenSearch aceita weights no normalization-processor e exige que somem 1,0. O Weaviate expõe o alfa diretamente, com 1 para busca vetorial pura e 0 para busca por palavras pura.
À esquerda da figura está a varredura do alfa na fusão por pontos, com o hit@1 de todas as consultas em barras: 83% com alfa 0 (só o BM25, nos 20 primeiros), 89% com 0,2, 90% com 0,4, 92% com 0,5 (verde, o padrão), 89% com 0,6, 86% com 0,8 e 82% com 1 (só o vetorial). As barras de extremo estão em rosa e as de meio em índigo: a curva sobe, tem um pico largo e desce, e o pico está perto do meio. No RRF ponderado a varredura tem a mesma forma, com a melhor região em pesos de 0,25 a 0,5 para a vetorial contra 1,0 para a lexical (88% e 89%, contra 85% com pesos iguais e 82% com peso 4).
À direita, o teste que importa. Ajustar o alfa nas mesmas perguntas em que se avalia é trapaça involuntária: sempre se encontra um valor que parece bom. Então repeti 20 vezes um procedimento honesto, em que se separa metade das perguntas (estratificadas por categoria), escolhe-se o melhor alfa nessa metade e mede-se na outra:
alfas escolhidos no treino (20 divisões): [0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
MRR no teste, médias das 20 divisões: ajustado 0.931 | alfa fixo 0,5 0.937 | só vetorial 0.863 | só lexical 0.878
ajustado menos fixo 0,5: média -0.006, melhor divisão +0.000, pior -0.018
O alfa ajustado nunca ganhou do 0,5 fixo na metade de teste (a média foi −0,006 e a melhor divisão empatou). Com 36 perguntas de treino, o que se aprende é ruído, e o padrão no meio já estava no pico. A regra prática que tiro disso é começar com pesos iguais, e só mexer neles quando houver centenas de perguntas e uma diferença que sobreviva a uma divisão treino e teste. Tenha em mente que isso vale para o meu corpus, em que as duas buscas têm qualidade parecida; se uma das buscas fosse muito pior que a outra, o peso ótimo se afastaria do centro.
13. Quantos candidatos de cada busca
A última escolha é a profundidade: quantos resultados de cada busca entram na fusão. No RRF do Elasticsearch isso é o rank_window_size, que pelo que li na documentação tem como padrão o valor de size; no Qdrant é o limit de cada prefetch, que precisa ser pelo menos limit + offset da consulta principal. Testei n de 3 a 50 com o RRF:
n=3 | 97 0.99 | 45 0.59 | 100 1.00 | 83 0.88 | 90 96
n=5 | 97 0.99 | 45 0.63 | 100 1.00 | 83 0.89 | 93 97
n=10 | 97 0.99 | 50 0.65 | 100 1.00 | 85 0.90 | 92 96
n=20 | 97 0.99 | 50 0.66 | 100 1.00 | 85 0.90 | 94 97
n=50 | 97 0.99 | 50 0.66 | 100 1.00 | 85 0.90 | 94 97
Os valores estabilizam a partir de n = 10: a partir daí, o pedaço certo ou já está na lista ou não vai entrar. Em n = 3 e 5, as paráfrases pioram (45%), porque o pedaço certo que está em quarto lugar numa lista e em segundo na outra não chega a ser candidato. A regra de ouro é que a profundidade deve ser maior que o número de pedaços que você entregará ao modelo, e quando houver reranking ela costuma ser bem maior, de 50 a 100 candidatos, porque o reranker ainda vai reordenar. A busca híbrida e o reranking têm papéis diferentes: a híbrida decide quem entra na lista de candidatos, e o reranking decide a ordem dentro dela.
Parte 4: Vetores esparsos e bancos
14. Vetores esparsos
Um vetor esparso é um vetor em que quase todas as posições são zero. Em vez de guardar milhares de zeros, guarda-se só a lista das posições não nulas e dos valores delas. Essa é a forma em que o BM25 cabe dentro de um banco vetorial: dá-se a cada termo do vocabulário uma posição, e o pedaço vira um vetor cujas posições não nulas são os termos que ele contém, com o peso BM25 de cada um. A pergunta vira outro vetor esparso, com 1 nos termos dela, e o produto escalar entre os dois é exatamente o score BM25. A diferença para o vetor denso de Embeddings e Busca vetorial é que o denso tem poucas dimensões (384 no e5-small), todas diferentes de zero, e cada uma mistura vários sentidos, enquanto o esparso tem tantas dimensões quanto termos do vocabulário e cada uma significa uma coisa só.
A figura parte do pedaço p02 (verde, à esquerda) e o transforma de três formas. Em azul, o vetor denso de 384 números, que vai para um índice HNSW (os vizinhos aproximados de Busca vetorial). Em lima, o vetor esparso do BM25, que no meu corpus tem 13 posições não nulas de um vocabulário de 741 termos, com pesos como 5,18 para 4018 e para o código inteiro, 4,53 para venc, 3,32 para carta e 1,75 para err. Esse vetor vai para um índice invertido (o cilindro lima), e a consulta esparsa (laranja) faz o produto escalar com ele. Em índigo tracejado, o terceiro caminho, o SPLADE, que explico abaixo e que não testei. O resultado, embaixo à direita, é o mesmo score do BM25: 13,86 para o p02 contra a consulta “o que significa o erro ERR-4018?”. O código que monta o vetor esparso e confere a equivalência:
def doc_esparso(i, k1=1.2, b=0.75):
"""Vetor esparso do documento: para cada termo, o peso BM25 dele (idf x saturação x tamanho)."""
from collections import Counter
idx, val = [], []
for t, tf in Counter(ind.docs[i]).items():
norm = k1 * (1 - b + b * ind.tam[i] / ind.media)
idx.append(vocab[t]); val.append(float(ind.idf(t) * tf * (k1 + 1) / (tf + norm)))
return models.SparseVector(indices=idx, values=val)
def consulta_esparsa(q):
"""Vetor esparso da consulta: 1 para cada termo conhecido."""
ts = sorted({vocab[t] for t in ind.tok(q) if t in vocab})
return models.SparseVector(indices=ts, values=[1.0] * len(ts))
p02: produto escalar esparso = 13.8627 | bm25() = 13.8627 | termos não nulos no documento: 13 de 741
O SPLADE (Sparse Lexical and Expansion Model, Formal, Piwowarski e Clinchant, SIGIR 2021) é a versão aprendida desse vetor. O resumo do artigo diz que é um modelo de recuperação de primeiro estágio que aprende representações esparsas de consultas e documentos, com regularização de esparsidade e saturação logarítmica dos pesos, treinado de ponta a ponta, e que mira manter as vantagens dos métodos de bag of words (casar termos exatos, índice invertido) com desempenho competitivo com os métodos densos e esparsos do estado da arte. O que li no resumo é que ele mistura as duas coisas, o vetor tem a forma de um BM25 e os pesos vêm de uma rede neural. O que o nome “expansion” sugere, e que eu não verifiquei no corpo do artigo, é que a rede pode dar peso a termos que não estão no texto. Não rodei nenhum modelo SPLADE, e portanto não sei dizer como ele se sai em português: tenho a impressão de que os modelos públicos mais conhecidos foram treinados em inglês, mas não verifiquei. Fica como lacuna.
Por que isso importa para a busca híbrida? Porque, em bancos que aceitam vetores esparsos, a busca lexical e a vetorial passam a viver no mesmo registro, com o mesmo id. A consulta híbrida deixa de ser dois sistemas e uma cola, e vira uma consulta com dois vetores, e a fusão acontece dentro do banco. Foi o que fiz no Qdrant:
cli = QdrantClient(":memory:")
cli.create_collection("aula", vectors_config={"denso": models.VectorParams(size=384, distance=models.Distance.COSINE)},
sparse_vectors_config={"lexical": models.SparseVectorParams()})
cli.upsert("aula", points=[models.PointStruct(id=n, vector={"denso": Pe5[n].tolist(), "lexical": doc_esparso(n)}, payload={"pid": IDS[n]}) for n in range(len(IDS))])
def consulta(qi, fusao):
q = CONSULTAS[qi][1]
return cli.query_points("aula", prefetch=[
models.Prefetch(query=Qe5[qi].tolist(), using="denso", limit=20),
models.Prefetch(query=consulta_esparsa(q), using="lexical", limit=20)],
query=fusao, limit=10, with_payload=True)
qdrant rrf k=60 : hit@1 83 hit@3 94 mrr 0.89
qdrant dbsf : hit@1 90 hit@3 96 mrr 0.93
RRF à mão (k=60) : {'h1': 0.85, 'h3': 0.94, 'h5': 0.97, 'mrr': 0.9}
posição do ouro idêntica entre o Qdrant e o RRF à mão em 69 de 72 consultas
Usei o cliente qdrant-client 1.19.1 em modo local (sem servidor), com models.RrfQuery(rrf=models.Rrf(k=60)) e models.FusionQuery(fusion=models.Fusion.DBSF). O RRF do Qdrant e o meu coincidem em 69 das 72 posições, e a diferença de 2 pontos no hit@1 (83 contra 85) vem de detalhes que não investiguei: a documentação diz que o Qdrant conta as posições a partir de zero, e eu usei posições a partir de 1, o que muda as parcelas e pode inverter empates. A DBSF do banco ficou em 90%, perto da minha fusão por pontos (90% com DBSF e 92% com min-max). A documentação ainda lista o qdrant/bm25, um modelo de inferência que gera o vetor a partir do texto, mas eu mesmo montei o vetor esparso, e não testei essa rota.
15. O que cada banco oferece
A figura reúne, em cinco colunas, o que li na documentação de cada um (consultada em 15 de outubro de 2026). Só o Qdrant e o Postgres eu executei, e os outros três são leitura.
O Qdrant (azul) guarda vetores esparsos ao lado dos densos, num índice próprio que a página de indexação descreve como exato, sem aproximação. A consulta híbrida usa prefetch com query_points: cada prefetch roda uma busca e a consulta principal funde os resultados. A fusão pode ser RRF (a constante k configurável a partir da 1.16, com padrão 2 na página, e pesos a partir da 1.17) ou DBSF (a partir da 1.11). A página recomenda RRF ponderado quando há conjunto de avaliação, DBSF quando se confia nas pontuações e não há conjunto, e RRF simples como “o padrão seguro” nos demais casos.
O Postgres (cinza) faz busca lexical com tsvector e índice GIN, e o pgvector cuida do vetor. O README do pgvector tem uma frase só sobre isso: “Use together with Postgres full-text search for hybrid search”, com um exemplo de SQL que filtra por @@ e ordena por ts_rank_cd, e uma menção a usar RRF ou um cross-encoder, com links para exemplos em Python. A seção abaixo mostra o que acontece de fato. O ponto frágil está marcado em rosa na figura: a documentação do Postgres diz que as funções de ranking “não usam nenhuma informação global”, o que implica que o idf, que é global, não entra na conta.
O Elasticsearch (laranja) tem a similaridade BM25 documentada, com k1 em 1,2 e b em 0,75. O retriever rrf combina dois ou mais retrievers filhos, tem rank_constant com padrão 60 e rank_window_size, e a página diz que “cada retriever filho tem o mesmo peso” na fórmula. O exemplo da página combina um retriever standard com um knn.
O OpenSearch (roxo) tem a consulta hybrid e dois processadores de pipeline, que li nos arquivos de documentação do repositório oficial (não na página do site, que no meu acesso só devolveu a navegação). O normalization-processor aceita as normalizações min_max (padrão), l2 e z_score, e as combinações arithmetic_mean (padrão), geometric_mean e harmonic_mean, com pesos entre 0 e 1 que precisam somar 1,0. O score-ranker-processor implementa o RRF, com rank_constant padrão 60.
O Weaviate (terracota) expõe a busca híbrida com o parâmetro alpha: 1 é vetorial pura, 0 é por palavras pura. O método de fusão padrão desde a versão 1.24 é o relativeScoreFusion, que usa as pontuações relativas, e o rankedFusion usa só as posições. O parâmetro properties limita o lado lexical a um subconjunto de campos sem afetar o lado vetorial.
Nesta figura as cores identificam cada banco e não seguem o mapa de papéis da aula. Os nomes e números acima são o que as páginas diziam em 15 de outubro de 2026, e mudam entre versões: confira a versão do seu banco antes de copiar um parâmetro.
Agora o Postgres, que eu rodei. Usei a versão 18.1 com o pgvector 0.8.7 (compilado à parte, numa instância descartável no scratchpad, sem tocar no banco do sistema) e uma configuração de busca pt_sem_acento, copiada da portuguese e com o dicionário unaccent antes do stemmer, que é como a documentação do módulo manda tirar acentos. O unaccent() como função não é imutável, então não serve numa coluna gerada, e a configuração resolve isso. A tabela e o índice:
create text search configuration pt_sem_acento (copy = portuguese);
alter text search configuration pt_sem_acento alter mapping for hword, hword_part, word with unaccent, portuguese_stem;
create table pedacos (id text primary key, texto text, emb vector(384),
tsv tsvector generated always as (to_tsvector('pt_sem_acento', texto)) stored);
create index on pedacos using gin (tsv);
O primeiro choque foi na própria linguagem de consulta. A função plainto_tsquery junta os termos com E: só devolve pedaços que contêm todos os termos da pergunta, o que para uma pergunta em linguagem natural quase nunca acontece:
plainto_tsquery (E): 'carta' & 'venc' & 'erro' & 'aparec'
com OU : 'carta' | 'venc' | 'erro' | 'aparec'
consultas com pelo menos um resultado: E = 24 de 72, OU = 72 de 72
Com E, 48 das 72 perguntas não devolvem nada. Troquei o & por | na consulta (o BM25 faz isso por padrão). O segundo choque foi o ts_rank_cd, a função de ranking:
ts_rank_cd(normalização=0): hit@1 60 hit@3 67 mrr 0.66
ts_rank_cd(normalização=1): hit@1 64 hit@3 78 mrr 0.71
ts_rank_cd(normalização=4): hit@1 43 hit@3 53 mrr 0.52
== híbrida em SQL (RRF k=60): hit@1 65 hit@3 76 mrr 0.74
== só vetorial no pgvector (exato): hit@1 82 hit@3 89 mrr 0.86
E primeiro, OU se vier vazio (ts_rank_cd norm 1): hit@1 72 hit@3 83 mrr 0.78
A busca lexical do Postgres acertou 60 a 64% no topo, contra 100% das consultas de código do meu BM25, e a híbrida em SQL (RRF, 65%) ficou abaixo da busca vetorial sozinha (82%). A estratégia “primeiro E, depois OU” recuperou parte (72%), mas ainda ficou longe.
A figura mostra o porquê com a consulta “o que significa o erro ERR-4018?”. A pergunta (laranja) vira uma tsquery com OU ('signif' | 'erro' | 'err' | '-4018'; repare que o analisador do Postgres leu -4018 como um número com sinal, e fez o mesmo no documento, então o casamento funciona). O índice GIN diz quais pedaços contêm algum dos termos. O ranking dá 0,6 ao primeiro colocado, um catálogo de erros em que err aparece cinco vezes (rosa, seta grossa), e só 0,3 ao p02, o único pedaço que contém -4018 (verde, com a seta barrada). Sem idf, o termo raro -4018 pesa o mesmo que o termo comum err, e o ts_rank_cd soma as ocorrências repetidas, então quem repete a palavra comum vence. É exatamente o que a documentação diz com “não usam informação global”. Embaixo, as medidas. As alternativas para ter BM25 de verdade no Postgres existem (extensões e outros índices), mas eu não testei nenhuma e não as nomeio como recomendação.
A conclusão sobre bancos tem duas partes. Os que têm BM25 nativo ou vetores esparsos (Elasticsearch, OpenSearch, Weaviate e o Qdrant com esparsos) permitem consultas híbridas sem montar a lexical à mão, e a escolha entre eles depende do resto da sua infraestrutura. E o Postgres, que atende bem a busca vetorial pequena, faz a metade lexical com uma ferramenta de ranking que, na minha medida, não substituiu o BM25. O full text search continua útil para filtrar e recuperar candidatos, e o ts_rank_cd com normalização 1 chegou a 78% em hit@3. Para o papel do BM25 numa consulta de códigos, porém, eu não contaria com ele.
Parte 5: Decidir e fechar
16. Quando a busca híbrida não vale a pena
A busca híbrida custa. Há um segundo índice para manter (o invertido, com a tokenização e o stemming certos para o seu idioma), um segundo caminho de consulta para observar e uma fusão para ajustar, e cada peça nova é um lugar a mais para errar, como as armadilhas da seção 18 mostram. O ganho existe quando as perguntas dependem de símbolos exatos e o modelo vetorial não os distingue bem, e some quando o modelo já acerta.
A figura é o roteiro de decisão, que começa no laranja (monte as perguntas) e passa por três losangos azuis. No primeiro, “há códigos, siglas, nomes ou números nas perguntas?”: se não há, fique no vetorial (verde) e meça de novo se o acervo ou as perguntas mudarem. Se há, o segundo losango pergunta se o vetorial acerta o topo nelas com folga, e a resposta vem da medida, não do palpite: se sim, uma busca a menos para manter. Se não, o terceiro pergunta se o banco já tem BM25 e vetor, e então vem a híbrida nativa (índigo, pulsando, seta grossa). Se o banco não tem, a saída em rosa avisa: o lexical do Postgres puro perde o idf, e eu não testei extensões.
Os sinais de que você não precisa dela são concretos. Se as suas perguntas são todas em linguagem natural sobre assuntos, sem códigos e sem nomes raros, a categoria de código do seu conjunto de avaliação estará vazia ou o vetorial acertará quase tudo nela, e a fusão não terá o que consertar. Se o modelo é bom nos seus códigos (o e5 acertou 92% nos meus, com uma margem fina), o ganho vem de poucas perguntas. E se o custo de operar dois índices pesa mais que o de errar uma em cada vinte perguntas, a conta pode fechar contra. Em compensação, os sinais a favor são o inverso: um acervo técnico (códigos de erro, SKUs, nomes de funções, siglas internas, números de contrato), um modelo de embedding pequeno ou trocado, perguntas de usuários que colam mensagens de erro, e acervos em que o termo exato é a única pista (nomes de pessoas, de empresas, de lugares).
17. Como medir o ganho
Medir o ganho é a parte que mais se erra, porque os números parecem convincentes mesmo quando são ruído. O roteiro é o da figura.
Comece, à esquerda, com perguntas reais (laranja), de preferência 50 a 100 ou mais, separadas por categoria (código, paráfrase, mista), com o pedaço certo marcado antes de olhar qualquer resultado (verde). Rode os quatro métodos que a tabela da seção 4 usou: vetorial, BM25 e as duas fusões. Para cada um, calcule hit@1, hit@3 e MRR, por categoria e no total. Olhar por categoria é o que mostra onde cada método ganha: a tabela tem a vantagem do BM25 sobre o vetorial concentrada nos códigos (100% contra 92%) e a do vetorial sobre o BM25 nas paráfrases (55% contra 50%), em vez de uma média que esconde tudo. Depois, o bootstrap pareado (o losango azul): reamostra-se as perguntas com reposição, 5.000 vezes, e em cada reamostra calcula-se a diferença entre dois métodos nas mesmas perguntas. O intervalo que contém 95% das diferenças diz o que dá para afirmar. Se o intervalo cruza o zero, o ganho é incerto (a seta barrada), e é hora de juntar mais perguntas. Se não cruza, o ganho é real e vale guardar o conjunto de perguntas para a próxima mudança.
híbrida RRF menos vetorial e5: hit@1 +2.8 pts [-4.2, +9.7] | mrr +0.038 [-0.004, +0.086]
híbrida RRF menos bm25: hit@1 -1.4 pts [-6.9, +4.2] | mrr -0.000 [-0.037, +0.036]
híbrida pontos (min-max, 0,5) menos vetorial e5: hit@1 +9.7 pts [+2.8, +18.1] | mrr +0.076 [+0.025, +0.134]
híbrida pontos (min-max, 0,5) menos bm25: hit@1 +5.6 pts [+1.4, +11.1] | mrr +0.037 [+0.006, +0.077]
bm25 menos vetorial e5: hit@1 +4.2 pts [-4.2, +12.5] | mrr +0.039 [-0.020, +0.098]
híbrida RRF menos híbrida pontos (min-max, 0,5): hit@1 -6.9 pts [-13.9, -1.4] | mrr -0.037 [-0.078, -0.004]
Leia a saída com cuidado. O RRF é 2,8 pontos melhor que o vetorial, mas o intervalo vai de −4,2 a +9,7: com 72 perguntas, não posso afirmar que ele ganha. O BM25 sozinho, também, não se distingue do vetorial (intervalo de −4,2 a +12,5). A fusão por pontos é a única que se separa das duas, com os ganhos de +9,7 e +5,6 pontos e intervalos que não cruzam o zero, e mesmo assim com as ressalvas da seção 11 (o alfa de 0,5 foi o melhor da varredura, e o ganho encolhe num acervo maior). O que eu defenderia dessas medidas é bem menos do que “a busca híbrida é melhor”: nesse corpus, para as consultas de código e as mistas, o BM25 sozinho resolve, o vetorial sozinho erra um pouco, e a fusão por pontos ganha algo nas paráfrases, que são justamente a categoria com menos perguntas (20) e mais ruído.
Quatro limites desta medida, para você não os herdar. O corpus é pequeno e inventado por mim, e as perguntas também: as de código são fáceis para o BM25 por construção, já que carregam o código exato. Só testei dois modelos de embedding (um pequeno e um de paráfrase) e um stemmer. As paráfrases são 20, e uma pergunta vale 5 pontos. E hit@1 contra um único pedaço-ouro é uma régua dura, que Avaliação de RAG vai suavizar.
18. Erros comuns e boas práticas
O primeiro erro é somar as pontuações brutas. O cosseno e o BM25 estão em escalas diferentes (seção 9), e a soma faz um deles mandar sem decisão sua. Normalize ou use RRF. A razão é a mais simples da aula: a soma de grandezas incomparáveis não tem significado.
O segundo é usar tokenizadores diferentes para o documento e para a pergunta, ou esquecer que o tokenizador precisa servir ao idioma. Um índice feito com stemming em inglês numa base em português quase não casa nada, e “paulo?” colado na pontuação nunca casa com “paulo”. A solução é aplicar o mesmo código aos dois lados e testar com a frase-ouro de Ingestão de documentos (uma frase conhecida, que deve ser achada).
O terceiro é deixar pedaços de score zero entrarem na lista lexical. Eles não casaram nenhum termo, não são evidência, e no RRF ganham posições que somam parcelas. Só entram os que têm score positivo, como o topn(..., so_positivos=True) da seção 11.
O quarto é ajustar pesos e parâmetros nas mesmas perguntas em que se avalia. Os alfas que a seção 12 ajustou em metade das perguntas nunca ganharam do 0,5 na outra metade. Use divisão treino e teste e desconfie de ganhos pequenos.
O quinto é concluir demais de um experimento pequeno. A ordem entre RRF e pontos mudou quando o acervo cresceu (seção 11). Com intervalos de bootstrap que cruzam o zero, a frase certa é “não consigo afirmar”.
O sexto é usar plainto_tsquery (E) numa pergunta em linguagem natural no Postgres: ele devolve vazio em dois terços das perguntas (seção 15). Use OU, ou uma estratégia de duas tentativas.
O sétimo é esperar que o ranking lexical de qualquer banco seja BM25. O ts_rank_cd do Postgres não usa idf. Leia a documentação do seu banco sobre como o ranking lexical é calculado antes de apostar nele para códigos.
O oitavo é esquecer que os dois índices precisam ser atualizados juntos. Se o pedaço muda, o vetor e os termos mudam, e a ingestão incremental de Ingestão de documentos deve gravar nos dois lugares na mesma operação, com o mesmo id estável (é o id que permite juntar as duas listas no mesmo pedaço). Um pedaço apagado de um índice e esquecido no outro aparece na fusão como fantasma.
19. Para ir além
A busca híbrida decide quem entra na lista de candidatos, e deixa os melhores em cima, mas a ordem dentro dessa lista continua sendo uma média de duas opiniões rápidas: uma que olha o significado de longe (o vetor, calculado antes da pergunta existir) e outra que olha palavras. Nas figuras desta aula o pedaço certo às vezes ficou em segundo ou terceiro lugar, e o modelo de linguagem que lê o contexto (Prompt e contexto) sofre com isso, porque os primeiros pedaços pesam mais e a janela é limitada. Reranking ensina uma segunda passada, mais cara e mais precisa, que lê cada candidato junto da pergunta e reordena a lista que a busca híbrida devolveu. As duas aulas formam um par: a híbrida garante que o pedaço certo esteja entre os candidatos (o que se mede pelo hit@k com k grande), e o reranking o põe no topo.
20. Exercícios
Os exercícios 1 a 4 são contas e raciocínio. Os demais rodam em CPU, sem chave de API, a partir de uma pasta sua com pedaços de texto (50 a 200) e perguntas com o pedaço certo marcado, e de um ambiente virtual com sentence-transformers, nltk, rank-bm25 e qdrant-client.
-
Num acervo de 100 pedaços, o termo “estorno” aparece em 4 deles. Calcule o idf (fórmula
ln(1 + (N - df + 0,5) / (df + 0,5))) e a contribuição desse termo para um pedaço de tamanho médio em que ele aparece 2 vezes, com k1 = 1,2. (Resposta: idf = ln(1 + 96,5/4,5) = 3,111; fator de tf = 2 × 2,2 / (2 + 1,2) = 1,375; contribuição = 3,111 × 1,375 = 4,278.) -
O pedaço A é o 1º na busca vetorial e o 5º na lexical. O pedaço B é o 2º nas duas. Com RRF e k = 60, quem vence? E se a busca vetorial tiver peso 2? (Resposta: A = 1/61 + 1/65 = 0,03178 e B = 1/62 + 1/62 = 0,03226, então B vence. Com peso 2 para o vetorial, A = 2/61 + 1/65 = 0,04817 e B = 2/62 + 1/62 = 0,04839, e B ainda vence por pouco. O RRF premia quem é bom nas duas.)
-
Por que o RRF não precisa normalizar as pontuações e a fusão por pontos precisa? Que informação o RRF joga fora, e quando ela faz falta? (Resposta: o RRF usa só a posição, que já está numa escala comum; joga fora a margem entre os candidatos, e ela faz falta quando uma busca tem muita certeza e a outra está em dúvida, como no caso do p48 da seção 11.)
-
Por que um termo que aparece em todos os pedaços tem idf perto de zero, e por que a fórmula com
1 +dentro do logaritmo nunca dá valor negativo? (Resposta: se df = N, o argumento do logaritmo vira 1 + 0,5/(N + 0,5), um pouco acima de 1, e o log fica perto de zero, mas positivo; a versão clássicaln((N - df + 0,5)/(df + 0,5))fica negativa para termos em mais da metade dos pedaços, e o1 +evita isso.) -
Reproduza a seção 6 com 10 frases do seu idioma e do seu domínio: aplique o stemmer a pares de singular e plural e a pares de substantivo e verbo, e liste os que não coincidem. Quantos pares seus falham, e quais deles importam para o seu acervo?
-
Implemente
Indiceebm25sobre os seus pedaços e confira contrarank_bm25(o item de topo deve coincidir na maioria das perguntas). Depois varra k1 de 0 a 3 e b de 0 a 1 e anote o hit@1. A grade é plana como a da seção 8, ou o seu acervo reage a algum parâmetro? -
Monte o conjunto de perguntas em três categorias (código ou nome exato, paráfrase, mistas), com pelo menos 20 de cada. Meça vetorial, BM25 e as duas fusões. Quais são as categorias em que cada método ganha, e o intervalo de bootstrap da diferença cruza o zero?
-
Faça a divisão treino e teste da seção 12 com os seus dados: escolha o alfa numa metade, avalie na outra, repita 20 vezes. O alfa ajustado ganha do 0,5?
-
Acrescente ao acervo 200 pedaços que não têm relação com as perguntas (tabelas de códigos, por exemplo) e meça de novo. O acerto do vetorial nas perguntas de código cai? A fusão por pontos continua à frente do RRF?
-
Se você usa Postgres, reproduza a seção 15: crie a configuração com
unaccent, compareplainto_tsquerycom a versão em OU e a estratégia de duas tentativas, e confira se ots_rank_cdpõe um catálogo com a palavra repetida na frente do pedaço com o código raro.
21. Glossário
| Termo | Significado |
|---|---|
| Busca lexical | Busca por palavras: compara os termos da pergunta com os dos pedaços |
| Busca vetorial | Busca por significado: compara o vetor da pergunta com os vetores dos pedaços |
| Busca híbrida | Combinação das duas buscas numa única lista ordenada |
| Índice invertido | Dicionário de termo para a lista de pedaços que o contêm, com o tf de cada um |
| Lista de postagens | A lista de pedaços (e frequências) de um termo no índice invertido |
| tf | Frequência do termo: quantas vezes aparece no pedaço |
| df | Número de pedaços que contêm o termo |
| idf | Inverso da frequência nos pedaços: mede a raridade de um termo no acervo |
| TF-IDF | Pontuação que multiplica o tf pelo idf, sem saturação nem correção de tamanho |
| BM25 | Refinamento do TF-IDF com saturação do tf (k1) e correção de tamanho (b) |
| k1 | Parâmetro do BM25 que controla o quanto a repetição de um termo ajuda (padrão 1,2) |
| b | Parâmetro do BM25 que controla o quanto o tamanho do pedaço desconta o tf (padrão 0,75) |
| Stopword | Palavra muito comum (o, de, em) que a busca lexical costuma ignorar |
| Stemming | Redução de uma palavra ao radical, para juntar formas como entrega e entregas |
| Subpalavra | Pedaço de palavra em que o tokenizador de um modelo de embedding corta o texto |
| RRF | Reciprocal Rank Fusion: soma de 1 / (k + posição) sobre as listas, com k = 60 no artigo original |
| Min-max | Normalização que leva o menor valor da lista a 0 e o maior a 1 |
| DBSF | Fusão por pontuação normalizada pela média e pelo desvio padrão de cada lista |
| alfa | Peso da busca vetorial numa fusão por pontos (0 é só lexical, 1 é só vetorial) |
| Vetor esparso | Vetor em que quase todas as posições são zero, guardado como lista de posições e valores |
| SPLADE | Modelo que aprende vetores esparsos, com pesos de termos escolhidos por uma rede neural |
| hit@k | Fração das perguntas em que o pedaço certo está entre os k primeiros |
| MRR | Média do inverso da posição do primeiro pedaço certo |
| Bootstrap pareado | Reamostragem das perguntas para estimar a incerteza da diferença entre dois métodos |
| Pedaço-ouro | O pedaço que contém a resposta de uma pergunta do conjunto de avaliação |
Fontes
Consultadas em 15 de outubro de 2026. As frases entre aspas traduzidas do inglês são traduções minhas.
- Qdrant: hybrid queries:
prefetchequery_points; RRF comka partir da v1.16.0 (a página descreve o k como 2 por padrão, com posições a partir de zero) e pesos a partir da v1.17.0; DBSF a partir da v1.11.0, com média e desvio padrão e pontos finais de três desvios; aviso sobre combinar pontuações brutas de vetor denso e esparso sem normalização; guia de escolha (RRF ponderado com conjunto de avaliação, DBSF se confiar nas pontuações, RRF como padrão seguro). - Qdrant: vectors: vetores esparsos guardados como posições e valores, em armazenamento e índice próprios; modelo
qdrant/bm25mencionado na seção de inferência. A página que li não descreve o modificador de idf nem como a similaridade esparsa é calculada. - Qdrant: indexing: o índice esparso é exato; o índice de texto de payload tem tokenizador, minúsculas (ligado por padrão), ASCII folding, stemmer e stopwords (desligados por padrão); “um índice de texto não afeta consultas BM25”. Li só os primeiros 100 mil caracteres da página.
- pgvector (README): a frase “Use together with Postgres full-text search for hybrid search”, o exemplo de SQL com
ts_rank_cde a menção a RRF ou cross-encoder com links para exemplos. O README não traz SQL de RRF. - PostgreSQL: controles de busca de texto:
ts_rankets_rank_cd, o inteironormalizationcomo máscara de bits (0, 1, 2, 4, 8, 16, 32) e a frase de que as funções de ranking não usam informação global. - PostgreSQL: dicionários: stopwords, dicionários Snowball (baseados no trabalho de Martin Porter, com parâmetro
language), o dicionáriosimplee a menção ao módulounaccent. A página de configuração, que li antes, não mencionaportuguesenemunaccent. - Elasticsearch: RRF: retriever
rrf,rank_constantpadrão 60,rank_window_sizepadrão igual asize, “cada retriever filho tem o mesmo peso”, e o exemplo comstandardeknn. - Elasticsearch: similaridade: BM25 com
k1(saturação do tf, padrão 1,2),b(normalização por tamanho, padrão 0,75) ediscount_overlaps. - OpenSearch: normalization-processor e score-ranker-processor, lidos nos arquivos do repositório
opensearch-project/documentation-website(branch main), porque a página do site só devolveu a navegação: normalizaçõesmin_max,l2ez_score, combinaçõesarithmetic_mean,geometric_meaneharmonic_mean, pesos que somam 1,0, e o RRF comrank_constantde 1 a 10000 (padrão 60). Não li a página da consultahybridem si. - Weaviate: busca híbrida:
alpha(1 vetorial pura, 0 por palavras pura; sem alfa, o peso depende do cliente),relativeScoreFusioncomo padrão desde a 1.24,rankedFusion, operadores do BM25 e o parâmetroproperties. - Cormack, Clarke e Büttcher, “Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods”, SIGIR 2009: a fórmula, k = 60 fixado num piloto e a justificativa de a importância dos postos baixos não desaparecer. Li o PDF com extração de texto.
- Formal, Piwowarski e Clinchant, “SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking”, SIGIR 2021 (arXiv 2107.05720): li só o resumo.
- Busca vetorial, Banco de dados vetorial, Chunking e Ingestão de documentos: embeddings, hit@k e MRR, o corpus do Mercado Aurora e os ids estáveis.
Medido por mim (ambiente virtual novo no scratchpad da sessão, macOS, Python 3.12, nada instalado fora dele; sentence-transformers 6.1.0, torch 2.14.1, nltk 3.10.3, rank-bm25 0.2.2, qdrant-client 1.19.1, psycopg 3.3.6, numpy 2.5.4; Postgres 18.1 com pgvector 0.8.7 numa instância descartável): o corpus de 89 pedaços e as 72 perguntas; os tokenizadores e o stemmer; o BM25 e o TF-IDF próprios, a conferência contra o rank_bm25 e a grade de k1 e b; o RRF e a fusão por pontos (min-max e DBSF) com varreduras de k, de peso, de alfa e de profundidade; a divisão treino e teste do alfa; o efeito do tamanho do acervo; o bootstrap pareado; o vetor esparso no Qdrant em modo local com RRF e DBSF; e o Postgres com tsvector, unaccent, ts_rank_cd e RRF em SQL. Os modelos de embedding são o multilingual-e5-small e o paraphrase-multilingual-MiniLM-L12-v2, do cache local do Hugging Face.
Sem execução ou sem fonte: não rodei Elasticsearch, OpenSearch nem Weaviate, e tudo o que a aula diz sobre eles vem da documentação consultada, sem teste. Não rodei nenhum modelo SPLADE (nem sei se há um bom para português), e a parte sobre expansão de termos é a leitura do resumo do artigo, sem verificação no texto completo. Não testei extensões de BM25 para o Postgres, nem o modelo qdrant/bm25 de inferência, nem o Qdrant em servidor (só o modo local). O corpus é pequeno e inventado, as perguntas também, e as de código são fáceis para o BM25 por construção; com 72 perguntas, o erro de um hit@1 é de uns 8 a 10 pontos, e as paráfrases são só 20. Ajustei o alfa de 0,5 olhando a varredura no mesmo conjunto (a seção 12 mostra a versão com treino e teste, em que ele não perdeu para o ajustado), e a vantagem da fusão por pontos encolheu com o tamanho do acervo. As diferenças de 2 pontos entre o RRF do Qdrant e o meu não foram investigadas (a documentação do Qdrant conta posições a partir de zero, e eu a partir de 1). Só testei um stemmer (Snowball do NLTK) e uma lista de stopwords. Os tempos de execução não são citados porque incluíam o carregamento do modelo. Os números de versão e os nomes de parâmetros dos bancos mudam, e valem para a data da consulta. As 15 figuras foram renderizadas e examinadas; as principais (1, 3, 8 e 9) tiveram mais de uma rodada.