Pular para o conteúdo

Trilha de RAG · parte 10 de 12

Reranking: reordenando candidatos antes de entregá-los ao modelo

Como melhorar a ordem dos resultados com uma segunda leitura mais precisa, e quantos candidatos e pedaços vale usar em cada etapa.

Publicada em · 59 min de leitura

Tamanho da fonte:
Largura da coluna:

Reranking reordena uma lista de candidatos com uma segunda leitura mais cara e mais precisa. A busca rápida traz dezenas de pedaços, mas a ordem entre eles é uma média de opiniões rápidas, e nem sempre o melhor fica em cima. Ao terminar, você deve entender por que existem dois estágios, como um cross-encoder lê a pergunta e o pedaço juntos, quantos candidatos e quantos pedaços usar, como o texto lido pelo reranker afeta o resultado e como medir se a reordenação ajuda.

A aula parte do zero, mas pressupõe Chunking e Busca vetorial. A escada vai de uma analogia com a triagem de currículos até medições em CPU, passando por um resultado que talvez te surpreenda: com o corpus e o modelo desta aula, o reranker melhora muito uma busca por palavras e quase nada uma busca vetorial que já era boa. Todo o código foi executado por mim, numa máquina com macOS e Python 3.12, e as saídas coladas são as que o programa imprimiu. O que muda com o tempo (modelos e parâmetros de APIs) vem da documentação consultada em 15 de outubro de 2026, e o que 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

ParteSeçõesPergunta que responde
1. O problema1 a 3O que é reranking, por que a ordem dos resultados importa e como funciona a recuperação em dois estágios?
2. O cross-encoder4 a 8Por que ler pergunta e pedaço juntos é mais preciso e mais lento, e o que isso rende de verdade?
3. Outros rerankers9 a 12Que famílias de reranker existem e o que as documentações dizem das APIs?
4. Decidir e medir13 a 18Quantos candidatos, a que custo, com que limiar, com que diversidade, e como saber se ganhou?
5. Fechar19 a 22Que erros evitar, para ir além, exercícios e glossário

Pré-requisito: Busca híbrida. Os diagramas seguem um mapa de cores fixo, com uma cor a mais: verde limão (o tema neon do ArchFlow) para o reranker, que é o assunto desta aula. Laranja continua sendo o que a pessoa controla (perguntas, prompt), 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, custa caro ou fica de fora.

Parte 1: O problema

1. A triagem de currículos

Uma empresa abre uma vaga e recebe 500 currículos. Ninguém lê 500 currículos com atenção. O que o RH faz é uma primeira triagem rápida: procura as palavras da vaga, descarta quem claramente não serve e separa cinquenta. Depois, a pessoa que vai decidir lê esses cinquenta com calma, cruzando o que cada candidato fez com o que a vaga pede, e entrevista dez. A triagem é barata e erra na ordem (um currículo ótimo, mas escrito com outras palavras, pode ficar na posição 40). A leitura cuidadosa é cara e só é viável porque a triagem já cortou 450 candidatos.

Reranking é o nome técnico da segunda etapa. Num sistema de busca, a primeira etapa (recuperação) acha rápido um conjunto de candidatos entre milhões de pedaços, e a segunda (reranking, ou reordenação) lê cada candidato junto com a pergunta, com um modelo mais caro, e devolve a lista numa ordem melhor. O reranker não encontra pedaços novos: só reordena os que a primeira etapa trouxe, e por isso o resultado dele nunca passa da qualidade do que recebeu. Duas coisas decidem o que ele consegue entregar, a qualidade do próprio modelo e a quantidade de candidatos que a primeira etapa ainda inclui.

onde entra
figura 1 · onde entra

A figura mostra o lugar do reranker no pipeline que você já conhece. A pergunta (laranja) e o índice (cilindro verde) alimentam a busca (azul), que entrega cerca de 50 candidatos numa ordem aproximada (a seta azul grossa, com vários pacotes). O reranker (verde limão, pulsando porque é o assunto de hoje) recebe os 50 pares, devagar (os pacotes são lentos de propósito, porque aqui mora o custo), e devolve uma pontuação para cada um. O losango compara a melhor pontuação com um limiar: acima dele, os cinco melhores seguem para o prompt (seta verde grossa, e depois o modelo de linguagem); abaixo, a seta é barrada com ✕, porque a resposta honesta é “não sei” em vez de entregar contexto fraco. As seções 13 a 15 detalham cada uma dessas decisões.

2. Por que a ordem importa

Como visto em Prompt e contexto, a janela de contexto é um espaço limitado e que cada token custa dinheiro e atenção. A consequência para o RAG é simples: dos 50 pedaços que a busca achou, só uns 3 a 8 vão para o prompt. O resto é jogado fora, e o que decide quem entra é a posição na lista. Se a resposta estiver na posição 7 e o corte for em 5, o modelo nunca a vê, e vai responder com o que tiver, que são pedaços vizinhos e plausíveis. A falha é traiçoeira porque, para quem olha só a resposta final, parece alucinação do modelo, quando o erro já tinha acontecido duas etapas antes.

ordem importa
figura 2 · ordem importa

A figura compara a mesma lista de sete candidatos antes e depois do reranking. À esquerda (grupo sem reranking), a busca colocou uma tabela de prazos de frete em primeiro e a resposta certa (“trocas: 7 dias corridos”) em sétimo; o limite do contexto é k = 5, então os dois últimos pedaços ficam de fora (rosa tracejado) e a resposta se perde. No meio, o reranker (verde limão) lê cada par pergunta mais pedaço e faz a resposta subir da posição 7 para a 1 (seta verde grossa, com pacotes). À direita, ela é o primeiro item do contexto, e quem fica de fora agora são dois pedaços de assuntos vizinhos. É uma ilustração construída por mim para mostrar o mecanismo; as medidas reais estão nas seções 7 e 8.

Há uma segunda razão para a ordem importar, que não depende do corte. Mesmo dentro do contexto, a posição do pedaço influencia o quanto o modelo o aproveita. O artigo “Lost in the Middle” (Liu e colegas, 2023) mediu isso em tarefas com vários documentos no prompt e relata que o desempenho costuma ser mais alto quando a informação relevante está no começo ou no fim do contexto, e cai quando ela está no meio, mesmo em modelos feitos para contextos longos. O padrão lembra a letra U, embora o resumo do artigo não use esse termo. Eu não reproduzi esse experimento (não tenho um modelo de linguagem para rodar aqui), de modo que a aula só repete o achado do artigo. A decisão prática que ele sugere é colocar os melhores pedaços nas pontas do contexto, e a mais segura é colocar o melhor primeiro. A ideia de reservar o segundo melhor para o fim é uma dedução minha a partir do padrão, que não testei.

meio
figura 3 · meio

Na figura, as posições 1 e 7 (verde) são onde o modelo tende a usar melhor o que recebe, e as posições 3 a 5 (rosa, degradadas) são onde ele tende a usar pior. O retângulo de baixo guarda a consequência: ordem boa é, antes de tudo, pôr os melhores nas pontas. Um reranker bom faz o trabalho de ordenar, e a montagem do prompt (Prompt e contexto) decide onde cada pedaço cai.

3. Recuperação em dois estágios

A razão de existirem dois estágios é uma conta de custo. A busca vetorial funciona porque cada pedaço virou um vetor antes da pergunta chegar: na hora da consulta, só a pergunta passa por um modelo (cerca de 19 ms nas minhas medidas, com o e5-small em CPU), e o resto é comparação de vetores, que um índice aproximado faz em milissegundos mesmo entre milhões. Esse é o estágio 1, e ele existe para ser barato e ter recall alto: trazer o pedaço certo para dentro de uma lista de dezenas, mesmo que não no topo. Recall, aqui, é a fração das perguntas para as quais o pedaço certo aparece em algum lugar da lista de candidatos.

O estágio 2 inverte as prioridades. Ele recebe poucos candidatos e pode se dar ao luxo de rodar um modelo inteiro por par, porque só vai pagar esse custo dezenas de vezes por consulta, e não milhões. Se você tentasse o estágio 2 sobre todo o acervo, o custo mataria a ideia: com os 5 ms por par que medi (seção 14), reordenar um milhão de pedaços levaria cerca de 5.000 segundos, mais de uma hora por pergunta. A conta explica o desenho em funil.

funil
figura 4 · funil

A figura desce da fonte para o modelo de linguagem. O acervo (cilindro) alimenta o estágio 1 com milhões de vetores já calculados (os pacotes rápidos e finos, porque comparar vetores é barato), e o estágio 1 entrega um top-N, em que N é o número de candidatos, de 50 a 100. O estágio 2 (verde limão, com o selo lento) recebe N pares e entrega o top-k (os pacotes lentos e a seta grossa), em que k é o número de pedaços que vão para o prompt, de 3 a 8. Os símbolos N e k aparecem o tempo todo daqui em diante: N é o quanto você reordena, k é o quanto você entrega.

Um parêntese sobre filtros por metadados, que também mexem na lista. Filtros como “só documentos públicos” ou “só de 2026” decidem quem pode concorrer, e por isso valem antes do estágio 1, dentro da consulta ao banco, como Ingestão de documentos mostrou no caso das permissões. Reranking decide a ordem entre os que podem concorrer. Misturar as duas coisas (filtrar depois do reranking, por exemplo) gasta o orçamento do reranker em candidatos que seriam descartados.

Parte 2: O cross-encoder

4. Bi-encoder e cross-encoder

O modelo de embedding de Embeddings é um bi-encoder: ele lê o texto sozinho e produz um vetor, e dois textos são comparados depois, pelos seus vetores. O nome vem das duas passagens independentes, uma pela pergunta e outra pelo pedaço, cada uma em seu momento. A grande vantagem é que a passagem do pedaço acontece uma vez só, na ingestão, e o vetor fica guardado. A desvantagem é que o vetor do pedaço precisa servir a qualquer pergunta futura, e ao comprimir um parágrafo em 384 números (no caso do e5-small), o modelo perde detalhes que só importam para certas perguntas, como um número, uma negação ou qual dos três planos é o da pergunta.

Um cross-encoder faz o oposto: recebe a pergunta e o pedaço juntos, numa só sequência de entrada, e produz uma única pontuação de relevância. Como os dois textos passam pelo modelo ao mesmo tempo, os mecanismos de atenção do transformer deixam cada token da pergunta olhar para cada token do pedaço e vice-versa, e o modelo pode checar se “por ano” na pergunta casa com “plano anual, R$ 199” no pedaço. É por isso que ele costuma ser mais preciso. E é por isso que ele é mais lento: a entrada depende da pergunta, então nada pode ser calculado antes. Para cada consulta, são N passagens completas pelo modelo, uma por candidato.

