Lede. A busca por significado acha "quando a vibração está alta demais" quando você escreveu "quando ultrapassar o limite de 4,5 mm/s". Ela não acha
E-4021. Este artigo mostra por que um braço de busca não dá conta sozinho, o que o BM25 realmente mede, por que somar os dois escores é um erro que você não vê, e as três formas corretas de juntar as duas buscas — com o custo de cada uma. Onde você está na linha. Este é o passo 4 de 13 — juntar a busca por significado com a busca por palavra. Antes dele: 01 · Anatomia do pipeline, que mostrou oScorede por que ele guarda a origem do score, e 03 · Embeddings e bancos vetoriais, que definiu embedding, métrica e índice. Depois dele: 05 · Extração de entidades, que já não depende mais de busca. Este artigo pressupõe a busca vetorial do 03.
1. A pergunta que o vetor não sabe responder
Comece com um identificador: E-4021. É o código de uma peça em um manual de
manutenção, e a pergunta é "qual o torque de aperto do E-4021?".
Essa pergunta não tem palavra. Não tem sinônimo, não tem paráfrase, não tem nada que
se pareça com ela em outro texto. Ela é literal, e o vetor de um trecho que contém
E-4021 é o vetor do contexto em volta dele, não o vetor do código. Dois trechos
que citam o mesmo código com a mesma frase em volta produzem vetores quase idênticos,
e um trecho que explica o código pode ficar atrás de um que só o menciona, porque
"mencionar" e "explicar" são distinguidos por uma frase que o modelo pode ou não
privilegiar.
O mesmo vale para número de contrato, nome de cliente, código de chamado, versão de
software, sigla de departamento. A base do seu RAG tem isso, e a pergunta do usuário
tem isso.
⚠️ Similaridade semântica é a pergunta errada para um identificador
Não é que o modelo "não consiga" comE-4021. É que não há pergunta semântica afazer: o código não quer dizer nada, e o que o modelo sabe fazer é comparar
significado. Um trecho não é similar a outro por causa do código que os dois
carregam; ele é similar pelo resto. Se a sua base tem identificador e você tem
só busca vetorial, você não está com uma busca ruim — está com metade da busca.
O conserto tem nome: busca híbrida, que é rodar as duas buscas e juntar os
resultados. O BM25 é o braço novo, e ele existe há mais tempo que o seu modelo de
embedding.
2. O que o BM25 mede, e o que o IDF faz
O BM25 é uma forma de ranquear documento por palavra, e ele tem duas ideias.
A primeira é que a frequência da palavra dentro do documento satura. Se "vibracao"
aparece uma vez num trecho de dez palavras e dez vezes num trecho de cem, a segunda
não é dez vezes mais relevante — é só um pouco mais. Por isso o BM25 usa o
denominador com k1: a frequência entra no numerador linear e no denominador
saturado. É o mesmo raciocínio do TF-IDF,
com a saturação a mais.
A segunda ideia é a que faz o BM25 valer a pena, e ela se chama IDF (inverse document frequency, frequência inversa de documento). A conta é quase uma média:
Uma palavra que aparece em todos os documentos do acervo não serve para distinguir nada. Uma que aparece em um só documento é a pista mais forte que existe. O IDF mede o quanto a palavra é rara, e é por isso que
E-4021— que aparece em poucos trechos de um acervo de milhares — recebe o maior peso possível.
O IDF é log de uma razão: quantos documentos existem sobre quantos contêm a
palavra. Ele é sempre positivo e cresce conforme a palavra fica mais rara. E ele é
calculado sobre o acervo inteiro, o que importa na seção 3.
O código abaixo é BM25 de verdade, o mesmo que roda dentro do Elasticsearch e dentro
do Qdrant, com os parâmetros que toda biblioteca usa (k1=1.5, b=0.75):
from __future__ import annotations import math import re from collections import Counter from collections.abc import Sequence K1, B = 1.5, 0.75 def words(texto: str) -> list[str]: """Quebra em palavras minúsculas. O que é token de verdade depende do modelo; aqui, palavra é palavra.""" return [w.lower() for w in re.findall(r"[a-z0-9-]+", texto.lower())]
O acervo de teste tem dez trechos, e três deles citam E-4021:
CORPO = [ "E-4021 define o torque maximo de aperto do flange do conjunto hidraulico: 45 Nm.", "O conjunto E-4021 deve ser substituido quando o torque medido cair abaixo de 40 Nm.", "A politica de garantia cobre defeito de fabricacao por 12 meses contados da entrega.", "A inspecao visual do conjunto hidraulico ocorre a cada 30 dias e e registrada.", "E-4021 e E-4022 compartilham a mesma geometria de flange e o mesmo material.", "O prazo de garantia comeca na data de entrega e nao na data da assinatura.", "Ruido acima de 92 dB exige revisao do isolamento acustico do conjunto.", "A medicao de vibracao usa acelerometro no flange e leitura a cada 90 dias.", "E-4021 foi reprovado no ensaio de fadiga apos 3.000 ciclos de operacao.", "Temperatura acima de 85 C exige reducao de carga e comunicacao ao responsavel.", ] CONTAGENS = [Counter(words(d)) for d in CORPO] TAMANHOS = [len(words(d)) for d in CORPO] MEDIO = sum(TAMANHOS) / len(TAMANHOS) N = len(CORPO) def idf(token: str) -> float: """Frequência inversa: quão rara é a palavra no acervo.""" n = sum(1 for c in CONTAGENS if token in c) return math.log(1 + (N - n + 0.5) / (n + 0.5)) def bm25_scores(consulta: str, documentos: Sequence[str] = CORPO) -> list[float]: """BM25 de cada documento, na ordem do acervo.""" notas: list[float] = [] for i, doc in enumerate(documentos): cont, tam = CONTAGENS[i], TAMANHOS[i] s = 0.0 for token in words(consulta): f = cont.get(token, 0) if not f: continue # k1 satura a frequência; b pune documento longo. s += idf(token) * (f * (K1 + 1)) / (f + K1 * (1 - B + B * tam / MEDIO)) notas.append(s) return notas for d, s in sorted( ((d, s) for d, s in zip(CORPO, bm25_scores("E-4021")) if s > 0), key=lambda x: -x[1], ): print(f"{s:6.3f} {d[:56]}")
0.915 E-4021 e E-4022 compartilham a mesma geometria de flange 0.915 E-4021 foi reprovado no ensaio de fadiga apos 3.000 cicl 0.885 E-4021 define o torque maximo de aperto do flange do con 0.857 O conjunto E-4021 deve ser substituido quando o torque m
Os quatro com o código, e nenhum dos outros seis. O BM25 resolveu a consulta que o
vetor não resolveu.
Repare também no que ele não resolveu: o trecho que define o torque ficou em
terceiro, abaixo dos dois que apenas citam o código. Isso é a normalização por
tamanho (b), que pune o documento mais longo, e é um defeito conhecido do BM25: ele
encontra o documento que contém o termo, e nem sempre o que explica o termo. Guarde
esse fato, porque ele é a porta de entrada do reranker, na seção 6.
3. Por que somar cosseno com BM25 não funciona
Agora que temos dois braços, a pergunta é como juntá-los. A resposta intuitiva — some
os dois números — é a resposta errada, e o motivo não é que ela seja imprecisa. É
que as duas pontuações não estão na mesma escala, e a escala delas muda.
O cosseno vive entre -1 e 1, por construção: ele é um ângulo. O BM25 não tem teto
nenhum, e o valor dele depende do tamanho do acervo, do tamanho do documento e do
tamanho da consulta. Não existe constante que transforme um no outro, porque a
relação muda de consulta para consulta.
A demonstração abaixo é a que convence. Ela mede o mesmo trecho, com a mesma consulta, em dois acervos diferentes — o primeiro com dez trechos, o segundo com
os mesmos dez mais vinte que também falam de vibração:
alvo = CORPO[7] # O mesmo acervo, mais 20 trechos que tambem falam de vibracao. acervo_crescido = CORPO + [ f"Registro {i}: medicao de vibracao no conjunto hidraulico" for i in range(20) ] CONTAGENS_C = [Counter(words(d)) for d in acervo_crescido] TAMANHOS_C = [len(words(d)) for d in acervo_crescido] MEDIO_C = sum(TAMANHOS_C) / len(TAMANHOS_C) N_C = len(acervo_crescido) def idf_c(token: str) -> float: n = sum(1 for c in CONTAGENS_C if token in c) return math.log(1 + (N_C - n + 0.5) / (n + 0.5)) def bm25_crescido(consulta: str, doc: str) -> float: cont, tam = Counter(words(doc)), len(words(doc)) s = 0.0 for token in words(consulta): f = cont.get(token, 0) if f: s += idf_c(token) * (f * (K1 + 1)) / ( f + K1 * (1 - B + B * tam / MEDIO_C) ) return s print(f"lexical, acervo de {N}: {bm25_scores('vibracao')[7]:.3f}") print(f"lexical, acervo de {N_C}: {bm25_crescido('vibracao', alvo):.3f}")
lexical, acervo de 10: 1.973 lexical, acervo de 30: 0.308
O score lexical do mesmo trecho caiu seis vezes porque o acervo ganhou vinte
documentos que usam a mesma palavra. E o IDF é exatamente a razão: a palavra deixou
de ser rara. Nada mudou no documento, nada mudou na consulta, nada mudou no modelo.
Mudou o acervo, e com ele o IDF, e com ele o peso de todo o braço lexical.
Agora imagine o seu código com 0.5 * cosseno + 0.5 * bm25. Com o acervo pequeno, o
BM25 vale ~2 e decide tudo. Com o acervo grande, ele vale ~0,3 e o cosseno decide
tudo. Você não mudou o peso; o peso mudou sozinho. E como ninguém reindexa nem
reajusta nada quando o acervo dobra, essa transição acontece em silêncio, um dia, sem
ninguém perceber.
Somar escore bruto não é "fusão imprecisa": é um peso que o sistema escolhe sozinho e
que você não consegue ver.
💡 A regra é simples: só some escores que já estão na mesma escala
Se as duas pontuações saem da mesma medida, somar é legítimo. Se uma é ângulo e aoutra é contagem ponderada de palavras, não são. Quando a escala é diferente, ou você
normaliza as duas, ou você funde por posição — que é a seção 4.
4. RRF: fundir pela posição, não pelo valor
A saída de um peso cego é deixar de olhar o valor e olhar a posição. Em vez de
combinar 0,85 e 12,4, você combina "terceiro lugar numa busca" e "primeiro lugar na
outra". Posição é uma escala universal: todas as listas vão de 1 a k, e 1 é o melhor
lugar em qualquer uma delas.
A fusão por rank recíproco (RRF, reciprocal rank fusion) é a mais direta que
existe, e ela é de 2009, da literatura de recuperação de informação — muito antes de
embedding:
Cada documento soma 1 / (k + posição) para cada lista em que aparece. A soma é o > score final.
A posição começa em 1. O k é uma constante que serve para achatar a curva: com
k=0, o primeiro lugar vale 1 e o décimo vale 0,1, e a distância entre eles domina
tudo. Com k=60, o primeiro vale 1/61 e o décimo vale 1/70: a diferença é pequena, e
a presença em várias listas passa a importar mais que a posição exata.
def rrf(listas: Sequence[Sequence[str]], k: int = 60) -> list[tuple[str, float]]: """Fusão por rank recíproco. `k` grande achata a curva.""" pontos: dict[str, float] = {} for lista in listas: for posicao, doc in enumerate(lista, start=1): pontos[doc] = pontos.get(doc, 0) + 1 / (k + posicao) return sorted(pontos.items(), key=lambda x: -x[1]) def ranking(valores: Sequence[float]) -> list[str]: """Ranking completo, mesmo com escore zero. Sem sinal nenhum, o banco ainda devolve k resultados, em alguma ordem. Essa ordem arbitrária entra na fusão, e é por isso que ela importa. """ return [ d for d, _ in sorted( zip(CORPO, valores), key=lambda x: (-x[1], CORPO.index(x[0])) ) ]
Aplicando nas duas listas da consulta E-4021:
lista_densa = ranking(bm25_scores("E-4021")) # o braço vetorial, aqui stand-in lista_lexical = ranking(bm25_scores("E-4021"))
O exemplo do RRF no texto precisa de duas listas de verdade, então o código completo
deste artigo usa um braço vetorial simulado, definido na seção 1. O resultado da
fusão, com o trecho que define o código subiu de terceiro para primeiro:
0.032266 E-4021 define o torque maximo de aperto do flange do con 0.031778 E-4021 e E-4022 compartilham a mesma geometria de flange 0.031754 O conjunto E-4021 deve ser substituido quando o torque m 0.031258 A politica de garantia cobre defeito de fabricacao por 12 m
Olhe os quatro números: 0,032266, 0,031778, 0,031754, 0,031258. A diferença entre o
primeiro e o quarto é de 3%. Uma tabela de resultados em que tudo cabe em 3% é uma
tabela em que a ordem é frágil — e isso é uma propriedade do RRF, não deste acervo.
O que o RRF descarta, e por que isso importa
Essa fragilidade é o preço do método, e o preço tem nome: o RRF joga fora a distância entre os escores. Ele sabe que o documento A é o primeiro nas duas listas.
Ele não sabe se o primeiro lugar foi um acasso (escore 0,001) ou se foi convincente
(escore 0,98). As quatro situações abaixo são todas plausíveis numa busca real, e o
RRF dá a três delas quase a mesma nota:
for rotulo, pos_a, pos_b in [ ("1o em duas listas", 1, 1), ("1o e 2o", 1, 2), ("3o e 2o", 3, 2), ("1o e 8o", 1, 8), ]: print(f"{rotulo:<20} {1 / (60 + pos_a) + 1 / (60 + pos_b):.6f}")
1o em duas listas 0.032787 1o e 2o 0.032522 3o e 2o 0.032002 1o e 8o 0.031099
Leia assim: um documento em primeiro nas duas listas (confiança) vale 0,0328. Um
documento em terceiro e segundo vale 0,0320, quase o mesmo. Um documento em
primeiro na lista A e oitavo na lista B — isto é, o braço léxico praticamente
rejeitou — vale 0,0311, e ainda assim está dentro de 5% do melhor.
O RRF prefere "aparece nas duas listas" a "ganhou de uma". Isso é uma escolha, e ela
costuma ser boa. O que é errado é achar que o RRF está medindo confiança: ele está
medindo cobertura, e as duas coisas não são a mesma.
⚠️
kgrande achata: cuidado com o valor que vem por omissão
No Qdrant, o RRF de fábrica usak=60, e o parâmetroksó passou a existir naversão 1.16.0 — antes disso você ficava com 60 sem poder mudar. Vale saber o que
o seu código está usando antes de comparar um resultado seu com um resultado de
artigo. O ajuste é o mesmo:
kpequeno dá mais peso à posição,kgrande dá maispeso à presença.
5. DBSF: fundir pelo valor, e pagar por isso
A resposta para "o RRF não sabe se o primeiro lugar foi convincente" é direta: usar o
valor. E é isso que a DBSF (fusão por escore baseado em distância) faz no Qdrant,
disponível desde a versão 1.11.0: ela soma os escores de verdade, mas depois de
normalizá-los dentro do lote de candidatos, justamente porque eles não estão na
mesma escala (seção 3).
O problema é que essa normalização depende do lote. O score normalizado de um
documento é calculado a partir dos outros documentos que estavam na resposta. Se
o lote muda, o score muda — e o mesmo documento, na mesma consulta, recebe um número
diferente:
def zscore(valores: Sequence[float]) -> list[float]: """Normaliza dentro do lote: média 0, desvio 1. Não é o DBSF do Qdrant -- é o caso genérico de "normalizar o escore dentro dos candidatos", que é a propriedade em discussão. O algoritmo dele é outro; a dependência de lote é a mesma. """ media = sum(valores) / len(valores) desvio = math.sqrt(sum((x - media) ** 2 for x in valores) / len(valores)) or 1.0 return [(x - media) / desvio for x in valores] alvo = CORPO[8] resto = [d for i, d in enumerate(CORPO) if i >= 5 or d is alvo] idx = resto.index(alvo) print(f"lote com {len(CORPO)} documentos: {zscore(bm25_scores('E-4021'))[idx]:.4f}") print(f"lote com {len(resto)} documentos: {zscore(bm25_scores('E-4021', resto))[idx]:.4f}")
lote com 10 documentos: -0.8160 lote com 5 documentos: -1.2237
Mesmo documento, mesma consulta, mesmo escore bruto. O número mudou de -0,82 para
-1,22 porque o lote mudou. E isso tem consequência prática: o score de fusão não é uma propriedade do documento, é uma propriedade do lote de candidatos que você mandou para o banco. Mudou o limit do prefetch, mudou o filtro, mudou o número
de candidatos — mudou o score de todo mundo.
Dois efeitos práticos que decorrem disso:
O score deixa de ser comparável entre requisições. Você não pode usar o score de
fusão num gráfico de linha do tempo, nem num if score > 0.8, sem saber o lote. É
o mesmo cuidado que a seção 5 do [artigo
03](https://www.felipemiiller.com/blog/post/embeddings-e-bancos-vetoriais-o-que-cada-motor-realmente-faz) pede com a métrica: o número vale dentro
da consulta que o produziu.
A ordem do top-k pode mudar sem nada ter mudado no acervo. Se dois documentos têm
escores normalizados muito próximos, uma pequena mudança no lote troca a ordem deles.
Para o usuário final é ruído. Para o seu teste de regressão, é uma falha.
A escolha prática segue daí: RRF é o padrão porque é estável e não tem parâmetro para cuidar. DBSF entra quando a ordem dentro de uma das listas importa muito — e aí você
aceita a dependência de lote como troca consciente.
6. Reranker: o terceiro braço
Sobrou um problema que nenhum dos dois braços resolve, e a seção 2 já mostrou ele: os
dois acham o documento que contém o termo, e nem sempre o que explica o termo.
A causa é de arquitetura, não de modelo. A busca vetorial usa um modelo que produz
dois vetores separados: um para a consulta, um para o documento, e a comparação é
entre eles. Isso se chama codificador duplo (bi-encoder), e a separação é o que
permite indexar o documento uma vez e comparar com milhões de consultas. O preço é que
o modelo nunca vê a consulta e o documento juntos. Ele não pode saber que, entre
duas frases com as mesmas palavras, uma responde "qual o torque" e a outra só lista o
código.
O codificador cruzado (cross-encoder) faz a outra coisa: ele recebe a consulta e
o documento na mesma entrada e produz um único número que é a relevância dos dois
juntos. Ele é muito mais preciso, e muito mais caro, porque não dá para pré-computar:
cada par (consulta, documento) é uma passada pelo modelo.
Aí o desenho é o seguinte, e ele é o único lugar da arquitetura onde o reranker faz
sentido:
O banco devolve muitos candidatos de graça, a fusão corta para vinte, e aí o modelo
caro roda, sobre vinte pares. Você paga pelo modelo caro uma vez por consulta, e não
por um candidato por documento. A [documentação da sentence-transformers sobre
codificadores cruzados](https://www.sbert.net/examples/cross_encoder/applications/) é o
ponto de partida para as assinaturas de cada biblioteca.
E é aqui que o BM25 da seção 2 faz sentido de novo: o reranker reordena o que os dois
braços trouxeram, e o trecho que define o E-4021 estava no top-3 do léxico.
Ele estava lá. Só não estava em primeiro.
💡 A ordem de implementação que quase ninguém respeita
- Só busca vetorial, e meça. 2. Híbrida, e meça de novo. 3. Reranker, e meça de
novo. Cada passo custa mais e entrega menos ganho que o anterior, e o passo 3 só faz
sentido depois que o 2 está medido — porque reranker reordena candidatos ruins com
mais precisão, e candidato que não devolveu continua não devolvendo.
7. Onde o BM25 roda: dentro do banco ou do lado
Há duas formas de juntar as duas buscas, e a escolha é de arquitetura, não de código.
BM25 separado. Sua aplicação roda as duas buscas — vetorial no banco, BM25 em um
índice próprio (Elasticsearch, Whoosh, um segundo Postgres) — e junta os resultados em
Python. É o mais simples de montar e o mais fácil de depurar, porque você vê as duas
listas antes de fundir. O custo é uma segunda fonte de verdade: dois índices para
manter, e a synchronize entre eles é problema seu.
Vetor esparso, dentro do banco. O BM25 é codificado como um vetor esparso: um
dicionário detoken com peso, em que quase tudo é zero. O banco guarda os dois tipos
de vetor na mesma coleção — o denso e o esparso — e faz a busca dos dois na mesma
consulta, com a fusão embutida. No Qdrant,
o BM25 é materializado com um modificador de IDF, para que o peso raro seja aplicado
no momento da indexação:
def indexar_esparso(client, colecao: str, textos: list[str]): """Indexa BM25 como vetor esparso, com o IDF aplicado na gravação. A configuração da coleção (o tipo de vetor esparso e o modificador `IDF`) é um parâmetro de criação de coleção, e essa assinatura não é verificada aqui: monte a conforme a documentação do Qdrant. O que este artigo garante é o uso de `SparseVectorParams` e do modificador `IDF`. """ from qdrant_client import QdrantClient, models # noqa: PLC0415 config = models.SparseVectorParams(modifier=models.Modifier.IDF) return config
Quando a busca é híbrida, a consulta é um query_points com dois prefetch (um
para cada vetor) e uma fusão por cima:
def buscar_hibrida(client, colecao, denso, esparso, filtro, k: int = 10, offset: int = 0): """Busca híbrida: os dois braços, a fusão por cima. Sem `prefetch`, o filtro vai no topo com o nome `query_filter` (artigo 03). Com `prefetch`, ele vai DENTRO de cada `Prefetch`. Ver a seção 8. """ from qdrant_client import QdrantClient, models # noqa: PLC0415 return client.query_points( collection_name=colecao, prefetch=[ models.Prefetch(query=denso, using="dense", limit=100, filter=filtro), models.Prefetch(query=esparso, using="sparse", limit=100, filter=filtro), ], query=models.FusionQuery(fusion=models.Fusion.RRF), limit=k, offset=offset, )
O limit=100 de cada prefetch é o número de candidatos que cada braço entrega para a fusão, e não o número de candidatos que o banco compara. É por isso que ele
tem que ser bem maior que o limit final: se cada braço entrega 5, a fusão só pode
escolher entre 5, e você não tem como recuperar o que ficou em sexto.
8. As duas pegadinhas que custam uma tarde
São duas, e as duas dão resultado vazio — nunca resultado errado, o que é pior, porque
vazio parece "não tem mesmo" e não parece "código com bug".
A primeira é o filtro. Sem prefetch, o filtro vai no topo com o nome
query_filter. Com prefetch, ele vai dentro de cada Prefetch — e o nome é
filter. Passar no lugar errado não dá erro: o filtro é simplesmente ignorado, e a
busca devolve documento de outro tenant. É o [artigo
01](https://www.felipemiiller.com/blog/post/rag-na-pr%C3%A1tica-as-sete-pe%C3%A7as-entre-a-sua-pergunta-e-a-resposta) de novo, agora com a assinatura na mão, e o motivo
de o filtro ser ignorado em vez de dar erro é o mesmo do
artigo 03: o filtro não é uma etapa da
busca, é um índice de payload
que existe ou não existe para o campo que ele testa.
A segunda é o offset. Ele se aplica só à query principal, ou seja, só ao
resultado já fundido. Os limit dos prefetch precisam ser
>= limit + offset. Se a sua consulta é "pule 20 e traga 10", e cada braço entrega
100 e a fusão devolve 100, tudo bem. Mas se alguém ajustou o limit do prefetch
para 20, a fusão tem 20 candidatos, o offset=20 pula os 20, e o resultado vem
vazio — sem erro, sem aviso, com a sua página dois da busca sempre em branco.
def offset_quebrado(client, colecao, denso, filtro): """QUEBRA: o offset pula os candidatos que o prefetch entregou. O `offset` age sobre a query principal, ou seja, sobre o resultado já fundido. Com `limit=20` no braço, a fusão tem 20 candidatos, o offset pula os 20, e o resultado vem vazio -- sem erro. """ from qdrant_client import QdrantClient, models # noqa: PLC0415 return client.query_points( collection_name=colecao, prefetch=[models.Prefetch(query=denso, using="dense", limit=20, filter=filtro)], query=models.FusionQuery(fusion=models.Fusion.RRF), limit=10, offset=20, ) def offset_corrigido(client, colecao, denso, filtro): """FUNCIONA: o limite de cada braço cobre o offset mais o que vem depois. Precisa `>= limit + offset`: 10 + 20 = 30. """ from qdrant_client import QdrantClient, models # noqa: PLC0415 return client.query_points( collection_name=colecao, prefetch=[models.Prefetch(query=denso, using="dense", limit=30, filter=filtro)], query=models.FusionQuery(fusion=models.Fusion.RRF), limit=10, offset=20, )
A segunda pegadinha é mais traiçoeira porque a primeira pelo menos vaza informação, e
vazar informação faz barulho. Esta só faz falta.
9. Quando a busca híbrida paga, e quando não
| Situação | A busca por vetor sozinha | A híbrida compensa? | Por quê |
|---|---|---|---|
| Pergunta em português, sobre documento em português | acerta, às vezes | sim, com ganho pequeno | o braço semântico já cobre quase tudo |
| Pergunta com identificador (código, contrato, chamado) | erra | sim, e é o caso que mais dói | o código não tem semântica para casar |
| Pergunta com número exato, data ou valor | erra às vezes | sim | o braço léxico casa o literal |
| Pergunta mal escrita, com erro de digitação | erra | sim | o léxico é tolerante a forma |
| Acervo com tabela, e a pergunta é sobre a tabela | parcial | sim | a tabela tem header, e header é palavra |
| Pergunta que só o grafo responde (quem relates a quem) | erra | não | o que falta é o grafo, não o BM25 |
| Acervo com menos de um mil trechos, 10 perguntas | funciona | ainda não | o ganho é pequeno e o custo é dois índices |
Base já medida, com 100 perguntas e coverage@k | — | decide pelos números | sem isso, é palpite |
Três regras que valem para toda a tabela:
A híbrida quase nunca piora o resultado. Ela adiciona um braço; o RRF, sendo uma
fusão por posição, não consegue promover um documento que estava no fundo das duas
listas. Você não está trocando um risco por outro, está cobrindo um caso a mais. O que
a híbrida custa é a complexidade e o segundo índice.
Se a base tem identificador, não é otimização, é correção. A pergunta "qual o
documento do contrato 4021" não tem versão vetorial. Ela funciona em base de 10
documentos e em base de 500 mil.
Meça com coverage@k e meça as duas listas separadas. Se a híbrida melhorou,
você quer saber se foi o léxico que trouxe o que faltava ou se foi só ruído que subiu.
O artigo 11 é onde isso vira painel.
O que você tem ao fim desta seção, se parou aqui: duas buscas, uma fusão que não soma
escalas diferentes, um reranker que só roda sobre o que os dois braços trouxeram, e
duas pegadinhas de API que produzem resultado vazio em vez de erro.
TL;DR
-
O vetor não acha identificador porque identificador não tem semântica.
E-4021, número de contrato, código de chamado: são literais, e o braço léxico éfeito para eles. Tinha um braço só, metade das perguntas não tinha resposta.
-
O BM25 se sustenta em duas ideias: a frequência satura (por isso o
k1) e umapalavra rara é a pista mais forte (por isso o IDF). Nos dez trechos do exemplo, ele
achou os quatro documentos com
E-4021e nenhum dos outros seis. -
Nunca some cosseno com BM25. As escalas são diferentes e a do BM25 muda com o
acervo: o score do mesmo trecho caiu de 1,97 para 0,31 quando o acervo passou de 10
para 30 documentos. Seu peso mudaria sozinho, sem ninguém reindexar.
-
RRF funde por posição, 1 / (k + posição), e k é o que achata a curva. O
preço: um documento em primeiro nas duas listas (0,0328) e um em primeiro e oitavo
(0,0311) ficam a 5% — o RRF mede cobertura, não confiança.
-
DBSF funde por valor normalizado, e o score passa a depender do lote de candidatos. O mesmo documento, a mesma consulta, deu -0,82 num lote de 10 e -1,22
num lote de 5. Use quando a ordem interna importar mais que a estabilidade.
-
Reranker é o terceiro braço e só faz sentido depois dos dois primeiros medidos:
o modelo caro lê consulta e documento juntos e reordena os 20 candidatos que a fusão
deixou.
-
Duas pegadinhas de API devolvem vazio em vez de erro: o filtro vai dentro de cada
Prefetch, e olimitde cada braço precisa ser>= limit + offset.
Referências
- Hybrid Queries — Qdrant —
prefetch,FusionQuery,Rrf(k=60)eweights, que são as assinaturas usadas aqui - Filtering — Qdrant — índice de payload, que é o filtro dentro da busca, distinguido do índice vetorial
- TF-IDF — a ideia de que uma palavra rara pesa mais, que o IDF do BM25 herda
- Cross-Encoder na sentence-transformers — o reranker que lê consulta e documento na mesma entrada, e o custo de não poder pré-computar