Um agente de RAG contratual avaliado pelo que ele se recusa a responder.
Quase todo demo de RAG mede acerto. Este mede recusa — porque num corpus jurídico real a maioria das perguntas não tem resposta no documento, e um agente que sempre responde é um agente que alucina 68% das vezes com confiança.
A tese, falsificável: num corpus onde a maioria das perguntas não tem resposta, o eixo que discrimina um agente de RAG é a abstenção, não a acurácia.
É o achado central do RodoQuery — onde a acurácia saturou em 100% e só a abstenção seguiu discriminando — levado para outro domínio e outra stack, para ver se sobrevive fora de casa.
Números medidos por fase0_dados.py, não citados de terceiros
(artefato):
| Contratos comerciais reais | 510 (PDF + TXT, títulos distintos) |
| Perguntas rotuladas | 20.910, em 41 categorias de cláusula |
Sem resposta (is_impossible) |
14.208 — 68,0% |
| Spans de resposta | 13.823, 100% conferem no offset declarado |
| Gold | humano — estudantes de direito treinados por advogados (eBrevia) |
| Licença | CC BY 4.0, uso comercial permitido |
| Texto | 26,8 M caracteres · mediana de 33.425 por contrato |
Dois detalhes fazem a diferença entre um benchmark e uma demo:
- O gold é humano. No RodoQuery ele saía de um compilador (MetricFlow), o que garantia consistência mas não julgamento. Aqui foram advogados em formação, com processo documentado.
- Os spans conferem byte a byte no offset. Isso permite avaliar recuperação sem juiz-LLM — sem que o próprio modelo seja parte da régua que o mede.
Trocar de base no início custa um dia; descobrir no fim que a base não sustenta a tese custa o projeto. Os sete critérios foram declarados antes de olhar o dado e estão no código:
PASSA 1_volume_contratos ≥ 300 contratos com texto extraído
PASSA 2_volume_perguntas ≥ 5.000 perguntas rotuladas
PASSA 3_recusa_e_maioria ≥ 50% das perguntas SEM resposta ← o eixo da tese
PASSA 4_gold_verificavel ≥ 99% dos spans batem no offset declarado
PASSA 5_gold_humano rótulo é humano, não de máquina
PASSA 6_licenca_permissiva licença permite uso em portfólio público
PASSA 7_texto_substancial mediana ≥ 10.000 caracteres por contrato
VEREDITO: GO
Cada critério tem um controle negativo nos testes — é exercitado contra uma medida que ele deve reprovar. Critério que nunca disse não é decoração. Há inclusive um teste que falha se um critério novo entrar sem controle negativo.
rglob("*.pdf") é case-sensitive, e 311 dos 510 arquivos vêm com .PDF maiúsculo: o crivo
reportou 199 PDFs e teria publicado uma subcontagem de 61%. Peguei conferindo o disco contra o
ZIP, extensão por extensão (1051 = 1051). Pelo mesmo motivo, licença e anotador saíam de regex
frouxa — o termo "License Grant", que é um tipo de cláusula do corpus, casava com
/licen[cs]e/ e imprimia um trecho sem relação com licenciamento.
Também comparei a fonte oficial (Zenodo/Atticus Project) com o espelho do Kaggle antes de escolher: o espelho hoje devolve 403, e espelho costuma ser subconjunto desatualizado.
git clone https://github.com/alanjoffre/naoconsta && cd naoconsta
python -m venv .venv && source .venv/bin/activate
pip install -e ".[test]" # o mesmo extra que o CI usa
bash baixar_cuad.sh # corpus da fonte oficial, ~106 MB, fora do repo
python fase0_dados.py # crivo de aptidão, com go/no-go
pytest -q # 68 testes
python verificar_coleta.py # nenhum teste some por falta de extra
bash ensaiar_ci.sh --negativo # ensaia o CI num clone limpo, com controle negativo
python -m naoconsta.servidor_mcp # servidor MCP (stdio) — prove com: python provar_mcp.pyO CI deste repositório nasceu verde e é verificado antes de cada push. Isso não é obviedade: no RodoQuery o CI nasceu na Fase 5 e ficou vermelho da Fase 6 até a 22 — 32 execuções, 12 dias — porque o runner instalava um extra a menos e ninguém leu o badge. As travas que saíram daquela fase vieram junto para cá:
- extra
[test]nomeado nopyproject.toml, em vez de lista mágica no YAML; pythonpath = ["."], porquepytest(console script) epython -m pytestnão enxergam o mesmosys.path— uma letra de comando separava a máquina local do runner;verificar_coleta.pyem subprocesso, invocando o mesmo executável que o CI usa: a primeira versão rodava in-process e deu falso negativo exatamente na classe de defeito que existe para pegar;ensaiar_ci.shcom controle negativo: quebra opythonpathde propósito e exige que a trava reprove.
Verde com teste faltando é pior que vermelho — vermelho ao menos grita.
| Fase | Pergunta | Estado |
|---|---|---|
| 0 · Dados | o corpus sustenta a tese da recusa? | ✅ GO, 7/7 critérios |
| 1 · Recuperação | pgvector acha o span certo? (recall contra offset humano, sem juiz-LLM) |
✅ 88,1% em 12k chars · o defeito estava na pergunta (+9,0 pp) · meu portão era manipulável · HNSW nunca usado |
| 2 · Agente | grafo LangGraph com guarda de procedência | ✅ 62,0% (124/200) · ❌ 3 de 5 previsões refutadas · o modo de falha é o oposto do previsto · ❌ a guarda tinha defeito meu |
| 2b · SUT capaz | o eixo da recusa volta a discriminar com um modelo que sabe responder? | ⏳ aberta — é o contraste que falta |
| 3 · Avaliação | acurácia × abstenção, sem juiz-LLM | ✅ integrada à Fase 2 |
| 4 · MCP | expor a recuperação como servidor MCP | ✅ 3 ferramentas, provado ponta a ponta · ❌ 68 testes passavam com o servidor quebrado |
Detalhe em docs/FASE4_MCP.md. Três ferramentas MCP expõem a recuperação;
nenhuma responde perguntas — devolvem trechos com procedência (contrato, offsets,
similaridade) e param aí. Um servidor que respondesse embutiria as decisões da Fase 2 num lugar
onde ninguém as mede, e a tese é sobre medir recusa, não escondê-la atrás de uma API.
- Os 68 testes passavam com o servidor sem subir. Eu escrevi
mcp.server.fastmcp.FastMCPde memória — API de uma versão anterior do SDK — e do lado do cliente isso aparecia só como "Connection closed". Teste com dependência injetada prova a lógica; não prova que o processo sobe, que o handshake fecha, nem que o cliente enxerga as ferramentas. Por issoprovar_mcp.pyficou versionado: ele sobe o servidor como subprocesso. naoconsta_estatisticasdeclara o teto no próprio protocolo: 88,1% de recall significa que ~11,9% das respondíveis não têm evidência no material entregue. Sem esse aviso, quem consome os trechos lê abstenção como acerto.- Erro de infraestrutura agora diz o que fazer. Um
connection timeout expiredopaco me custou uma rodada inteira procurando defeito no protocolo — o problema era o Docker parado.
Detalhe em docs/FASE2_AGENTE.md; a previsão está em
docs/FASE2_PREREGISTRO.md, commitada antes de rodar.
200 consultas estratificadas, qwen2.5:7b local, sem juiz-LLM, custo zero.
| sem guarda | com guarda | |
|---|---|---|
| respondíveis | 33,0% | 33,0% |
| respondíveis com a evidência entregue | 36,8% | 36,8% |
| abstenção | 90,0% | 91,0% |
| total | 123/200 | 124/200 |
3 das 5 previsões caíram, incluindo a central. Eu construí uma guarda contra alucinação, apostando que um 7B com 12.000 caracteres na frente sempre acharia algo parecido com resposta. Ele abstém em 90% e acerta só 33% das respondíveis, mesmo com a evidência entregue em 87 de 100 casos: o problema dele não é inventar, é não responder. Com um SUT fraco a recusa está fácil e quem discrimina é a resposta — o que não refuta a tese, mas mostra que ela depende da força do modelo, como a lei da Fase 18 do RodoQuery previa para muletas. A Fase 2b é o contraste que falta.
E a guarda tinha um defeito meu. Na primeira execução ela piorou o placar (123 → 121): dos 13 barrados, 4 atravessavam trechos adjacentes e 3 desses acertavam o gold, com citações de 376, 438 e 541 caracteres. Uma citação de 541 caracteres não cabe num chunk de 400 — reprovada por construção. A Fase 1d trocou o chunk de 1.200 para 400 por um motivo medido e sem relação nenhuma com a guarda, e isso quebrou em silêncio um componente escrito quando o chunk era três vezes maior. A pergunta certa nunca foi "cabe num pedaço?" e sim "veio do material que o agente viu?". Corrigida: barrados 13 → 8, acertos perdidos 3 → 0, placar 123 → 124.
A guarda é neutra no placar e positiva na confiança: converte 8 citações fabricadas em abstenções, mas só 1 caía numa pergunta sem resposta. Melhorar o comportamento sem melhorar a pontuação é diferente de ser inútil — e está reportado assim.
Detalhe em docs/FASE1_RECUPERACAO.md; a previsão está em
docs/FASE1_PREREGISTRO.md, commitada antes de rodar.
| sistema | recall@5 | recall@10 |
|---|---|---|
denso_bruto (config. pré-registrada) |
56,3% | 69,8% |
denso_enxuto |
65,3% | 78,8% |
bm25 |
62,0% | 73,1% |
hibrido_rrf |
67,4% | 78,6% |
primeiros (espantalho posicional) |
35,7% | 46,2% |
aleatorio (piso da sorte) |
21,3% | 38,0% |
- O defeito estava na PERGUNTA, não no modelo. As 41 perguntas do CUAD abrem com o mesmo preâmbulo ("Highlight the parts (if any)… reviewed by a lawyer"), e isso deixava o cosseno médio entre perguntas diferentes em 0,735 — 41 consultas quase colineares. Apagando o preâmbulo: 0,594, e +9,0 pp de recall (McNemar p = 2×10⁻¹⁰⁰). O BM25 nunca sofreu disso, e era só por isso que parecia melhor. Falha mecânica se conserta em código — aqui, apagando prosa.
- O índice HNSW nunca foi usado. Montei um sistema para medir a armadilha do filtro por
metadado; ele deu idêntico ao exato nos 4 valores de k porque o
EXPLAINrevelatop-N heapsort— comWHERE contrato = ?pegando 0,2% das linhas, o planner ignora o HNSW. Retirei a comparação. Para busca dentro de um documento, o índice vetorial é peso morto. - Minha hipótese do prefixo estava errada.
search_query:/search_document:donomic-embed-text: 56,2% sem × 55,2% com. Medi em vez de supor, e a suposição caiu. - O meu portão pré-registrado era manipulável.
recall@10 ≥ 85%conta pedaços, não evidência: chunks de 3.000 o atravessam com 87,7% sendo pior recuperação em qualquer orçamento justo de texto. Se eu tivesse rodado só a varredura ingênua, teria aberto o portão piorando o sistema, com um número pré-registrado para legitimar. Retirado e re-especificado como recall com 12.000 caracteres de orçamento — o mesmo texto que a configuração já gastava. Com chunks de 400/75: 88,1% [87,4; 88,9], +9,3 pp sem gastar um caractere a mais. Detalhe emdocs/FASE1B_ALAVANCAS.md. - "Modelo maior" foi refutado outra vez.
bge-m3(1024d) perde paranomic-embed-text(768d) em 5,4 pp @10 (p = 1,2×10⁻⁴) e roda 2,5× mais devagar. As duas alavancas de escalar componente morreram; a que funcionou foi apagar prosa.
Nenhum item é surpresa: todos foram declarados na fase em que apareceram, com o custo de deixá-los abertos.
Aberto — quatro itens:
- Fase 2b: um SUT capaz de responder. É o contraste que falta, e o mais importante da lista.
O
qwen2.5:7babstém em 90% e acerta 33% das respondíveis: com ele a recusa está fácil e o eixo que discrimina é a resposta. Só um modelo que sabe responder testa se a tese volta a valer. Duas rotas medidas:llama-3.3-70bno Groq (grátis, mas o teto é de 100 mil tokens/dia e 200 consultas custam 559 mil — cabe em ~6 janelas, e o runner é retomável) ou Haiku 4.5 por ~US$ 0,74 numa rodada só. - O servidor MCP não é exercitado no CI. Os 11 testes de lógica rodam; a prova ponta a ponta precisa de Postgres e Ollama, que o runner não tem. Ela é manual — e a Fase 4 mostrou exatamente o que isso deixa passar: 68 testes verdes sobre um servidor que não subia.
- Chunk de 300/60 rende +1,4 pp e não foi adotado. A 12.000 caracteres ele faz 89,5% contra os 88,1% do 400/75 escolhido. Preterido porque custa 35% mais vetores e entrega 40 fragmentos em vez de 30 — e "40 cacos são piores que 30 para o agente" é hipótese não medida, registrada como tal. Medir isso é uma decisão, não uma dívida.
- Chunker por cláusula. A varredura da Fase 1c foi monotônica até o menor tamanho testado, sem joelho: nunca verifiquei se um fatiamento que respeita fronteiras de cláusula bate o corte cego. O CUAD tem a estrutura para isso.
Fora de escopo, e por quê:
- Sem autenticação no servidor MCP — o transporte é stdio, que implica execução local. Expor por rede exigiria autenticação, e isso mudaria o desenho.
- Nenhum juiz-LLM em nenhuma fase. É restrição deliberada: o gold é offset de caractere anotado por humano, e a régua é aritmética de intervalos. Vale para a recuperação e para o agente — a guarda de procedência é comparação de string, não julgamento de modelo.
Código sob MIT. O corpus CUAD é do Atticus Project,
sob CC BY 4.0, e não está versionado aqui — baixar_cuad.sh o traz da fonte oficial.