bi vs cross
figura 5 · bi vs cross

A figura coloca os dois lado a lado. No grupo do bi-encoder (à esquerda), a pergunta (laranja) e o pedaço (roxo) entram sozinhos, cada um no seu modelo (azul). O ramo do pedaço é pontilhado e lento, porque roda uma vez por pedaço na ingestão (“offline”) e termina no cilindro de vetores guardados; só o ramo da pergunta roda na hora da consulta, e o cosseno compara o vetor da pergunta com N vetores já prontos. No grupo do cross-encoder (à direita), cada candidato forma um par com a pergunta, e o modelo (verde limão, pulsando) roda uma passada por par, na hora da consulta, entregando uma pontuação. A seta grossa e lenta, com vários pacotes, representa os N pares. Essa assimetria é a razão de existirem dois estágios: o bi-encoder vale para escolher entre milhões, o cross-encoder vale para decidir entre dezenas.

A literatura sobre ranking com BERT, que popularizou a ideia, mostrou ganhos grandes: Nogueira e Cho (2019) relatam que um reranker com BERT ficou em primeiro na tabela do MS MARCO, com ganho de 27% em MRR@10 em relação ao melhor anterior (os números são do resumo do artigo). A seção 7 mostra que esse tipo de ganho depende do ponto de partida.

5. O cross-encoder por dentro

Para ver o que sai de um cross-encoder, usei o cross-encoder/mmarco-mMiniLMv2-L12-H384-v1, que o cartão do modelo no Hugging Face descreve como um reranker multilíngue com licença Apache-2.0, treinado no mMARCO (uma versão do MS MARCO traduzida automaticamente para 14 idiomas, segundo o cartão) sobre a base mMiniLMv2, de 12 camadas e dimensão 384. Pesa cerca de 470 MB (o tamanho do arquivo de pesos, medido no cabeçalho do download), tem uns 0,1 bilhão de parâmetros e foi a única coisa nova que baixei para esta aula. Escolhi um modelo pequeno de propósito, para rodar em CPU, e o resultado da aula vale para ele: outro modelo, maior ou treinado de outro jeito, pode se comportar de forma bem diferente, e eu não testei nenhum.

A figura seguinte acompanha um exemplo real da minha medição: a pergunta “Quanto custa o Prime por ano?”, e três candidatos que a busca por palavras trouxe.

cross por dentro
figura 6 · cross por dentro

À esquerda, a pergunta (laranja) e três pedaços (roxo). Cada pedaço se junta à pergunta numa sequência [CLS] pergunta [SEP] pedaço [SEP], em que [CLS] e [SEP] são tokens especiais que o modelo usa para marcar o começo e a separação dos dois textos. As três sequências entram no mesmo transformer (verde limão, pulsando), que cruza a atenção entre todos os tokens. À direita saem três pontuações: o pedaço A, que contém a resposta (“A assinatura custa R$ 19,90 por mês. No plano anual, o valor é de R$ 199”), recebe -1,08, a mais alta; o B (frete grátis) recebe -2,28; e o C (troca por vontade do cliente), -3,10. O último passo ordena as pontuações. As pontuações são negativas porque este modelo devolve um logit, um número sem limite, em vez de um valor entre 0 e 1 (a seção 15 volta a isso). O código que produz a tabela, com todos os candidatos da busca por palavras:

from base import *

P = pedacos(60)
i = [k for k, (_, q, _) in enumerate(QS) if q == "Quanto custa o Prime por ano?"][0]
q, ouro = QS[i][1], QS[i][2]
S_bm = primeiro_estagio(P, "bm25")[i]; S_e5 = primeiro_estagio(P, "e5")[i]
cand = list(np.argsort(-S_bm))[:6]                                   # os 6 primeiros do BM25
pontos = modelo("ce").predict([(q, P[j][1]) for j in cand], show_progress_bar=False)
print("pergunta:", q)
print(f"{'pos. BM25':>9} {'BM25':>6} {'cosseno':>8} {'cross-enc.':>10}  pedaço")
for r, j in enumerate(cand):
    marca = "  <- contém a resposta" if ouro in P[j][1] else ""
    print(f"{r + 1:>9} {S_bm[j]:>6.1f} {S_e5[j]:>8.3f} {pontos[r]:>10.2f}  {P[j][1][:70]}{marca}")
nova = [cand[k] for k in np.argsort(-pontos)]
print("ordem depois do cross-encoder (posições do BM25):", [cand.index(j) + 1 for j in nova])
pergunta: Quanto custa o Prime por ano?
pos. BM25   BM25  cosseno cross-enc.  pedaço
        1    6.5    0.803     -10.62  Cada plano define em quanto tempo o suporte responde pela primeira vez
        2    5.7    0.844      -8.12  # Assinatura Aurora Prime O Aurora Prime é a assinatura do Mercado Aur
        3    5.5    0.813      -3.10  Quando a troca é por vontade do cliente, como tamanho ou cor escolhido
        4    4.8    0.813      -4.73  O pedido pode ser pago com até 2 cartões. O cliente informa quanto cob
        5    4.3    0.859      -1.08  A assinatura custa R$ 19,90 por mês. No plano anual, o valor é de R$ 1  <- contém a resposta
        6    3.7    0.875      -2.28  O frete é grátis para pedidos acima de R$ 249 nas regiões Sul e Sudest
ordem depois do cross-encoder (posições do BM25): [5, 6, 3, 4, 2, 1]

A coluna da esquerda é a posição que o BM25 (a busca por palavras) deu, e a pergunta tem a resposta no pedaço que ele colocou em quinto lugar. Os três primeiros do BM25 falam de suporte, do próprio Prime sem preço e de trocas, todos com palavras da pergunta. O cross-encoder deu a nota mais alta ao pedaço da resposta e reordenou: [5, 6, 3, 4, 2, 1] são as posições antigas na ordem nova. Repare também na coluna do cosseno do e5: entre esses seis pedaços, ele já daria à resposta o segundo melhor valor (0,859, contra 0,875 do pedaço de frete grátis). A seção 7 mede o que isso quer dizer em 56 perguntas.

6. O laboratório

Os experimentos usam o mini-corpus de Chunking: 9 documentos em Markdown do e-commerce fictício Mercado Aurora e as mesmas 56 perguntas, cada uma com uma frase-resposta marcada. O corte é o recursivo, com pedaços de até 60 tokens, que gera 112 pedaços (menores que os de até 100 tokens da ingestão, de propósito, para a lista de candidatos ter variedade e o reranking ter o que fazer). Um pedaço acerta quando contém a frase-resposta inteira, e as métricas são as de Chunking: hit@k (fração das perguntas em que algum dos k primeiros pedaços acerta) e MRR (média do inverso da posição do primeiro acerto). Instalei, num ambiente virtual novo e só meu, o sentence-transformers 6.1.0 (com torch 2.14.1 e transformers 5.20.0) e o tiktoken 0.14.0. Tudo roda em CPU (8 threads), sem chave de API. O arquivo base, que todos os experimentos importam, define o modelo, os pedaços, os quatro primeiros estágios e as métricas:

"""Base das experiências: pedaços do corpus de Chunking, primeiros estágios, cross-encoder e métricas."""
import math, re, time
from collections import Counter
import numpy as np
from sentence_transformers import SentenceTransformer, CrossEncoder
from corpus import QS, DOCS, ws          # 56 perguntas com frase-resposta
import chunkers

CE_NOME = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
_modelos = {}

def modelo(nome):
    if nome not in _modelos:
        if nome == "ce":
            _modelos[nome] = CrossEncoder(CE_NOME, device="cpu")
        else:
            _modelos[nome] = SentenceTransformer(nome, device="cpu")
    return _modelos[nome]

E5 = "intfloat/multilingual-e5-small"
MINILM = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

def pedacos(tam=60):
    """(documento, texto) de cada pedaço: corte recursivo de até `tam` tokens."""
    return [(d, ws(DOCS[d][a:b])) for d in DOCS for a, b in chunkers.recursivo(DOCS[d], tam) if ws(DOCS[d][a:b])]

def tok(s):
    return re.findall(r"\w+", s.lower())

def bm25(P, qs, k1=1.5, b=0.75):
    docs = [tok(t) for _, t in P]; n = len(docs); med = sum(map(len, docs)) / n
    df = Counter(w for d in docs for w in set(d)); out = np.zeros((len(qs), n))
    for i, q in enumerate(qs):
        for w in set(tok(q)) & set(df):
            idf = math.log(1 + (n - df[w] + .5) / (df[w] + .5))
            for j, d in enumerate(docs):
                f = d.count(w)
                if f: out[i, j] += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * len(d) / med))
    return out

def vetorial(P, qs, nome):
    pq, pp = ("query: ", "passage: ") if nome == E5 else ("", "")
    E = modelo(nome).encode([pp + t for _, t in P], normalize_embeddings=True)
    Q = modelo(nome).encode([pq + q for q in qs], normalize_embeddings=True)
    return Q @ E.T

def rrf(*matrizes, k=60):
    """Fusão por posição: soma 1/(k+posição) em cada lista."""
    out = np.zeros_like(matrizes[0])
    for M in matrizes:
        pos = np.argsort(np.argsort(-M, axis=1), axis=1) + 1
        out += 1 / (k + pos)
    return out

def primeiro_estagio(P, nome):
    qs = [q for _, q, _ in QS]
    if nome == "bm25": return bm25(P, qs)
    if nome == "minilm": return vetorial(P, qs, MINILM)
    if nome == "e5": return vetorial(P, qs, E5)
    if nome == "híbrido": return rrf(bm25(P, qs), vetorial(P, qs, E5))

def reordena(P, S, N):
    """Pega os N primeiros de cada linha de S, pontua cada par (pergunta, pedaço) e reordena."""
    ordens, scores = [], []
    for i, (_, q, _) in enumerate(QS):
        ordem = list(np.argsort(-S[i]))
        cand = ordem[:N]
        s = modelo("ce").predict([(q, P[j][1]) for j in cand], show_progress_bar=False)
        ordens.append([cand[k] for k in np.argsort(-s)] + ordem[N:]); scores.append(sorted(s, reverse=True))
    return ordens, scores

def ordens_de(S):
    return [list(np.argsort(-S[i])) for i in range(len(S))]

def acertos(P, ordens):
    """acertos[i][r] = True se o pedaço na posição r da pergunta i contém a frase-resposta inteira."""
    return [[QS[i][2] in P[j][1] for j in o] for i, o in enumerate(ordens)]

