Pular para o conteúdo
Pablo Ortiz
← Blog

Um RAG que sabe dizer: não está nas minhas notas

Busca vetorial nunca volta vazia. Como um corte de distância e uma segunda defesa no prompt fizeram meu RAG recusar em 0,2 s.

4 min de leitura
  • #rag
  • #pgvector
  • #avaliacao
  • #llm-local

Perguntei ao meu assistente "quais são as figuras de linguagem?". Ele trouxe três trechos sobre lógica difusa e UML. Nenhum falava de figuras de linguagem, mas os três chegaram com a mesma cara de resposta das perguntas certas.

Se esses trechos fossem direto para o modelo, eu receberia um parágrafo confiante sobre um assunto que eu nunca estudei.

Este post mostra como o Copiloto, meu assistente local, aprendeu a dizer "não tenho isso indexado". E por que isso ficou 60 vezes mais barato do que responder.

O problema: busca vetorial nunca volta vazia

Um RAG busca os trechos mais parecidos com a pergunta e entrega ao modelo. A busca vetorial sempre tem um vizinho mais próximo, então ela nunca devolve vazio. Mesmo quando o assunto não existe no índice, algum trecho fica em primeiro lugar.

O Copiloto usa busca híbrida: vetorial e full-text, fundidas por Reciprocal Rank Fusion (RRF). Minha primeira ideia foi usar o score do RRF como medida de confiança. Não funciona. O score do RRF depende só da posição de cada trecho nas listas:

RRF(d)=rR1k+r(d)\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + r(d)}

O primeiro colocado tem o mesmo score com ou sem resposta de verdade. Nos meus testes, "tem resposta" e "não tem" empataram em 0,0164.

O que separa: a distância de cosseno

A distância de cosseno entre a pergunta e o melhor trecho separa os dois grupos. Medi contra o índice real, com 1.773 trechos:

PerguntaMelhor distânciaTem resposta?
normalização de banco de dados0,269sim
o que é uma chave estrangeira0,405sim
o que é overfitting0,420sim
como funciona a busca híbrida do copiloto0,483sim
figuras de linguagem0,480não
regras do xadrez para o roque0,553não
receita de bolo de cenoura0,580não
qual a capital da Mongólia0,745não

As duas faixas se tocam perto de 0,48. Nenhum número separa os dois grupos sem errar.

Escolhi o corte em 0,55, de propósito conservador. Ele recusa o que está claramente fora e deixa a zona cinzenta para uma segunda defesa.

Duas defesas, uma barata e uma esperta

flowchart TD
P[Pergunta] --> B[Busca híbrida: vetorial + full-text]
B --> C{Distância > 0,55 e nenhum acerto lexical?}
C -- sim --> N[Não indexado, sem chamar o LLM]
C -- não --> L[LLM com trechos numerados]
L --> V{Citações válidas?}
V -- sim --> R[Resposta com fontes]
V -- não sabe --> N2[Não indexado]

1. O código, antes do LLM. Se a melhor distância passa de 0,55 e nenhuma palavra da pergunta aparece literalmente num trecho, a resposta é "não tenho isso indexado". Nenhum token é gasto.

2. O prompt, depois da busca. Na zona cinzenta, o modelo recebe os trechos e a ordem explícita de admitir quando eles não respondem. Toda afirmação precisa de uma citação [n], e um validador rejeita citação para um trecho que não existe.

Em forma simplificada:

pergunta.py
def perguntar(pergunta: str) -> Resposta:
    trechos = buscar(pergunta, limite=5)
 
    if trechos[0].distancia > 0.55 and not acerto_lexical(pergunta, trechos):
        return Resposta.nao_indexado()  # 0,2 s, zero token
 
    texto = llm.gerar(prompt_ancorado(pergunta, trechos))
    validar_citacoes(texto, trechos)  # todo [n] precisa existir
    return Resposta(texto, trechos)

O "acerto lexical" foi um desvio do plano original. Se a pergunta tem uma palavra exata de uma nota, como pgvector ou um nome próprio, o índice tem o assunto, mesmo que o embedding discorde. Recusar nesse caso seria o pior tipo de erro.

O resultado: 12 de 12

Avaliei com 12 perguntas fixas: 8 com resposta no índice e 4 sem. Modelo local gemma4:e4b, na minha GPU de 6 GB.

DistânciaTempoResultado
8 perguntas com resposta0,30 a 0,4512 a 20 stodas responderam, citando de 1 a 5 fontes
4 perguntas sem resposta0,57 a 0,690,2 stodas recusaram

Duas perguntas mostraram por que a segunda defesa existe. "Quais são as figuras de linguagem?" mediu 0,475 e passou pelo corte, como planejado. O modelo recebeu lógica difusa como contexto e respondeu que não sabia. "O que é uma metáfora?" mediu 0,497, recebeu um trecho sobre função do 2º grau e também recusou.

Recusar custa 0,2 s. Responder custa de 12 a 20 s. O corte não melhora só a qualidade: ele evita que a GPU passe 15 segundos elaborando uma resposta errada.

O que eu faria diferente

  • Planejar o salvo-conduto lexical desde o início. Ele só apareceu quando uma pergunta com resposta foi recusada.
  • Mostrar todos os trechos que a busca trouxe, não só os citados. Sem ver o que chegou ao modelo, não dá para desconfiar da resposta. É a diferença entre "confie em mim" e "confira".