RAG além do vetor:
busca híbrida RRF e Knowledge Refinery
Busca vetorial resolve boa parte do retrieval — até o usuário procurar um ID exato e receber oito vizinhos semânticos, nenhum deles o documento certo. Este post abre o pipeline do Catcher Data etapa por etapa: fusão RRF de dois braços de busca, badge de match em cada hit, re-rank LLM opcional com over-fetch configurável, debug de retrieval por estágio e FAQ cards destilados pelo Knowledge Refinery.
O problema do cosine puro
Embeddings são bons em capturar significado. Uma busca por "como cancelo a assinatura" encontra a página de cancelamento mesmo que ela não repita nenhuma dessas palavras. Esse é o caso feliz, e é o que toda demo de RAG mostra.
O caso infeliz aparece em produção. O usuário busca PIX-2024-88317, SKU-A4472 ou resolveTenantDB. Termos assim são identificadores: carregam pouca semântica que um modelo de embedding consiga distribuir no espaço vetorial. O vetor da query acaba cercado de vizinhos "parecidos" — outros IDs, outros SKUs, outras funções com nomes irmãos. O documento que contém o termo exato existe no corpus, mas sai na posição 40 do ranking por cosine. Com um corte top-k de 8, ele nunca chega ao contexto do LLM.
O sintoma no produto é conhecido: o agente responde "não encontrei nada sobre isso" para uma informação que está literalmente gravada na base. Quem opera RAG reconhece o padrão — número de pedido, código de erro, nome de função, cláusula de contrato.
Busca lexical tem o comportamento inverso. Um índice full-text acha o termo exato sem esforço, mas não entende sinônimo, paráfrase nem intenção. Nenhum dos dois braços basta sozinho. A resposta não é escolher um; é fundir os dois.
Como a fusão RRF funciona
No Catcher Data, tanto o Vector quanto a Wiki executam dois rankings independentes para cada busca híbrida:
- Braço denso: a query vira embedding (OpenAI — o modelo fica gravado por coleção; o default atual é
text-embedding-3-largecom 3072 dimensões, e coleções antigas em3-small/1536 seguem funcionando) e busca por similaridade de cosine no pgvector, com o operador<=>. - Braço lexical: full-text search do Postgres sobre o mesmo corpus, com
tsvectore índice GIN, usando o dicionáriosimple— sem stemming agressivo, de propósito, para quePIX-2024-88317continue sendoPIX-2024-88317dentro do índice.
Os dois rankings são fundidos por Reciprocal Rank Fusion (RRF) antes do corte top-k. A fórmula cabe em três linhas:
# RRF: soma do recíproco da posição em cada ranking
score(doc) = Σ 1 / (k + rank_i(doc))
# rank_i(doc) = posição do doc no ranking do braço i (denso, lexical)
# k = constante de suavização (tipicamente 60)
RRF não compara scores; compara posições. Isso importa porque similaridade de cosine e score de full-text vivem em escalas incomparáveis — normalizar um contra o outro é frágil e sensível ao corpus. Posição no ranking é a única moeda comum entre os dois braços.
A fusão ainda aceita um ajuste fino: lexical_weight, um valor de 0 a 1 que inclina a soma para um dos lados. Corpus dominado por código, logs e identificadores costuma se beneficiar de mais peso lexical; corpus de texto corrido, de mais peso denso. Trate o parâmetro como sintonia, não como interruptor — o padrão equilibrado resolve a maioria dos casos, e mover o peso sem medir antes é chute.
O detalhe operacional que muda o resultado: a fusão acontece antes do corte. O top-k final é calculado sobre o ranking fundido, não sobre cada braço isolado. O documento com o termo exato — primeiro no braço lexical, quadragésimo no denso — entra na fusão com peso alto e sobrevive ao corte. No cosine puro, ele teria sido descartado antes de o LLM ver qualquer coisa.
Anatomia de uma busca híbrida
Vale seguir uma chamada real do começo ao fim. Com hybrid: true, o pipeline percorre sete passos:
- A query chega. Uma string só, por exemplo
"PIX-2024-88317 estorno", mais okfinal desejado. - Braço denso executa. A query vira embedding e o pgvector devolve os vizinhos mais próximos por cosine — o primeiro ranking.
- Braço lexical executa. A mesma query consulta o índice GIN via full-text — o segundo ranking, calculado de forma independente.
- Over-fetch. Cada braço devolve mais candidatos do que o
kpedido. É isso que dá material à fusão: um documento na posição 15 de um braço ainda pode terminar no top 5 da lista final. - Fusão RRF. Os dois rankings viram um só, somando o recíproco das posições — com
lexical_weightaplicado, se você o informou. - Corte top-k. Só agora a lista fundida é cortada no tamanho pedido.
- Badges. Cada hit sai anotado com a origem em
metadata.match.
Na prática, a resposta tem esta forma:
POST /v1/cloudvec/collections/{cid}/search
{ "query": "PIX-2024-88317 estorno", "k": 8, "hybrid": true }
// resposta (encurtada)
{
"hits": [
{
"id": "chunk_9f2c",
"content": "O pedido PIX-2024-88317 teve estorno parcial aprovado em...",
"score": 0.0325,
"metadata": { "match": "both", "source": "conciliacao-2024.md" }
},
{
"id": "chunk_c703",
"content": "PIX-2024-88317: cliente abriu contestação pela central e...",
"score": 0.0164,
"metadata": { "match": "lexical", "source": "tickets-fevereiro.md" }
},
{
"id": "chunk_51aa",
"content": "Fluxo de estorno de pagamentos instantâneos: o prazo de...",
"score": 0.0161,
"metadata": { "match": "semantic", "source": "runbook-pagamentos.md" }
}
]
}
A leitura dos três hits conta a história do pipeline. O primeiro foi bem nos dois braços — termo exato e contexto certo — e a fusão o premiou com o dobro de score. O segundo é o resgate lexical: contém o ID literal, mas o cosine o enterraria; numa busca vetorial pura, ele não estaria na resposta. O terceiro é o braço denso fazendo o que faz bem — trouxe o runbook de estorno sem dividir uma palavra sequer com a query. Nenhum braço sozinho entregaria os três.
O badge match: semantic, lexical ou both
Cada hit da busca híbrida sai anotado com a origem: match: semantic, lexical ou both. Parece detalhe cosmético. Não é.
Retrieval falha em silêncio. Quando a resposta do agente vem errada, a primeira pergunta de debug é sempre a mesma: o documento certo chegou ao contexto? Se chegou, o problema está no prompt ou no modelo. Se não chegou, o problema está na busca — e você precisa saber qual braço falhou.
- Query com termo exato retornando só
semantic: o braço lexical não está enxergando o campo certo, ou a tokenização quebrou o termo. - Query conceitual retornando só
lexical: os embeddings podem estar desatualizados, ou o chunking cortou o contexto no lugar errado. bothnos primeiros hits: os dois braços concordam; o retrieval está saudável para aquela classe de query.
Sem essa anotação, toda investigação de "por que não achou" começa do zero. Com ela, o badge é o primeiro log que você lê.
Quando ligar o re-rank (e quando não)
Sobre a lista fundida, dá para ligar uma etapa extra com rerank: true: um re-rank listwise com LLM. Listwise quer dizer que o modelo recebe a query e os candidatos juntos, de uma vez, e reordena o conjunto — em vez de dar nota a cada documento isolado. É a etapa mais precisa do pipeline, porque é a única em que algo de fato lê os textos e julga a lista inteira contra a pergunta.
O re-rank trabalha sobre um over-fetch próprio, controlado por k_overfetch (padrão 20): a fusão entrega até k_overfetch candidatos e o LLM escolhe o top-k final dentro deles. Subir o valor dá mais material ao re-rank — mais chance de resgatar um documento que a fusão deixou na posição 14 — e mais tokens na chamada. Baixar economiza, ao custo de reordenar uma lista curta demais para mudar alguma coisa. O padrão de 20 sobre um k final de 5 a 8 cobre a maioria dos corpora.
É opt-in por chamada por um motivo simples: cada busca com re-rank ganha uma chamada de LLM e a latência que vem com ela. A conta é sempre custo do erro contra custo da chamada:
- Vale a pena: um agente que vai executar uma ação com base no documento recuperado; uma resposta de suporte que segue direto para o cliente; qualquer fluxo em que trocar o parágrafo errado custa mais do que alguns segundos e alguns centavos.
- Não vale: autocomplete, sugestão em tempo real, caminhos sensíveis a latência e buscas exploratórias de alto volume, em que o usuário vai refinar a query de qualquer jeito.
Regra prática: comece sem re-rank, observe os badges e o debug de retrieval, e ligue rerank: true apenas nos caminhos em que a fusão sozinha comprovadamente não basta.
Debug de retrieval é feature
"Por que essa resposta veio errada" é a pergunta mais cara de responder em um sistema de RAG. Sem instrumentação, a investigação é arqueologia: reproduzir a query, logar scores intermediários, comparar rankings na mão e adivinhar qual etapa enterrou o documento certo.
Na Wiki, essa instrumentação faz parte da resposta. O debug de retrieval mostra, por hit, a posição em cada estágio do pipeline: o rank no braço denso, o rank no braço lexical e o rank após o re-rank, quando ele está ligado. Três números por documento — e cada combinação aponta um diagnóstico diferente:
- Denso 44, lexical 1, final 3: a fusão funcionou como projetada. O braço lexical resgatou o termo exato que o cosine não viu.
- Denso 2, lexical ausente, final 6: o documento perdeu posições na fusão porque o braço lexical não o encontrou — verifique a tokenização e o campo indexado.
- Bem ranqueado nos dois braços, rebaixado após o re-rank: o LLM julgou outro candidato mais relevante. Leia os dois: ou o re-rank acertou, ou o chunk certo está mal recortado e não parece responder a pergunta.
- Ausente dos dois braços: o problema não é ranking, é ingestão — o documento não entrou no índice, ou a fonte está desabilitada.
A diferença prática é o tempo até o diagnóstico. Sem os ranks por braço, cada investigação começa do zero e termina em hipótese. Com eles, a primeira leitura da resposta já diz qual componente olhar — e "por que não achou" vira uma pergunta com resposta mecânica, não um mistério.
Knowledge Refinery: destilar antes de buscar
Existe uma classe de pergunta em que nem a melhor busca é a ferramenta certa: a pergunta recorrente. "Qual o timeout padrão da API" não precisa de oito chunks costurados por um LLM a cada consulta — precisa de uma resposta de duas linhas, destilada uma vez.
O Knowledge Refinery faz essa destilação, e o fluxo é assíncrono de ponta a ponta. Você dispara um run para o espaço; o run varre o corpus em background e extrai FAQ cards — pares de pergunta e resposta com as páginas-fonte citadas em cada card. O status do run é acompanhável do início ao fim, então dá para disparar, seguir o progresso e revisar o resultado quando fechar. A configuração é por espaço: uma wiki de runbooks pode destilar agressivamente enquanto um corpus exploratório fica de fora. Os cards resultantes são listáveis via GET .../faq e revisáveis como qualquer artefato — o que o Refinery produz não é caixa-preta.
Na consulta, o modo qa_first: true muda a ordem tanto da busca quanto do ask: cards destilados são preferidos antes dos chunks crus. Se um card responde a pergunta, ele volta direto — sem costura, sem variação entre consultas. Se não, o pipeline cai para a busca híbrida normal, com tudo que este post descreveu. A diferença prática:
| Aspecto | Sem Refinery | Com qa_first |
|---|---|---|
| Caminho | Busca híbrida → top-k chunks → LLM costura a resposta | Card FAQ encontrado → resposta direta |
| Consistência | A costura precisa dar certo a cada consulta | Destilado uma vez, servido igual sempre |
| Contexto consumido | Vários chunks por pergunta | Um card |
| Melhor para | Perguntas raras e exploratórias | Perguntas recorrentes e operacionais |
wiki_ask: resposta com fonte, não opinião
O elo final é o wiki_ask — na API REST, um POST /v1/cloudwiki/spaces/{sid}/ask com {"question": "..."}. Você envia uma pergunta e recebe uma resposta fundamentada via RAG, com as páginas-fonte citadas junto. Não é um chat solto sobre o corpus — cada resposta aponta de onde saiu.
Citação de fonte não é formalidade. É o que permite a quem consome a resposta — humano ou agente — verificar antes de agir. Um agente de código que consulta a wiki do projeto recebe a síntese e os caminhos das páginas; se a decisão for cara, abre as fontes e confere. Nós operamos exatamente assim: os agentes que desenvolvem o Catcher Data consultam a wiki interna via wiki_ask antes de implementar qualquer coisa.
Todo o pipeline deste post — busca híbrida com RRF, badges de match, re-rank opcional, debug de retrieval, qa_first e wiki_ask — está disponível na API REST e como tools MCP para agentes, com os dados hospedados no Brasil (GCP southamerica-east1, São Paulo). Os parâmetros completos e mais exemplos estão na documentação do Vector e na documentação da Wiki.
Teste a busca híbrida no seu corpus
Crie uma coleção no Vector ou um espaço na Wiki, ingira seus documentos e compare o retrieval com e sem o braço lexical. Console em português, dados na região de São Paulo.