def metricas(ac):
    n = len(ac)
    h = lambda k: sum(any(a[:k]) for a in ac) / n
    mrr = sum(next((1 / (r + 1) for r, x in enumerate(a) if x), 0) for a in ac) / n
    return dict(h1=h(1), h3=h(3), h5=h(5), mrr=mrr)

Os quatro primeiros estágios merecem uma palavra. O BM25 é a busca por palavras (a implementação é minha, curta, com os parâmetros usuais k1 = 1,5 e b = 0,75). O minilm é o paraphrase-multilingual-MiniLM-L12-v2 e o e5 é o multilingual-e5-small, os dois modelos de embedding de Embeddings. O híbrido é um rascunho meu da fusão por posição (reciprocal rank fusion, com k = 60) entre BM25 e e5, só para ter um primeiro estágio parecido com o que a Busca híbrida entrega; os detalhes da fusão são de Busca híbrida, e o que escrevi aqui serve apenas a este experimento. A função reordena pega os N primeiros de cada lista, pontua cada par com o cross-encoder e reordena só esses N, deixando o resto na ordem original.

7. O que o reranker mudou, medido

O primeiro experimento reordena os 20 primeiros candidatos de cada um dos quatro primeiros estágios e compara as métricas antes e depois. Para cada um, conta também em quantas perguntas o topo passou a acertar e em quantas deixou de acertar, e aplica um teste do sinal (que só olha esses dois números e pergunta se o desequilíbrio entre eles é maior do que o acaso explicaria).

from base import *

P = pedacos(60)
print(f"{len(P)} pedaços, {len(QS)} perguntas, reordenando os N=20 primeiros")
print(f"{'primeiro estágio':<16} {'recall@20':>9} | {'hit@1':>11} {'hit@3':>11} {'hit@5':>11} {'MRR':>11}")
for nome in ("bm25", "minilm", "e5", "híbrido"):
    S = primeiro_estagio(P, nome)
    antes = acertos(P, ordens_de(S))
    depois = acertos(P, reordena(P, S, 20)[0])
    rec = sum(any(a[:20]) for a in antes) / len(QS)
    a, d = metricas(antes), metricas(depois)
    f = lambda k: f"{a[k]:.2f} -> {d[k]:.2f}"
    print(f"{nome:<16} {rec:>9.2f} | {f('h1'):>11} {f('h3'):>11} {f('h5'):>11} {f('mrr'):>11}")
    melhor = sum((not x[0]) and y[0] for x, y in zip(antes, depois))
    pior = sum(x[0] and not y[0] for x, y in zip(antes, depois))
    n = melhor + pior
    p = min(1, 2 * sum(math.comb(n, i) for i in range(0, min(melhor, pior) + 1)) / 2 ** n) if n else 1
    print(f"{'':<16} {'':>9} | hit@1: {melhor} perguntas passaram a acertar, {pior} deixaram de acertar (teste do sinal, p={p:.3f})")
112 pedaços, 56 perguntas, reordenando os N=20 primeiros
primeiro estágio recall@20 |       hit@1       hit@3       hit@5         MRR
bm25                  0.96 | 0.50 -> 0.77 0.77 -> 0.93 0.84 -> 0.95 0.65 -> 0.85
                           | hit@1: 15 perguntas passaram a acertar, 0 deixaram de acertar (teste do sinal, p=0.000)
minilm                0.96 | 0.71 -> 0.75 0.82 -> 0.93 0.88 -> 0.95 0.79 -> 0.84
                           | hit@1: 6 perguntas passaram a acertar, 4 deixaram de acertar (teste do sinal, p=0.754)
e5                    1.00 | 0.80 -> 0.75 0.96 -> 0.91 0.98 -> 0.96 0.88 -> 0.84
                           | hit@1: 7 perguntas passaram a acertar, 10 deixaram de acertar (teste do sinal, p=0.629)
híbrido               0.98 | 0.77 -> 0.77 0.93 -> 0.95 0.95 -> 0.96 0.86 -> 0.85
                           | hit@1: 6 perguntas passaram a acertar, 6 deixaram de acertar (teste do sinal, p=1.000)
medido
figura 7 · medido

A figura resume a tabela. Os quatro primeiros estágios (azul, à esquerda) passam pelo mesmo cross-encoder (verde limão, no centro), e a espessura de cada seta indica o tamanho do efeito. Com o BM25 (seta grossa), o hit@1 sobe de 0,50 para 0,77 e o MRR de 0,65 para 0,85, e das 56 perguntas, 15 passaram a acertar no topo e nenhuma deixou de acertar (p abaixo de 0,001, o que não é acaso). Com o MiniLM, o hit@3 sobe de 0,82 para 0,93, mas o hit@1 quase não se mexe (6 ganhos, 4 perdas, dentro do ruído). Com o híbrido, nada muda (6 e 6). E com o e5, o resultado é o oposto do esperado: o hit@1 cai de 0,80 para 0,75, com 7 ganhos e 10 perdas (p = 0,63, também ruído, mas sem ganho nenhum).

O que a tabela ensina, em ordem de importância. Primeiro, o reranker puxou todos os primeiros estágios para o mesmo lugar, hit@1 entre 0,75 e 0,77. Esse patamar é o teto deste cross-encoder neste corpus, e quem já estava acima dele só pode perder. Segundo, o ganho é grande quando o primeiro estágio é fraco no topo mas bom no recall (o BM25 tem 0,96 de recall@20 e só 0,50 de hit@1: tinha a resposta na lista, na posição errada). Terceiro, com 56 perguntas, o erro de um hit rate é de uns 8 a 13 pontos para mais ou para menos, então diferenças pequenas não valem nada sem o teste pareado, e só a linha do BM25 passa nele.

Por que o cross-encoder perde para o e5? Olhei as dez perguntas que ele estragou. Em 5 delas, o pedaço certo era uma linha de tabela (”| Nordeste | 6 a 9 dias úteis | 3 dias úteis |”), e o cross-encoder preferiu um parágrafo corrido sobre o assunto geral, como “Há quatro modalidades. A entrega padrão é a mais barata e chega em dias úteis”, ou o cabeçalho da tabela. Nas outras 5, escolheu um pedaço de assunto vizinho (para a validade do token da API, por exemplo, escolheu um pedaço sobre a validade dos pontos de fidelidade). Para um modelo treinado em passagens de texto corrido, como as do MS MARCO, uma tabela em Markdown quase não parece resposta, e esse é um comportamento previsível: um reranker herda o domínio dos dados em que foi treinado, e os seus documentos podem ser bem diferentes. O que o experimento da próxima seção mostra é que parte do problema pode estar no texto entregue a ele.

8. O texto que o reranker lê

Uma pergunta natural depois da seção anterior é se o problema é do modelo ou do que entregamos a ele. O pedaço, no índice, perdeu o título do documento e da seção (Chunking mostrou que isso prejudica o bi-encoder, e a técnica do “pedaço com contexto” corrige). O reranker lê o mesmo texto, e uma linha de tabela solta, sem o título “Planos de suporte”, é difícil de julgar até para uma pessoa. O experimento seguinte entrega ao cross-encoder o pedaço com o prefixo “Documento > Seção:”, que Chunking usou, e compara com o pedaço puro.

from base import *

# o reranker lê o mesmo pedaço com ou sem o prefixo "Documento > Seção"
def secao(d, pos):
    t = DOCS[d]; h1 = re.match(r"# (.+)", t).group(1)
    h2 = [m for m in re.finditer(r"^## (.+)$", t, flags=re.M) if m.start() <= pos]
    return f"{h1} > {h2[-1].group(1)}" if h2 else h1

SP = [(d, a, b) for d in DOCS for a, b in chunkers.recursivo(DOCS[d], 60) if ws(DOCS[d][a:b])]
P = [(d, ws(DOCS[d][a:b])) for d, a, b in SP]
CTX = [f"{secao(d, a)}: {ws(DOCS[d][a:b])}" for d, a, b in SP]
ce = modelo("ce")
print(f"{'primeiro estágio':<16} {'':<14} {'hit@1':>6} {'hit@3':>6} {'MRR':>6}")
for nome in ("bm25", "e5"):
    S = primeiro_estagio(P, nome); base_ac = acertos(P, ordens_de(S))
    m = metricas(base_ac); print(f"{nome:<16} {'sem reranking':<14} {m['h1']:>6.2f} {m['h3']:>6.2f} {m['mrr']:>6.2f}")
    for rotulo, textos in (("pedaço puro", [t for _, t in P]), ("com contexto", CTX)):
        ordens = []
        for i, (_, q, _) in enumerate(QS):
            o = list(np.argsort(-S[i])); c = o[:20]
            s = ce.predict([(q, textos[j]) for j in c], show_progress_bar=False)
            ordens.append([c[k] for k in np.argsort(-s)] + o[20:])
        ac = acertos(P, ordens); m = metricas(ac); print(f"{nome:<16} {rotulo:<14} {m['h1']:>6.2f} {m['h3']:>6.2f} {m['mrr']:>6.2f}", end="")
        subiu = sum((not a[0]) and b[0] for a, b in zip(base_ac, ac)); caiu = sum(a[0] and not b[0] for a, b in zip(base_ac, ac))
        print(f"   hit@1 em relação ao sem reranking: +{subiu} -{caiu}")
primeiro estágio                 hit@1  hit@3    MRR
bm25             sem reranking    0.50   0.77   0.65
bm25             pedaço puro      0.77   0.93   0.85   hit@1 em relação ao sem reranking: +15 -0
bm25             com contexto     0.89   0.95   0.92   hit@1 em relação ao sem reranking: +22 -0
e5               sem reranking    0.80   0.96   0.88
e5               pedaço puro      0.75   0.91   0.84   hit@1 em relação ao sem reranking: +7 -10
e5               com contexto     0.89   0.96   0.93   hit@1 em relação ao sem reranking: +9 -4
contexto do reranker
figura 8 · contexto do reranker

A figura segue o mesmo pedaço (roxo, uma linha de tabela de planos de suporte) por dois caminhos. No de cima, ele entra como está no índice e o resultado é o que você viu: hit@1 de 0,75 com o e5 e 0,77 com o BM25 (rosa). No de baixo, ele entra com o prefixo de Chunking (seta grossa), e o hit@1 sobe para 0,89 nos dois (verde). Os dois reordenadores são o mesmo modelo, e a única diferença é o texto de entrada. Com o BM25 como primeiro estágio, o prefixo rendeu 22 perguntas ganhas e nenhuma perdida em relação a não ter reranking. Com o e5, foram 9 ganhas e 4 perdidas, e o MRR foi de 0,88 para 0,93, mas com 13 trocas em 56 perguntas a evidência é moderada, e eu a trataria como uma pista a confirmar nos seus dados. A conclusão prática é não avaliar um reranker sobre o texto bruto do índice sem antes testar uma versão com contexto: o texto que vai para o reranker é uma escolha de projeto, e ela pode valer mais que trocar de modelo.

