FMFelipe MiillerNotes on software & systems
HomeBlogAbout
GitHub

Keep building.

Felipe Miiller · © 2026

MailGitHubGitHubLinkedinGitHub
View source on GitHub
Back to blog

Busca híbrida: quando o vetorial puro erra e o léxico salva

04/10/2026
19 min de leitura
5489 palavras
RAGPythonAnálise de Dados
  • 1. A pergunta que o vetor não sabe responder
  • 2. O que o BM25 mede, e o que o IDF faz
  • 3. Por que somar cosseno com BM25 não funciona
  • O mesmo acervo, mais 20 trechos que tambem falam de vibracao.
  • 4. RRF: fundir pela posição, não pelo valor
  • O que o RRF descarta, e por que isso importa
  • 5. DBSF: fundir pelo valor, e pagar por isso
  • 6. Reranker: o terceiro braço
  • 7. Onde o BM25 roda: dentro do banco ou do lado
  • 8. As duas pegadinhas que custam uma tarde
  • 9. Quando a busca híbrida paga, e quando não
  • TL;DR
  • Referências

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 o Scored e 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" com E-4021. É que não há pergunta semântica a

fazer: 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 a

outra é 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.

⚠️ k grande achata: cuidado com o valor que vem por omissão
No Qdrant, o RRF de fábrica usa k=60, e o parâmetro k só passou a existir na

versã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: k pequeno dá mais peso à posição, k grande dá mais

peso à 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

  1. 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çãoA busca por vetor sozinhaA híbrida compensa?Por quê
Pergunta em português, sobre documento em portuguêsacerta, às vezessim, com ganho pequenoo braço semântico já cobre quase tudo
Pergunta com identificador (código, contrato, chamado)errasim, e é o caso que mais dóio código não tem semântica para casar
Pergunta com número exato, data ou valorerra às vezessimo braço léxico casa o literal
Pergunta mal escrita, com erro de digitaçãoerrasimo léxico é tolerante a forma
Acervo com tabela, e a pergunta é sobre a tabelaparcialsima tabela tem header, e header é palavra
Pergunta que só o grafo responde (quem relates a quem)erranãoo que falta é o grafo, não o BM25
Acervo com menos de um mil trechos, 10 perguntasfuncionaainda nãoo ganho é pequeno e o custo é dois índices
Base já medida, com 100 perguntas e coverage@k—decide pelos númerossem 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 uma

    palavra rara é a pista mais forte (por isso o IDF). Nos dez trechos do exemplo, ele

    achou os quatro documentos com E-4021 e 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 o limit de cada braço precisa ser >= limit + offset.


Referências

  • Hybrid Queries — Qdrant — prefetch, FusionQuery, Rrf(k=60) e weights, 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