A métrica que ninguém confessa: rodei minha memória sem reranker
Estudos·

A métrica que ninguém confessa: rodei minha memória sem reranker

7 min de leitura← Voltar para timeline

O número que ficou no banco

Minha memória já tinha um baseline novo. No capítulo anterior, o salto de um embedder só-PT para a configuração com embedder duplo + vetor esparso levou o recall@1 de 0.06 para 0.1614 num benchmark público de conversas longas — 1.977 perguntas contra 5.882 itens de memória. No papel, o problema de “o documento certo veio primeiro?” estava consideravelmente menos errado.

Mas todo benchmark deixa um número no banco, e o meu estava lá desde o primeiro run: o reranker. Um cross-encoder sentado depois do retrieval que re-pontua os candidatos do topo em conjunto. O pipeline tinha o gancho pronto (--rerank era uma flag), e eu nunca tinha rodado a config completa com ele ligado pra tirar o número oficial. Havia um motivo, e não era preguiça: eu não sabia quanto aquela confiança custava por pergunta.

Pra que serve um reranker — e por que ficou de fora

A camada de retrieval é rápida porque é rasa: vetores densos são comparados por distância cosseno, os esparsos por termo exato, e o RRF só funde rankings. Essa dança toda não lê uma única palavra dos documentos — compara geometria comprimida.

O cross-encoder faz o oposto: pega a pergunta e cada candidato juntos e lê o par como uma unidade. O score melhora muito — é pra isso que ele existe. O custo também piora muito, porque essa leitura não escala com o índice; escala com o número de candidatos que você traz pro corte final.

A régua que eu tinha definido era simples e desconfortável: o percentil 95 da busca não podia passar de 300ms. Esse limiar é a diferença entre a memória parecer parte do corpo e parecer uma consulta externa — num agente interativo, a memória fica no hot path de toda resposta.

O experimento

Três configurações, o mesmo benchmark, as mesmas perguntas:

  1. Só denso — a configuração original de embedder único
  2. Híbrido — embedder duplo + vetor esparso, fundidos por rank
  3. Híbrido + reranker — a config completa, com o cross-encoder ativo
async def search(self, query: str, limit: int = 8):
    cands = max(limit * 4, 20)
    return await self.client.query_points(
        collection_name=self.collection,
        prefetch=[
            {"query": dense_q, "using": "colibri", "limit": cands},
            {"query": bge_q,   "using": "bge",     "limit": cands},
            {"query": sparse_q, "using": "bm25",   "limit": cands},
        ],
        query=FusionQuery(fusion=Fusion.RRF),
        limit=cands,
    )

def rerank(self, query, hits, keep=8):
    pairs = [(query, h.payload["text"]) for h in hits]
    scores = self.model.predict(pairs)   # this here is the expensive part
    top = sorted(zip(hits, scores), key=lambda t: -t[1])[:keep]
    return [h for h, _ in top]

Então embrulhei cada busca num timer e dividi o tempo em dois baldes: retrieve (vetores + fusão) e rerank (o cross-encoder). Rodei a suíte e comecei a anotar percentis, não médias — a média esconde exatamente o momento em que o usuário desiste.

Os resultados

Config recall@1 p95 busca
Só denso 0.06 rápido
Híbrido (duplo + esparso) 0.1614 300ms
Híbrido + reranker 0.1941 29s

Releia a terceira linha. O reranker realmente melhora o recall: 0.1941 é o melhor número da história desse sistema, +3.3 pontos sobre o híbrido. O que ele adiciona em cima da fusão é exatamente a “leitura de nível de frase” dos candidatos do topo — os pares que a geometria considerou apenas parecidos.

E o p95 foi de 300ms para 29 segundos. Quase cem vezes mais lento. Na prática, uns 0.46 segundos de reranker por query de memória — parece inofensivo, até você lembrar que recall é medido em lotes frios e que o p95 real vem com fila, com carga concorrente, com tudo que não existe num run controlado. A cauda medida não cabe na régua dos 300ms, nem de longe.

A decisão

O reranker não foi pra produção. Ficou no banco como munição de avaliação: toda mudança candidata na memória agora roda no benchmark com rerank ligado e desligado, e os deltas de cada lado ficam registrados. O rerank atua como teto do que é possível; a produção roda sem ele, dentro do orçamento de latência.

def flag_search():
    v = os.environ.get("YURUMI_RERANKER_ENABLED", "").lower() in ("1", "true")
    print(f"reranker: {'ON' if v else 'OFF'} (OFF = latency budget 300ms)")
    return v

O valor default dessa flag é uma decisão estudada, não uma omissão.

O que eu aprendi

  1. A métrica média seduz; a cauda mata. O p95 de uma busca é o número que decide se a memória é utilizável numa conversa interativa. Eu comparava recall contra recall, sem nunca escrever os p95 lado a lado.
  2. Um módulo com duas posições precisa de flag explícita. Ter rerank disponível e escolher não usar é um produto diferente de não ter. A diferença só existe documentada — e defaultável.
  3. Configuração fora da avaliação é risco real. Ninguém nunca ligou o reranker porque ninguém sabia o preço. Dinheiro que ninguém confessa pagar é dinheiro gasto.
  4. A comparação certa é em três vias. Sem a linha de só-denso, eu poderia ter lido “híbrido tá bom, rerank tá melhor” e entregue a latência por +3 pontos. Com ela, vi que o trabalho de geometria já tinha me dado 0.06 → 0.1614, e o ponto marginal custava outra ordem de grandeza.

O próximo capítulo dessa série já tem destino definido: cache — de embeddings e resultados de fusão — pra subir a qualidade da cauda sem tocar na arquitetura. A lição ficou: medir também o que você não subiu.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$