Parte 3: Outros rerankers

9. As famílias de reranker

O cross-encoder pequeno que usei é uma de quatro famílias, que se distinguem pelo que aceitam trocar entre precisão, latência, custo e esforço de operação. A figura as coloca em paralelo, saindo da mesma lista de candidatos e chegando à mesma lista reordenada.

mapa rerankers
figura 9 · mapa rerankers

Os quatro retângulos do meio são as famílias, e a seta grossa de volta destaca o cross-encoder só porque é o que esta aula testa. O cross-encoder (verde limão) é a base: um modelo pequeno dá uma nota por par, o custo cresce com o número de pares. A late interaction (azul), da qual o ColBERT é o exemplo clássico, guarda um vetor por token e compara tokens entre si, trocando espaço de índice por velocidade (a seção 10 mostra a conta). O LLM como reranker (laranja) pede a um modelo de linguagem que ordene ou pontue, o que custa tokens (seção 11). E as APIs de reranking (cinza) escondem um desses modelos atrás de um serviço (seção 12). O que as separa na prática é o quanto cada uma custa por consulta e o quanto você quer operar.

Há uma quinta dimensão que atravessa as famílias: o que o modelo viu no treinamento. O experimento da seção 7 mostrou que um cross-encoder treinado em texto corrido pode falhar em tabelas, e a dependência vale para todas as famílias. Antes de escolher uma, rode a seção 18 nos seus documentos.

10. Late interaction e ColBERT

A late interaction tenta ficar com o melhor dos dois mundos. Como o bi-encoder, ela codifica a pergunta e o documento separadamente, de modo que as representações dos documentos podem ser calculadas antes, e por isso é rápida. Mas, em vez de um vetor por texto, guarda um vetor por token. O ColBERT (Khattab e Zaharia, 2020) é o modelo que popularizou essa arquitetura: o resumo do artigo diz que ele codifica pergunta e documento de forma independente com BERT, calcula os documentos offline e aplica uma etapa leve de interação para modelar a similaridade fina, e relata execução cerca de duas ordens de grandeza mais rápida que rerankers BERT que passam o par inteiro pela rede, com desempenho competitivo com eles.

A interação leve se chama MaxSim. Para cada token da pergunta, procura-se o token do documento mais parecido (o máximo das similaridades), e soma-se esses máximos. Cada palavra da pergunta “escolhe” o seu melhor par no documento, e o documento é bom na medida em que todas as palavras da pergunta encontram um bom par.

late interaction
figura 10 · late interaction

A figura é uma miniatura com números inventados, só para ilustrar. À esquerda, três tokens de pergunta (laranja) e, à direita, cinco tokens de um pedaço (roxo, vetores guardados antes). As linhas finas pontilhadas são as similaridades que não ganharam, e as três linhas grossas são os máximos: “prazo” encontra “dias” (0,9), “devolver” encontra “troca” (0,8) e “geladeira” encontra “duráveis” (0,7). A soma, 2,4, é a pontuação do pedaço. Nenhum desses números vem de um modelo real.

O preço está no espaço. Contei, com o tokenizador do e5, quantos vetores o meu mini-corpus teria: 112 vetores de 384 números num bi-encoder (43.008 números), contra 5.251 vetores de 384 números com um vetor por token (2.016.384 números), 47 vezes mais. Um modelo ColBERT real usa outro tokenizador e pode reduzir a dimensão de cada vetor, então a razão exata seria outra, mas a ordem de grandeza (dezenas de vezes mais espaço) acompanha o desenho. Na prática, a late interaction aparece de duas formas: como reranker sobre os candidatos (guardando os vetores por token só deles) e como índice próprio, com índices comprimidos. Eu não rodei nenhum modelo ColBERT; a seção cobre o que o artigo e as documentações dizem, e a página da Jina lista um jina-colbert-v2 entre seus modelos (seção 12).

11. LLM como reranker

Um modelo de linguagem também sabe ler uma pergunta e um texto e dizer se um responde ao outro. Há duas formas de pedir isso. Na pointwise, uma chamada por pedaço: o prompt traz a pergunta e um pedaço, e o modelo devolve uma nota (de 0 a 10, ou sim ou não). Na listwise, uma chamada só: o prompt traz a pergunta e todos os candidatos numerados, e o modelo devolve a ordem, algo como [3] > [1] > [2].

llm reranker
figura 11 · llm reranker

A figura separa as duas. No grupo de cima (pointwise), N documentos laranja entram no modelo (cinza) em N chamadas lentas, e cada uma devolve uma nota. No de baixo (listwise), uma só chamada recebe todos os pedaços numerados e devolve a permutação. O artigo “Is ChatGPT Good at Search?” (Sun e colegas, 2023) propôs a versão listwise como “permutação instruída” e, como o modelo não aguenta cem pedaços no prompt, aplica uma janela deslizante: nos experimentos do artigo, a janela é de 20 pedaços com passo de 10, o que dá 10 chamadas por pergunta para reordenar 100 candidatos. A caixa rosa tracejada guarda um problema que o apêndice do mesmo artigo registra: o modelo às vezes repete ou omite identificadores na resposta, e o sistema precisa tratar uma saída que não é uma permutação válida.

A vantagem dos rerankers por LLM é a flexibilidade (o prompt carrega instruções, como “prefira documentos oficiais”) e a qualidade em tarefas difíceis: o resumo do artigo diz que LLMs bem instruídos dão resultados competitivos, até superiores, aos de métodos supervisionados de ponta em benchmarks populares. O preço é latência e dinheiro. O mesmo artigo mede, nas condições dele, cerca de 1,1 s por chamada com o gpt-3.5-turbo e 3,2 s com o gpt-4, ou 11 s e 32 s por pergunta com as 10 chamadas sequenciais, e custo de cerca de 0,60 dólar por pergunta com o gpt-4 em 100 candidatos (e 0,098 dólar reordenando só os 30 primeiros). Esses valores são de 2023 e de modelos antigos, e servem só como ordem de grandeza.

Para o meu corpus, medi o que se sabe medir sem chamar um modelo: o tamanho do prompt listwise. Montei o prompt (instrução, pergunta, pedaços numerados e o formato de resposta) e contei os tokens com o o200k_base do tiktoken, que é só uma estimativa, já que cada modelo tem o seu tokenizador:

from base import *
import tiktoken

P = pedacos(60)
S = primeiro_estagio(P, "e5")
q = QS[0][1]
cand = list(np.argsort(-S[0]))

# 1. reranker por LLM, listwise: um prompt com os N candidatos numerados
def prompt_listwise(q, textos):
    cab = f"Ordene os trechos abaixo do mais ao menos útil para responder à pergunta.\nPergunta: {q}\n\n"
    corpo = "".join(f"[{k + 1}] {t}\n" for k, t in enumerate(textos))
    return cab + corpo + "\nResponda só com os números, do mais útil ao menos útil, no formato [3] > [1] > [2]."
enc = tiktoken.get_encoding("o200k_base")
print("reranker por LLM (prompt listwise, tokens contados com o200k_base, só uma estimativa):")
for N in (10, 20, 50):
    print(f"  N={N:>3}: {len(enc.encode(prompt_listwise(q, [P[j][1] for j in cand[:N]]))):>5} tokens de entrada por pergunta")

# 2. late interaction (ColBERT): um vetor por token em vez de um por pedaço
e5 = modelo(E5)
n_tok = [len(e5.tokenizer(t)["input_ids"]) for _, t in P]
dim = e5.get_embedding_dimension()
print(f"\nbi-encoder: {len(P)} vetores de {dim} números = {len(P) * dim:,} números")
print(f"late interaction (um vetor por token): {sum(n_tok)} vetores de {dim} números = {sum(n_tok) * dim:,} números, {sum(n_tok) / len(P):.0f}x mais")
print("(contagem pelo tokenizador do e5; um modelo ColBERT real usa outro tokenizador e pode reduzir a dimensão por token)")

# 3. MaxSim em miniatura: 2 tokens de pergunta contra 3 tokens de documento (números inventados para ilustrar)
q_t = np.array([[1.0, 0.0], [0.0, 1.0]]); d_t = np.array([[0.9, 0.1], [0.2, 0.8], [0.5, 0.5]])
sim = q_t @ d_t.T
print("\nMaxSim de brinquedo: matriz de similaridade token a token\n", sim, "\nmáximo por token da pergunta:", sim.max(axis=1), "-> soma =", round(float(sim.max(axis=1).sum()), 2))
reranker por LLM (prompt listwise, tokens contados com o200k_base, só uma estimativa):
  N= 10:   537 tokens de entrada por pergunta
  N= 20:  1047 tokens de entrada por pergunta
  N= 50:  2549 tokens de entrada por pergunta

bi-encoder: 112 vetores de 384 números = 43,008 números
late interaction (um vetor por token): 5251 vetores de 384 números = 2,016,384 números, 47x mais
(contagem pelo tokenizador do e5; um modelo ColBERT real usa outro tokenizador e pode reduzir a dimensão por token)

MaxSim de brinquedo: matriz de similaridade token a token
 [[0.9 0.2 0.5]
 [0.1 0.8 0.5]] 
máximo por token da pergunta: [0.9 0.8] -> soma = 1.7

Com pedaços de 60 tokens, o prompt listwise tem 537 tokens para N = 10, 1.047 para N = 20 e 2.549 para N = 50, e esse custo se repete a cada pergunta. Com pedaços de 400 tokens, numa estimativa grosseira, as contas seriam umas seis vezes maiores. Repare que eu não chamei nenhum modelo de linguagem, então esta aula não tem nenhuma medida de qualidade de reranker por LLM.

12. APIs de reranking

Quem não quer hospedar um modelo pode chamar um serviço. As três que a aula consulta têm uma interface parecida: você manda a pergunta e uma lista de textos, e recebe de volta os índices e as pontuações em ordem. A tabela traz o que as documentações dizem (consultadas em 15 de outubro de 2026), e nada além disso: preços e latência real eu não verifiquei.

ServiçoModelos citados na documentaçãoInterface e limites que a documentação cita
Cohere Rerankrerank-v4.0-pro (qualidade máxima), rerank-v4.0-fast (baixa latência), rerank-v3.5, rerank-english-v3.0, rerank-multilingual-v3.0POST /v2/rerank com model, query, documents, top_n, max_tokens_per_doc (padrão 4096) e priority. Recomenda não mandar mais de 1.000 documentos por requisição. Os modelos v3 têm contexto de 4096 tokens, e se pergunta e documento passam do limite, o documento é dividido e processado em várias inferências. relevance_score é normalizado em [0, 1] e a documentação avisa que não são razões (0,9 não é o dobro de 0,45)
Voyage AIrerank-3 (maior precisão), rerank-3-lite (menor latência), além de modelos legadosPOST https://api.voyageai.com/v1/rerank, com query, documents, model, top_k e truncation (padrão True). Até 1.000 documentos por requisição e 32.000 tokens por par nos modelos novos; limite de 600 mil tokens totais, contando a pergunta uma vez por documento. A resposta traz index, document, relevance_score e total_tokens. A documentação não define a escala da pontuação, e os exemplos mostram valores entre 0 e 1
Jina Rerankerjina-reranker-v3.5 e v3 (listwise), jina-reranker-m0 (multimodal), jina-reranker-v2-base-multilingual (cross-encoder menor), jina-colbert-v2 (late interaction)POST https://api.jina.ai/v1/rerank com model, query, documents, top_n e return_documents. Contexto de 1.024 tokens no v2 (com divisão automática de documentos longos) e de 131.072 no v3 e v3.5. A página cita latência de cerca de 150 ms para 100 documentos de 256 tokens com pergunta de 64 tokens, e licença CC BY-NC 4.0 para os modelos abertos, com uso comercial por licença à parte

Três coisas aparecem em todas. A primeira é o limite por documento, que trunca ou divide pedaços grandes, e por isso o tamanho do pedaço (Chunking) continua importando. A segunda é um parâmetro que limita quantos resultados voltam (top_n na Cohere e na Jina, top_k na Voyage), que é o k desta aula. A terceira é que a pontuação serve para ordenar, e só a Cohere diz que ela é normalizada, com a ressalva de que não é uma razão. Isso importa na seção 15, quando a pontuação virar um limiar: cada serviço e cada modelo têm a sua escala, e um limiar que funciona num não serve no outro.

Parte 4: Decidir e medir

13. Quantos candidatos: N e k

Há duas perguntas de tamanho, e elas não se misturam. O k é quantos pedaços vão para o prompt, e depende da janela de contexto, do custo em tokens e do tipo de pergunta (o pipeline completo volta a isso). O N é quantos candidatos o reranker lê, e depende do recall do primeiro estágio e da latência que você aguenta. N maior dá ao reranker mais chances de achar o pedaço certo no fundo da lista, mas custa linearmente mais, e também oferece mais chances de erro do próprio reranker.

Para ver isso, pontuei uma vez todos os 6.272 pares possíveis (56 perguntas por 112 pedaços) e, para cada N, reordenei só os N primeiros da lista de cada primeiro estágio. A coluna recall@N diz quantas perguntas têm o pedaço certo entre os N primeiros antes de qualquer reranking, que é o teto do que o reranker pode entregar.

from base import *

P = pedacos(60)
ce = modelo("ce")
# pontua todos os pares (pergunta, pedaço) uma única vez; cada N abaixo só escolhe um subconjunto
T = np.array([ce.predict([(q, t) for _, t in P], show_progress_bar=False, batch_size=64) for _, q, _ in QS])
print(f"{T.shape[0]} perguntas x {T.shape[1]} pedaços = {T.size} pares pontuados")

def com_corte(S, N):
    ordens = []
    for i in range(len(QS)):
        o = list(np.argsort(-S[i])); c = o[:N]
        ordens.append(sorted(c, key=lambda j: -T[i, j]) + o[N:])
    return ordens

for nome in ("bm25", "híbrido", "e5"):
    S = primeiro_estagio(P, nome)
    antes = acertos(P, ordens_de(S))
    print(f"\nprimeiro estágio: {nome} (sem reranking: hit@1 {metricas(antes)['h1']:.2f}, hit@3 {metricas(antes)['h3']:.2f}, MRR {metricas(antes)['mrr']:.2f})")
    print(f"{'N':>4} {'recall@N':>9} {'hit@1':>6} {'hit@3':>6} {'MRR':>6}")
    for N in (1, 3, 5, 10, 20, 50, len(P)):
        ac = acertos(P, com_corte(S, N))
        rec = sum(any(a[:N]) for a in antes) / len(QS)
        m = metricas(ac)
        print(f"{N:>4} {rec:>9.2f} {m['h1']:>6.2f} {m['h3']:>6.2f} {m['mrr']:>6.2f}")
56 perguntas x 112 pedaços = 6272 pares pontuados

primeiro estágio: bm25 (sem reranking: hit@1 0.50, hit@3 0.77, MRR 0.65)
   N  recall@N  hit@1  hit@3    MRR
   1      0.50   0.50   0.77   0.65
   3      0.77   0.73   0.77   0.78
   5      0.84   0.77   0.84   0.82
  10      0.93   0.79   0.91   0.86
  20      0.96   0.77   0.93   0.85
  50      0.98   0.79   0.95   0.86
 112      1.00   0.75   0.91   0.84

primeiro estágio: híbrido (sem reranking: hit@1 0.77, hit@3 0.93, MRR 0.86)
   N  recall@N  hit@1  hit@3    MRR
   1      0.77   0.77   0.93   0.86
   3      0.93   0.77   0.93   0.86
   5      0.95   0.79   0.93   0.87
  10      0.98   0.77   0.95   0.86
  20      0.98   0.77   0.95   0.85
  50      1.00   0.75   0.91   0.84
 112      1.00   0.75   0.91   0.84

primeiro estágio: e5 (sem reranking: hit@1 0.80, hit@3 0.96, MRR 0.88)
   N  recall@N  hit@1  hit@3    MRR
   1      0.80   0.80   0.96   0.88
   3      0.96   0.80   0.96   0.88
   5      0.98   0.79   0.96   0.88
  10      1.00   0.75   0.96   0.85
  20      1.00   0.75   0.91   0.84
  50      1.00   0.75   0.91   0.84
 112      1.00   0.75   0.91   0.84

Leia o BM25. Com N = 3, o reranker já tira o hit@1 de 0,50 para 0,73, porque o pedaço certo estava entre os três primeiros em 77% das perguntas. O ganho continua até N = 10 (hit@3 de 0,91, MRR de 0,86) e daí em diante estaciona: com N = 20 são 0,93 e 0,85, com N = 50 sobe um pouco (0,95), e com N = 112 (todos os pedaços, nenhum primeiro estágio) cai para 0,91 e 0,84, o desempenho do cross-encoder sozinho. O primeiro estágio ainda ajuda: ele elimina candidatos de que o reranker poderia gostar por engano. No e5, o N pequeno é o melhor: com N = 1 e N = 3 o resultado é o do primeiro estágio, e cada aumento de N dá ao reranker mais oportunidade de piorar a ordem (hit@3 de 0,96 para 0,91 em N = 20).

A regra prática que sai disso é escolher o menor N em que o recall@N do seu primeiro estágio já está perto do patamar. Aqui seria N entre 10 e 20 para o BM25. Em corpora grandes e com perguntas mais difíceis, o patamar chega mais tarde, e N de 50 a 100 é comum nas documentações (a documentação do sentence-transformers cita um exemplo com 100 candidatos e a da Jina, um exemplo de 100 documentos). Não generalize o meu número: meça o recall@N dos seus dados.

14. Latência e custo

O custo de um cross-encoder é, em primeira aproximação, o número de pares vezes o tempo de um par. Medi em CPU, com as 10 primeiras perguntas, a mediana de três repetições e um aquecimento antes (o primeiro uso do modelo é mais lento). Também medi o tempo de embutir a pergunta no bi-encoder, para ter uma régua, e o efeito do tamanho do pedaço.

from base import *
import statistics, torch

P = pedacos(60)
print(f"torch {torch.__version__}, {torch.get_num_threads()} threads, CPU")
S = primeiro_estagio(P, "e5")
ce, e5 = modelo("ce"), modelo(E5)
perguntas = [q for _, q, _ in QS][:10]
ordens = ordens_de(S)[:10]
ce.predict([(perguntas[0], P[0][1])], show_progress_bar=False)            # aquecimento
e5.encode(["query: aquecimento"])

t = []
for q in perguntas:
    t0 = time.perf_counter(); e5.encode(["query: " + q], normalize_embeddings=True); t.append(time.perf_counter() - t0)
print(f"embedding da pergunta (bi-encoder): {1000 * statistics.median(t):.1f} ms")

print(f"{'N':>4} {'pares':>6} {'ms por pergunta':>16} {'ms por par':>11}")
for N in (5, 10, 20, 50, 100):
    por_pergunta = []
    for _ in range(3):
        tempos = []
        for q, o in zip(perguntas, ordens):
            pares = [(q, P[j][1]) for j in o[:N]]
            t0 = time.perf_counter(); ce.predict(pares, show_progress_bar=False); tempos.append(time.perf_counter() - t0)
        por_pergunta.append(statistics.median(tempos))
    m = statistics.median(por_pergunta) * 1000
    print(f"{N:>4} {N:>6} {m:>16.0f} {m / N:>11.1f}")

pares = [(perguntas[0], P[j][1]) for j in ordens[0][:20]]
n_tok = [len(ce.tokenizer(a, b)["input_ids"]) for a, b in pares]
print(f"tokens por par (pergunta + pedaço), N=20: mínimo {min(n_tok)}, mediana {int(statistics.median(n_tok))}, máximo {max(n_tok)}; limite do modelo {ce.max_length}")

# o mesmo N=20, mas com pedaços de tamanhos diferentes: o custo cresce com os tokens do par, não só com o número de pares
print(f"{'tam. do pedaço':>14} {'tokens/par (med.)':>18} {'ms por pergunta (N=20)':>23}")
for tam in (60, 150, 400):
    Pt = pedacos(tam); St = primeiro_estagio(Pt, "e5"); ot = ordens_de(St)[:10]
    tempos, toks = [], []
    for q, o in zip(perguntas, ot):
        pares = [(q, Pt[j][1]) for j in o[:20]]
        toks += [len(ce.tokenizer(a, b)["input_ids"]) for a, b in pares]
        t0 = time.perf_counter(); ce.predict(pares, show_progress_bar=False); tempos.append(time.perf_counter() - t0)
    print(f"{tam:>14} {int(statistics.median(toks)):>18} {1000 * statistics.median(tempos):>23.0f}")
torch 2.14.1, 8 threads, CPU
embedding da pergunta (bi-encoder): 19.3 ms
   N  pares  ms por pergunta  ms por par
   5      5               47         9.3
  10     10               66         6.6
  20     20              107         5.3
  50     50              255         5.1
 100    100              498         5.0
tokens por par (pergunta + pedaço), N=20: mínimo 56, mediana 67, máximo 79; limite do modelo 512
tam. do pedaço  tokens/par (med.)  ms por pergunta (N=20)
            60                 63                     105
           150                132                     215
           400                350                     537
latencia
figura 12 · latencia

A figura traduz os números em barras proporcionais ao tempo, com um retângulo tracejado de 200 ms como orçamento de exemplo. No grupo da esquerda, o embedding da pergunta (azul) leva 19 ms, e as barras do reranker crescem com N: 47 ms com N = 5, 66 com N = 10, 107 com N = 20, 255 com N = 50 e 498 com N = 100 (rosa, bem além do orçamento). O tempo por par cai de 9,3 ms para 5,0 ms e se estabiliza perto de 5 ms, provavelmente porque um custo fixo por lote se dilui (não separei as causas). No grupo da direita, o mesmo N = 20 com pedaços maiores: 105 ms com pedaços de 60 tokens (63 tokens por par), 215 ms com 150 tokens e 537 ms com 400 tokens. O custo depende dos tokens totais lidos, e não só da quantidade de pares.

Três consequências. Primeiro, o reranker domina a latência do pipeline: com N = 20 e pedaços de 60 tokens, ele leva cinco vezes o tempo de embutir a pergunta, e com pedaços de 400 tokens, mais de vinte. Segundo, o orçamento de latência escolhe o N: com a minha CPU e pedaços de 60 tokens, caberiam uns 35 a 40 pares em 200 ms, ou uns 18 pares com pedaços de 150 tokens, contando só o reranker. Terceiro, quem passa do orçamento tem alavancas: reduzir N (seção 13), reduzir o tamanho do pedaço que vai ao reranker, usar um modelo menor, rodar numa GPU ou num serviço que a tenha, e usar um serviço (as APIs da seção 12). Eu só medi CPU, com um modelo, e não testei GPU nem quantização, então não afirmo quanto cada alavanca rende. Uma ressalva sobre o método: duas execuções do mesmo script deram 99 ms e 107 ms para N = 20, então diferenças de 10% entre configurações não são confiáveis. Os tokens por par vieram do tokenizador do próprio cross-encoder: de 56 a 79 tokens em N = 20, muito abaixo do limite de 512 do modelo.

15. Pontuação, calibração e limiar

Todo reranker devolve um número por par, e é tentador usar esse número para decidir se o pedaço serve ou não. É uma boa ideia, e é um bom jeito de produzir o “não sei” visto em Ingestão de documentos: lá, a busca com filtro de acesso nunca devolveu uma lista vazia, devolvia os melhores entre os permitidos mesmo que nenhum respondesse, e o texto de recusa precisava vir de outro lugar. Um limiar sobre a pontuação é esse outro lugar. Antes de usá-lo, convém saber o que o número é.

Calibração é o grau em que a pontuação corresponde a uma probabilidade: se o modelo é bem calibrado, entre os pares com pontuação 0,8, uns 80% são mesmo relevantes. Os rerankers em geral são treinados para ordenar, e não para calibrar, então a pontuação é confiável dentro de uma pergunta (comparar os candidatos) e menos confiável entre perguntas (comparar a melhor nota de uma pergunta com a de outra). A Cohere diz na documentação que as notas são normalizadas em [0, 1] e que não são razões. No meu modelo, o CrossEncoder do sentence-transformers 6.1 usa a ativação Identity (o programa imprime isso), então a nota é o logit bruto, sem limite, como a tabela da seção 5 mostrou. Uma versão ou um modelo com ativação sigmoide devolveria a mesma informação espremida em [0, 1], e o mesmo limiar não valeria nas duas.

Para medir, escrevi 12 perguntas cuja resposta não existe no corpus (pagar com Bitcoin, streaming no Prime, atendimento em Libras, “vocês vendem carros usados?”), conferi por busca de texto que as palavras-chave não aparecem nos documentos, e comparei a melhor pontuação de cada pergunta com e sem resposta, tanto para o cross-encoder quanto para o cosseno do bi-encoder. A tabela de cada limiar mostra duas coisas: quantas das 56 perguntas ficam com o topo correto e acima do limiar (as respostas úteis mantidas) e quantas das 12 perguntas sem resposta ainda passam (e levariam o modelo a responder com contexto errado).

from base import *

SEM_RESPOSTA = [
    "Posso pagar com Bitcoin ou outra criptomoeda?", "O Prime dá acesso a filmes e séries em streaming?",
    "Aceitam vale-refeição como forma de pagamento?", "O Mercado Aurora entrega para fora do Brasil?",
    "Como desativo os cookies do site?", "Quem fundou o Mercado Aurora?", "Existe aplicativo para Windows?",
    "Quanto de cashback recebo em compras no cartão?", "Posso falar com o suporte pelo WhatsApp?",
    "Qual é o peso máximo de um pacote na entrega padrão?", "O atendimento tem intérprete de Libras?",
    "Vocês vendem carros usados?",
]
P = pedacos(60)
com = [q for _, q, _ in QS]
todas = com + SEM_RESPOSTA
S = vetorial(P, todas, E5)                                    # cosseno do bi-encoder com cada pedaço
ce = modelo("ce")

print("função de ativação padrão do CrossEncoder:", type(ce.activation_fn).__name__)

topo_cos, topo_ce, certo_cos, certo_ce = [], [], [], []
for i, q in enumerate(todas):
    cand = list(np.argsort(-S[i]))[:20]
    s = ce.predict([(q, P[j][1]) for j in cand], show_progress_bar=False)
    topo_cos.append(S[i][cand[0]]); topo_ce.append(float(s.max()))
    if i < len(com):
        certo_cos.append(QS[i][2] in P[cand[0]][1]); certo_ce.append(QS[i][2] in P[cand[int(np.argmax(s))]][1])
topo_cos, topo_ce = np.array(topo_cos), np.array(topo_ce)
certo_cos, certo_ce = np.array(certo_cos), np.array(certo_ce)
n = len(com)
print(f"{n} perguntas com resposta, {len(SEM_RESPOSTA)} sem resposta no corpus")
for nome, v in (("cosseno do bi-encoder (e5)", topo_cos), ("pontuação do cross-encoder", topo_ce)):
    c, s = v[:n], v[n:]
    print(f"{nome}: com resposta, mediana {np.median(c):.3f} (de {c.min():.3f} a {c.max():.3f}); sem resposta, mediana {np.median(s):.3f} (de {s.min():.3f} a {s.max():.3f})")

def tabela(v, certo, limiares, rotulo):
    print(f"\n{'limiar':>7} | {'respostas úteis mantidas':>25} {'sem resposta que passam':>24}   ({rotulo})")
    for t in limiares:
        print(f"{t:>7.2f} | {np.mean(certo & (v[:n] >= t)):>25.2f} {np.mean(v[n:] >= t):>24.2f}")

tabela(topo_ce, certo_ce, (-4, -2, 0, 2, 4), "pontuação do cross-encoder, sem sigmoide")
tabela(topo_cos, certo_cos, (0.83, 0.85, 0.87, 0.89), "cosseno do bi-encoder")
print(f"\nsem limiar: respostas úteis no topo = {certo_ce.mean():.2f} (cross-encoder) e {certo_cos.mean():.2f} (bi-encoder)")
sig = lambda x: 1 / (1 + np.exp(-x))
print("sigmoide dos limiares -4, -2, 0, 2, 4:", [round(float(sig(x)), 3) for x in (-4, -2, 0, 2, 4)])
função de ativação padrão do CrossEncoder: Identity
56 perguntas com resposta, 12 sem resposta no corpus
cosseno do bi-encoder (e5): com resposta, mediana 0.887 (de 0.842 a 0.920); sem resposta, mediana 0.852 (de 0.830 a 0.892)
pontuação do cross-encoder: com resposta, mediana 2.321 (de -3.472 a 10.599); sem resposta, mediana -3.320 (de -5.174 a 7.422)

 limiar |  respostas úteis mantidas  sem resposta que passam   (pontuação do cross-encoder, sem sigmoide)
  -4.00 |                      0.75                     0.75
  -2.00 |                      0.73                     0.25
   0.00 |                      0.62                     0.08
   2.00 |                      0.50                     0.08
   4.00 |                      0.39                     0.08

 limiar |  respostas úteis mantidas  sem resposta que passam   (cosseno do bi-encoder)
   0.83 |                      0.80                     0.92
   0.85 |                      0.79                     0.58
   0.87 |                      0.79                     0.08
   0.89 |                      0.45                     0.08

sem limiar: respostas úteis no topo = 0.75 (cross-encoder) e 0.80 (bi-encoder)
sigmoide dos limiares -4, -2, 0, 2, 4: [0.018, 0.119, 0.5, 0.881, 0.982]
limiar
figura 13 · limiar

A figura mostra o fluxo de decisão e os resultados. As perguntas (laranja, as sem resposta com borda tracejada) passam pelo reranker, e o losango compara a melhor nota com o limiar: acima, o pedaço vai para o prompt (seta azul grossa); abaixo, a seta é barrada. Na parte de baixo, quatro pontos de operação. Com o limiar -2 no cross-encoder, mantêm-se 73% das respostas úteis (de um máximo de 75%, que é o hit@1 do cross-encoder sozinho), e 25% das perguntas sem resposta ainda passam. Com 0, a retenção cai para 62%, e só 8% das sem resposta passam, uma de 12. O cosseno do e5 mostra o contraste: com 0,85, mantém 79% das úteis e deixa passar 58% das sem resposta; com 0,87, mantém os mesmos 79% e deixa passar 8%. A faixa entre os dois valores é estreitíssima (0,02 de cosseno), e as medianas do cosseno ficam em 0,852 (sem resposta) e 0,887 (com resposta), quase coladas. Neste corpus, o cosseno bem escolhido até rendeu mais respostas úteis com o mesmo nível de passagens (79% contra 62% do limiar 0 do cross-encoder, que tem um teto de 75% porque o hit@1 dele é menor), de modo que o argumento a favor do limiar sobre o reranker é a folga da escala, e não uma vitória clara em qualidade. A nota do cross-encoder tem mediana de -3,32 para as sem resposta e 2,32 para as com, uma separação bem maior, que dá mais folga para escolher um valor.

Duas ressalvas pesam sobre esses números. A primeira é que 12 perguntas é muito pouco: cada pergunta vale 8 pontos percentuais, e a diferença entre 8% e 25% de passagens é a diferença entre 1 e 3 perguntas em 12. A segunda é que escolhi os limiares olhando os mesmos dados em que os avaliei, o que favorece os resultados; o certo é escolher o limiar num conjunto de perguntas e validar em outro. A lição de método é mais firme que os valores. O limiar é uma configuração do seu modelo, do seu corpus e do seu tipo de pergunta, que se ajusta com exemplos reais de perguntas sem resposta, e que precisa ser refeita ao trocar de reranker. E um limiar nunca é de graça: cada ponto de recusa a mais custa respostas úteis, como a coluna da esquerda mostra.

16. Diversidade com MMR

Há um problema diferente do da ordem: a repetição. O chunking com sobreposição (Chunking) gera pedaços vizinhos que dividem metade do texto, e tanto o BM25 quanto o vetor tendem a trazer os dois juntos. Se os cinco primeiros pedaços forem quase o mesmo parágrafo com pequenas variações, o prompt gasta tokens para dizer a mesma coisa e fica sem espaço para o pedaço vizinho que complementaria. O MMR (maximal marginal relevance, ou relevância marginal máxima), proposto por Carbonell e Goldstein no SIGIR de 1998, é um critério de reordenação que equilibra relevância à pergunta e novidade em relação ao que já foi escolhido.

A fórmula é uma só, aplicada a cada rodada: entre os candidatos que sobraram, escolhe-se o que maximiza λ · sim(pergunta, d) − (1 − λ) · máx sim(d, já escolhidos). O parâmetro λ vai de 0 a 1: com λ = 1, só conta a relevância (a ordem original), e com λ pequeno, a novidade pesa mais. O primeiro escolhido é sempre o mais relevante (não há nada escolhido para repetir), e o segundo já é afetado pela presença do primeiro.

mmr
figura 14 · mmr

A figura mostra o laço. Os pedaços com sobreposição (roxo) viram 20 candidatos, que entram no MMR (verde limão, com a fórmula na descrição), e uma seta de duas pontas indica que a cada rodada o MMR consulta os já escolhidos (verde) e escolhe o próximo. À direita, três pontos de operação medidos: quanto menor o λ, menos texto repetido e mais documentos distintos no top-5, e também menos acertos.

from base import *

# pedaços de 60 tokens com sobreposição de 30: vizinhos dividem metade do texto
SP = [(d, a, b) for d in DOCS for a, b in chunkers.fixo(DOCS[d], 60, 30)]
txt = [ws(DOCS[d][a:b]) for d, a, b in SP]
e5 = modelo(E5)
E = e5.encode(["passage: " + t for t in txt], normalize_embeddings=True)
Q = e5.encode(["query: " + q for _, q, _ in QS], normalize_embeddings=True)
S = Q @ E.T

def mmr(i, k, lam, N=20):
    cand = list(np.argsort(-S[i]))[:N]; sel = []
    while len(sel) < k:
        def valor(j):
            red = max((E[j] @ E[s] for s in sel), default=0.0)
            return lam * S[i, j] - (1 - lam) * red
        melhor = max((j for j in cand if j not in sel), key=valor)
        sel.append(melhor)
    return sel

def redundancia(sel):
    """Fração do texto do top-k que é repetição (caracteres cobertos mais de uma vez)."""
    cob, total = {}, 0
    for j in sel:
        d, a, b = SP[j]; total += b - a
        for c in range(a, b): cob[(d, c)] = 1
    return 1 - len(cob) / total

print(f"{len(SP)} pedaços com sobreposição; top-5 por pergunta")
print(f"{'λ':>5} {'hit@5':>6} {'hit@3':>6} {'texto repetido':>15} {'documentos distintos':>21}")
for lam in (1.0, 0.9, 0.7, 0.5):
    h5 = h3 = rep = docs = 0
    for i, (_, _, ouro) in enumerate(QS):
        sel = mmr(i, 5, lam)
        ac = [ouro in txt[j] for j in sel]
        h5 += any(ac); h3 += any(ac[:3]); rep += redundancia(sel); docs += len({SP[j][0] for j in sel})
    n = len(QS)
    print(f"{lam:>5.1f} {h5 / n:>6.2f} {h3 / n:>6.2f} {rep / n:>15.2f} {docs / n:>21.2f}")

# um exemplo: o que entra no top-3 com e sem diversidade
i = [k for k, (_, q, _) in enumerate(QS) if q.startswith("Até quando posso trocar uma geladeira")][0]
print("\npergunta:", QS[i][1])
for lam in (1.0, 0.7):
    print(f"λ={lam}:")
    for j in mmr(i, 3, lam):
        print(f"  {S[i, j]:.3f} [{SP[j][0]}] {txt[j][:90]}")
163 pedaços com sobreposição; top-5 por pergunta
    λ  hit@5  hit@3  texto repetido  documentos distintos
  1.0   0.95   0.84            0.20                  1.88
  0.9   0.91   0.82            0.17                  2.05
  0.7   0.82   0.77            0.07                  2.62
  0.5   0.70   0.66            0.01                  3.25

pergunta: Até quando posso trocar uma geladeira que chegou com defeito?
λ=1.0:
  0.885 [trocas] ção. ## Produtos com defeito Quando o produto chega com defeito ou para de funcionar, a tr
  0.879 [trocas] , com todos os acessórios e a nota fiscal. Produtos usados além do teste normal podem ter 
  0.865 [trocas] a troca por outro igual, o reembolso ou um abatimento no preço, e essa escolha só vale se 
λ=0.7:
  0.885 [trocas] ção. ## Produtos com defeito Quando o produto chega com defeito ou para de funcionar, a tr
  0.857 [conta] em esqueci minha senha e informe o e-mail da conta. O link de redefinição chega em poucos 
  0.865 [trocas] a troca por outro igual, o reembolso ou um abatimento no preço, e essa escolha só vale se

Os pedaços aqui têm sobreposição de 30 tokens em 60, o que dá 163 pedaços, e o top-5 é calculado sobre os 20 primeiros do e5. Com λ = 1 (sem MMR), 20% do texto dos cinco pedaços é repetição e o top-5 cobre 1,88 documentos distintos em média. Com λ = 0,7, a repetição cai para 7% e os documentos distintos sobem para 2,62, mas o hit@5 cai de 0,95 para 0,82. Com λ = 0,5, a repetição some (1%), os documentos chegam a 3,25, e o hit@5 cai para 0,70. O exemplo impresso mostra o mecanismo do dano: para “até quando posso trocar uma geladeira com defeito?”, o MMR com λ = 0,7 trocou um pedaço do documento de trocas (cosseno 0,879) por um pedaço sobre redefinição de senha da conta (0,857), só por ser diferente.

A conclusão tem duas partes. Nas minhas 56 perguntas, todas pedem um fato isolado, e para elas a diversidade só atrapalha, porque a resposta está em um pedaço e os vizinhos são a mesma resposta. O MMR serve para perguntas amplas, que precisam de vários pedaços diferentes (“resuma as regras de devolução, entrega e pagamento”), e para índices muito redundantes; eu não medi essas perguntas, então não sei quanto ele ganharia nelas. O uso mais seguro, quando há muita sobreposição, é um λ alto, perto de 0,9, que corta as repetições mais gritantes: o meu λ = 0,9 deu 17% de texto repetido e hit@5 de 0,91, e a troca pode ou não compensar. Uma alternativa a considerar antes do MMR é remover duplicatas na ingestão (Ingestão de documentos).

17. Onde cada decisão acontece

As seções anteriores definiram seis botões: o primeiro estágio, o N, o modelo, o texto que o reranker lê, o limiar e a diversidade. Eles se combinam numa ordem natural. Os filtros de metadados e de permissão agem primeiro, no banco. O primeiro estágio traz N candidatos. O reranker pontua cada par (com o texto contextualizado). Depois entram o limiar (descartar o que é fraco), a diversidade, quando ela se justifica, e o corte em k. Por fim, a montagem do prompt decide onde cada pedaço cai (os melhores nas pontas, como a seção 2 sugere). A pontuação do reranker só decide a ordem e o descarte; a escolha final do que o modelo lê continua sendo do prompt.

18. Como medir o ganho

O erro mais comum com reranking é adotá-lo porque “todo mundo usa”. A seção 7 mostrou um reranker que ajudou muito num primeiro estágio, não ajudou em outro, e piorou um terceiro, e nenhuma dessas diferenças era visível sem medir. O método tem cinco passos, e o último deles muda toda vez que algo muda.

O primeiro passo é montar um conjunto de perguntas com a resposta marcada, como o de Chunking (as 56 perguntas, ou as suas, com a frase-resposta). Segundo, medir o primeiro estágio sozinho: recall@N (o teto), hit@1 e MRR. Se o recall@N já é baixo, o problema está antes do reranker, e trocar o reranker não resolve; se o hit@1 já é alto, há pouco a ganhar. Terceiro, medir o reranker sobre o mesmo conjunto, com o N e o texto de entrada que você pretende usar, e comparar pergunta a pergunta: quantas passaram a acertar e quantas deixaram de acertar, com um teste do sinal (como o do código da seção 7) ou um bootstrap. Quarto, medir a latência no mesmo N, com o mesmo tamanho de pedaço e na máquina em que vai rodar. Quinto, repetir a cada mudança de modelo, de pedaço, de corpus ou de primeiro estágio, porque o ganho de um reranker é uma propriedade do conjunto.

Duas métricas aparecem em muitos artigos e não usei aqui. O nDCG@10 pontua a lista inteira dando mais peso às posições do topo e aceita graus de relevância, e é o que o artigo RankGPT, por exemplo, usa nas tabelas; para RAG com resposta de um pedaço só, o hit@k e o MRR bastam. Avaliação de RAG volta a esse assunto, medindo o sistema de ponta a ponta.

decisao
figura 15 · decisao

A figura reúne o método como um fluxo. Meça o primeiro estágio (azul) e pergunte se o recall@N é alto e o hit@1, baixo, isto é, se o pedaço está na lista, mas na posição errada. Se a resposta for não, a seta é barrada: mexa antes em chunking e busca híbrida, porque o reranker não acha o que não veio. Se for sim, pergunte se N pares cabem no orçamento de latência, e, se não couberem, reduza N, o pedaço ou o modelo. Cabendo, teste um reranker (verde limão), com N de 20 a 50 e o texto com contexto, e pergunte se melhorou além do ruído. Se sim, adote, com limiar, e meça de novo a cada mudança; se não, não adote, porque o primeiro estágio já basta.

Parte 5: Fechar

19. Erros comuns e boas práticas

O primeiro erro é adotar um reranker sem medir antes e depois. A seção 7 mostrou três comportamentos diferentes com o mesmo modelo, e só medir revela qual é o seu. O segundo é avaliar com poucas perguntas e afirmar diferenças dentro do ruído: com 56 perguntas, 5 pontos de hit@1 não querem dizer nada, e o teste pareado é o mínimo. O terceiro é reordenar candidatos demais: N grande custa linearmente em latência e, se o reranker erra, dá mais chances ao erro, como no e5. O quarto é reordenar de menos: se o N é menor que o ponto em que o recall@N satura, o pedaço certo nem entra.

O quinto é entregar ao reranker o texto bruto do pedaço, sem título, sem seção e sem o cabeçalho da tabela, quando um prefixo simples subiu o hit@1 de 0,75 para 0,89 no meu teste. O sexto é ignorar o limite de tokens do reranker: pedaços grandes são truncados (ou, em alguns serviços, divididos), e o fim do pedaço, que pode conter a resposta, pode ser ignorado. O sétimo é copiar um limiar de outro projeto ou de outro modelo: a escala da pontuação é do modelo, e a calibração, do corpus. O oitavo é usar MMR em perguntas de fato único, como a seção 16 mediu.

O nono é esperar que o reranker conserte um primeiro estágio com recall baixo. Ele reordena, e o que não está na lista não volta. O décimo é esquecer os filtros de permissão no primeiro estágio, deixando um documento restrito chegar ao reranker, e depois ao prompt. E o décimo primeiro é medir só a qualidade: um reranker que melhora o hit@1 em 10 pontos e dobra a latência da consulta pode ser um mau negócio para um chat, e um bom negócio para uma busca em que a precisão paga o tempo.

Como práticas, em ordem de custo: comece com o primeiro estágio bem medido, adicione um reranker pequeno com N entre 20 e 50, entregue ao reranker o texto com contexto, ajuste o limiar com perguntas reais sem resposta, guarde o conjunto de avaliação e refaça a medição a cada mudança. Só então olhe famílias mais caras (modelos maiores, serviços, LLM).

20. Para ir além

Você tem agora as peças da recuperação: a representação do significado (Embeddings e Similaridade entre vetores), os índices e o banco (Busca vetorial e Banco de dados vetorial), o corte e a preparação dos documentos (Chunking e Ingestão de documentos), a combinação de buscas (Busca híbrida) e a reordenação desta aula. Falta o que as junta. Juntando tudo, o RAG monta o pipeline completo: a pergunta chega, a recuperação em dois estágios traz os k melhores pedaços, o prompt os recebe com instruções de citar e de recusar quando o contexto não basta, e o modelo responde. Os botões desta aula (k, limiar, a ordem dos pedaços no prompt) vão ganhar contexto, porque é lá que se vê o que acontece quando cada decisão sai errada. Avaliação de RAG mede o sistema todo, separando o que a recuperação acertou do que o modelo fez com ela.

21. Exercícios

Os exercícios 1 a 4 são contas e raciocínio. Os demais rodam em CPU, sem chave de API, com os códigos desta aula copiados para uma pasta sua. Você precisa do corpus de Chunking (ou de uma pasta sua com uns dez documentos e umas 30 perguntas com a frase-resposta) e de um ambiente virtual com sentence-transformers e numpy.

  1. Um sistema tem 4 ms por par num servidor com GPU e um orçamento de 150 ms para o reranking, mais 30 ms de rede e overhead. Qual é o maior N possível? E se o pedaço dobrar de tamanho e o custo por par dobrar? (Resposta: (150 - 30) / 4 = 30 pares; com o custo por par em 8 ms, 15 pares. O custo cresce com os tokens por par, como a seção 14 mediu.)

  2. O recall@20 do primeiro estágio é 0,90 e o hit@1 é 0,60. Qual é o maior hit@1 que um reranker perfeito poderia alcançar com N = 20? E com N = 5, se o recall@5 for 0,75? (Resposta: 0,90 com N = 20 e 0,75 com N = 5. O reranker só reordena o que recebeu.)

  3. Num teste de 56 perguntas, o reranker passou a acertar em 7 e deixou de acertar em 10. Houve piora? O que você faria? (Resposta: a evidência é de ruído: 7 contra 10 é um desequilíbrio que o acaso explica bem, mas também não há ganho. Eu não adotaria sem um conjunto maior, e olharia as 10 perdas para ver se têm algo em comum, como tabelas.)

  4. Por que não se deve reordenar com o cross-encoder os 10 milhões de pedaços do acervo? Calcule com 5 ms por par. (Resposta: 10.000.000 × 5 ms = 50.000 s, cerca de 14 horas por consulta, em um núcleo; mesmo paralelizado, é inviável, e é por isso que existe o primeiro estágio.)

  5. Rode o código da seção 5 com três perguntas suas. Em quais o cross-encoder promove o pedaço certo e em quais ele o rebaixa? Existe um padrão no tipo de pedaço (tabela, código, lista)?

  6. Reproduza a seção 7 no seu corpus: BM25 e um embedding como primeiros estágios, N = 20, hit@1 e MRR antes e depois, com o teste do sinal. Anote o recall@20 do primeiro estágio antes de olhar o resto.

  7. Teste o prefixo de contexto da seção 8 no seu corpus. Ele ajuda? Em quais perguntas? Teste também só o título do documento, sem a seção.

  8. Meça a latência do seu reranker em N = 5, 10, 20, 50 e 100, com a sua máquina, com pedaços de dois tamanhos diferentes, e faça o gráfico de barras que a figura 11 mostra. Qual é o N máximo para o seu orçamento?

  9. Escreva 20 perguntas sem resposta no seu corpus (confira por busca de texto que as respostas não existem). Escolha o limiar do reranker com 10 delas e as perguntas com resposta de metade do conjunto, e valide na outra metade. Quanto cai o resultado em relação ao que você viu na primeira metade?

  10. Implemente o prompt listwise da seção 11 e conte os tokens para N = 10, 20 e 50 com o tokenizador do modelo que você usa. Se tiver acesso a uma API, rode 20 perguntas e conte quantas respostas não são uma permutação válida.

22. Glossário

TermoSignificado
RerankingReordenar candidatos já recuperados com um modelo mais preciso e mais caro
Recuperação em dois estágiosPrimeiro estágio barato traz N candidatos, segundo estágio caro os reordena
Bi-encoderModelo que codifica cada texto sozinho num vetor; permite pré-calcular os vetores dos documentos
Cross-encoderModelo que lê pergunta e documento juntos e devolve uma pontuação; mais preciso, não permite pré-cálculo
Late interactionArquitetura com um vetor por token e comparação token a token (MaxSim), como no ColBERT
MaxSimPara cada token da pergunta, a maior similaridade com um token do documento; soma-se os máximos
Pointwise e listwiseReranker por LLM que pontua um pedaço por vez, ou ordena uma lista inteira num só prompt
Janela deslizanteReordenar listas longas em janelas sobrepostas (no artigo RankGPT, janela 20, passo 10)
N e kN é quantos candidatos o reranker lê; k é quantos pedaços vão para o prompt
Recall@NFração das perguntas em que o pedaço certo está entre os N primeiros candidatos; teto do reranker
hit@k e MRRFração de acertos entre os k primeiros; média do inverso da posição do primeiro acerto
LogitPontuação bruta de um modelo, sem limite; a sigmoide a transforma em número entre 0 e 1
CalibraçãoQuanto a pontuação corresponde a uma probabilidade real de relevância
LimiarPontuação mínima para o pedaço entrar no prompt; abaixo dela, o sistema recusa
MMRRelevância marginal máxima: escolhe o próximo pedaço equilibrando relevância e novidade, com o parâmetro λ
Teste do sinalTeste pareado que só compara quantas perguntas melhoraram e quantas pioraram
Lost in the middleAchado de que o modelo usa pior a informação que está no meio de um contexto longo

Fontes

Consultadas em 15 de outubro de 2026. As páginas de documentação foram lidas por uma ferramenta que as resume, e não integralmente, então os campos e limites citados podem ter detalhes que a ferramenta não trouxe.

Medido por mim (ambiente virtual novo e só meu no scratchpad da sessão, macOS, Python 3.12, CPU com 8 threads, nada instalado fora dele; instalei só sentence-transformers 6.1.0, que trouxe torch 2.14.1 e transformers 5.20.0, numpy 2.5.4, tiktoken 0.14.0 e pypdf para ler um artigo; baixei só o cross-encoder mmarco-mMiniLMv2-L12-H384-v1, cerca de 470 MB; os modelos multilingual-e5-small e paraphrase-multilingual-MiniLM-L12-v2 já estavam no cache): o corte em 112 pedaços e as métricas das seções 5, 7, 8 e 13; a comparação entre quatro primeiros estágios; o experimento do texto com contexto; o varrimento de N sobre 6.272 pares; as latências por N e por tamanho do pedaço; o limiar com 12 perguntas sem resposta; o MMR com 163 pedaços sobrepostos; a contagem de tokens do prompt listwise e de vetores por token. Os arquivos estão no scratchpad (rerank/).

Sem execução ou sem fonte: não rodei nenhum reranker por LLM, nenhum modelo ColBERT e nenhuma API (Cohere, Voyage e Jina só pelo que a documentação diz), e portanto não tenho medida de qualidade nem de latência dessas três famílias, nem de preços. Também não reproduzi o efeito “lost in the middle”, que tem origem no artigo, e a sugestão de pôr o segundo melhor pedaço no fim do contexto é dedução minha. Testei um único cross-encoder, pequeno, treinado em dados traduzidos; modelos maiores ou treinados em outros domínios podem se comportar de outro jeito, e o resultado de que ele não supera o e5 vale só para este corpus (9 documentos inventados, 56 perguntas, 112 pedaços). Com 56 perguntas, o ruído de um hit rate é de uns 8 a 13 pontos, e só a linha do BM25 passou no teste do sinal; os limiares de pontuação foram escolhidos e avaliados nos mesmos 12 exemplos sem resposta, o que é otimista; as latências são de CPU, sem GPU, e duas execuções do mesmo script deram 99 e 107 ms em N = 20; o híbrido do experimento é um rascunho meu de fusão por posição (RRF, k = 60) e não reflete Busca híbrida; os tokens do prompt listwise foram contados com um tokenizador (o200k_base) que não é o do modelo que você vai usar; os números do artigo RankGPT (custos, latências) são de 2023 e de modelos antigos. Não implementei reranking sobre tabelas com um modelo treinado para elas, nem quantização, nem GPU, nem cache de pontuações. As 15 figuras foram renderizadas e examinadas; as principais (1, 5, 6, 10 e 12) tiveram duas ou mais rodadas.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?