0xEtern4lW0lf
Engenharia

Konhecia: o que deu certo e o que deu errado construindo um RAG corporativo local

9 min de leitura
#Konhecia#RAG#retrospectiva#testes#LLM local#pytest

O Konhecia está no ar. É um agente de suporte com RAG corporativo que roda 100% local: PDFs internos entram numa base vetorial e o agente responde estritamente com base neles — recusando explicitamente quando a informação não está lá, em vez de inventar.

Os seis posts anteriores cobriram cada tecnologia da stack em separado. Este é o post que eu mais queria escrever: o que aconteceu de fato, incluindo o que quebrou.

O ambiente

Vale registrar, porque muita coisa do projeto foi decidida por ele:

  • Windows 11 com WSL2 e Docker Desktop, com o NVIDIA Container Toolkit configurado para expor a GPU aos containers.
  • Tudo em container. A aplicação roda em python:3.11-slim, com usuário não-root. Nada de Python instalado no host, nada de venv local.
  • Cinco serviços no Compose: Postgres 16, Qdrant v1.19.1, o servidor Ollama, o container one-shot que baixa os modelos, e a API.
  • Modelos: qwen2.5:7b para geração (~4,7 GB) e nomic-embed-text para embeddings (~274 MB).

Desenvolver no Windows deixou marcas úteis no projeto. Duas foram parar no README porque me custaram tempo:

O cURL do Windows engole acentos em -d '...' inline. Uma pergunta com “ç” ou “ã” chegava corrompida na API, e eu passei um bom tempo procurando bug de encoding na aplicação — que não existia. A saída é salvar o JSON num arquivo UTF-8 e usar --data-binary @arquivo.json. É particularidade do shell, não da API.

make não vem no Windows. Como criei um Makefile de atalhos, quem clona no Windows precisa instalar via choco/scoop — ou rodar os docker compose exec equivalentes, que ficaram visíveis dentro do próprio Makefile justamente por isso.

A lição: o ambiente do autor vaza para o projeto. Se eu não tivesse anotado, teria publicado um repositório que quebra silenciosamente para quem está no mesmo sistema que eu usei para construí-lo.

Seis fases, sequência importa

Dividi em fases fechadas, cada uma terminando em algo que roda:

  1. Infraestrutura Docker + esqueleto FastAPI — healthcheck já verificando as três dependências externas.
  2. Camada de dados — Postgres, SQLAlchemy assíncrono, Alembic, repositórios.
  3. Pipeline de ingestão — PDF → chunks → embeddings → Qdrant, com CLI própria.
  4. Chain RAG com LCEL — recuperação, histórico, prompt anti-alucinação.
  5. Endpoints FastAPI com streaming — upload, chat via SSE, histórico.
  6. Documentação — o README de vitrine.

A ordem não foi arbitrária, e a fase 3 antes da 4 foi a melhor decisão do projeto. A ingestão ganhou uma CLI com subcomando de consulta:

make query q="Como faço para resetar minha senha?"
[1] score=0.7161 fonte=manual_de_suporte.pdf pagina=1

Isso permitiu validar a recuperação isoladamente, antes de existir qualquer LLM no caminho. Quando a resposta final está ruim, a primeira pergunta é sempre “o problema é recuperação ou geração?” — e essas são causas totalmente diferentes. Sem uma forma de olhar só a recuperação, você ajusta prompt achando que o problema é o prompt, quando os chunks certos nem chegaram.

Os testes: 19, contra infraestrutura real, sem mocks

Essa foi a decisão mais contrária ao manual, e a que eu repetiria.

docker compose exec app pytest -v

19 testes, nenhum mock, rodando contra o Postgres, o Qdrant e o Ollama do próprio docker compose:

ArquivoTestesCobre
test_pipeline.py3chunking, roundtrip de ingestão+busca, idempotência
test_repositories.py2persistência de conversas/mensagens, get_or_create
test_rag.py7helpers puros, janela de histórico, limiar, recusa, roundtrip real
test_api.py7autenticação, recusa, caminho fundamentado, upload, 404

Dois deles fazem o roundtrip completo contra o LLM real — ingerem um PDF de teste, perguntam e verificam que a resposta veio fundamentada com a fonte certa.

Por que sem mocks

Mockar o modelo de embedding é mockar exatamente a parte que pode dar errado. Um teste que verifica que “chamei o vector store com os argumentos certos” passa perfeitamente enquanto a dimensão do vetor está errada, o limiar está mal calibrado e a busca devolve lixo. Testa a chamada, não o comportamento.

Como a infraestrutura sobe com um comando, o custo de usar a real é baixo. O preço é honesto: a suíte é lenta (os testes de LLM levam segundos cada) e exige a stack de pé. Para um projeto deste tamanho, vale. Numa suíte de milhares de testes em CI, eu separaria em camadas.

Isolamento sem banco de teste separado

Sem infraestrutura dedicada de teste, o isolamento veio de duas fixtures.

Para o vetorial, uma collection efêmera por teste:

test_settings = base_settings.model_copy(
    update={"qdrant_collection_name": f"test_pipeline_{uuid.uuid4().hex[:8]}"}
)

Nome aleatório, apagada no finally. Nenhum teste toca a collection de produção (corporate_docs).

Para o relacional, limpeza da tabela pai:

await session.execute(text("DELETE FROM conversations"))

Só a pai — o ON DELETE CASCADE arrasta as mensagens. Aquela constraint que descrevi como “boa prática” no post sobre a camada de dados acabou pagando aqui, num uso que eu não tinha previsto.

E a API é testada via ASGITransport, sem subir servidor HTTP: o httpx fala direto com a aplicação ASGI. Testes de rota sem porta, sem espera de boot, com dependency_overrides apontando as rotas para a collection isolada.

Os quatro bugs que valeram a pena

1. now() embaralhando o histórico

server_default=func.now() é o que todo tutorial mostra. No Postgres, NOW() retorna o horário de início da transação — e pergunta e resposta de um mesmo turno são gravadas na mesma transação. Ambas recebiam created_at idêntico, o ORDER BY ficava indefinido, e o histórico injetado no prompt podia chegar invertido.

clock_timestamp() resolveu. Mas o que ficou foi outra coisa: o padrão nem sempre é o correto, e o sintoma aparece longe da causa. Eu culparia o prompt por um bom tempo antes de suspeitar de um carimbo de data.

2. Hedge: o modelo que não recusa nem responde

O prompt mandava recusar quando o contexto não respondesse. O modelo passou a especular com qualificador: “provavelmente o procedimento envolve…”, “pode-se inferir que…”.

Tecnicamente não mentiu — avisou que estava inferindo. Na prática, entregou invenção com aparência de cautela. É pior que alucinar direto, porque soa confiável.

A correção foi proibir as construções específicas, com nome, e dizer o que fazer no lugar. Instrução vaga produz comportamento vago; “não invente” não é acionável, uma lista de padrões proibidos é.

3. O bug sério: fontes numa resposta recusada

Consequência do anterior, e o pior do projeto.

O código detectava recusa comparando a saída com a frase de fallback. Mas o modelo nem sempre devolve a frase isolada — às vezes a embute no fim de uma explicação maior. Com igualdade exata, isso não era reconhecido como recusa.

Resultado: a resposta ia marcada como grounded: true, com as fontes anexadas. O sistema estava dando aval de procedência a uma não-resposta, citando um documento que não continha a informação. Num agente cujo propósito inteiro é ser confiável, não há falha pior.

A correção é uma linha — verificar substring em vez de igualdade — e virou o teste test_classify_answer_detects_fallback_embedded_in_longer_text.

Isso teve efeito no streaming também: a classificação só pode acontecer depois do texto completo. Por isso os tokens vão saindo, mas sources e grounded só aparecem no evento done. Não é escolha de protocolo — é o que impede uma resposta recusada de vazar citações no caminho.

4. O limiar de score é mais fraco do que parece

Eu tratava RAG_MIN_RELEVANCE_SCORE como a defesa contra alucinação. Não é.

Modelos de embedding dão similaridade “de fundo” relativamente alta até para textos sem relação — dois textos em português, no mesmo registro, sobre assuntos diferentes, não caem para perto de zero. E subir o limiar não salva: um teste verifica que, com 0.99, nenhum chunk real passa, nem os certos.

Por isso a arquitetura final tem duas camadas convergindo para a mesma frase de recusa: o limiar pega o caso fácil, o prompt pega o caso difícil. Uma defesa que você não mediu é uma suposição.

O que deu certo

Configuração inteira em .env, com .env.example comentado. Tamanho de chunk, top-k, limiar, janela de histórico, temperatura, nome do agente, tom de voz e a frase de recusa. Nada de domínio hardcoded — o mesmo código serve suporte de TI ou RH trocando variáveis. E calibrar RAG é inerentemente empírico: se cada ajuste exigir editar código, você não vai ajustar.

Ingestão idempotente desde o começo. uuid5 do caminho como id estável, apaga os chunks daquele documento antes de inserir. Reenviar o mesmo arquivo dez vezes deixa a base no mesmo estado que enviá-lo uma. Descobrir isso depois significaria uma base contaminada sem forma fácil de limpar.

Healthcheck que verifica dependências de verdade. /health testa Postgres, Qdrant e Ollama individualmente e devolve degraded com o culpado nomeado. Vale dez vezes mais que um {"status":"ok"} que só prova que o processo subiu.

Degradação graciosa da GPU. Sem placa, o Ollama cai para CPU sozinho. Alguém clona e roda num notebook comum, mais devagar, sem editar YAML.

compare_digest e autenticação no roteador. Comparação em tempo constante na API Key, e APIRouter(dependencies=[...]) em vez de decorar rota por rota — assim um endpoint novo nasce protegido, em vez de depender de alguém lembrar.

O que eu faria diferente

Testes em camadas. Sem mocks foi a escolha certa para achar bugs reais, mas a suíte é lenta e exige a stack inteira. Eu separaria: os helpers puros (_format_context, _classify_answer, chunking) rodam em milissegundos sem infraestrutura nenhuma e poderiam ser um alvo rápido, com os de integração num alvo separado.

Avaliação sistemática da recuperação. Calibrei chunk_size, top_k e o limiar no olho, com perguntas manuais. O certo é um conjunto de perguntas com as respostas esperadas e uma métrica — sem isso, “melhorou” é impressão. Como o projeto mostrou que o limiar é frágil, essa é a lacuna mais séria.

Ordenação explícita, não por relógio. clock_timestamp() corrigiu o sintoma, mas ordenar por horário continua frágil. Uma coluna de sequência monotônica por conversa seria uma garantia, não uma aposta na resolução do relógio.

Sair do PyPDFLoader. Está em descontinuação a favor de pacotes standalone; funciona, mas o warning nos logs é dívida com data marcada. E hoje só há suporte a PDF — DOCX e HTML seriam o próximo passo natural.

Multi-tenant. Uma API Key compartilhada é suficiente para um projeto demonstrativo local, e insuficiente para qualquer coisa além disso. Não há usuários, não há isolamento entre conversas de pessoas diferentes, e a collection é única. Está documentado nas limitações do README — o pecado seria omitir.

O resumo

O que eu tiro do projeto não é sobre RAG especificamente:

  • A validação isolada de cada camada vale mais que qualquer atalho. A CLI de consulta antes da chain foi o que impediu horas de ajuste no lugar errado.
  • Testes contra infraestrutura real encontram bugs que mocks escondem por construção. Os quatro bugs deste post são todos de comportamento, não de contrato de chamada.
  • Num sistema que se propõe confiável, “quase certo” é falha. Devolver fontes numa resposta recusada não trava, não gera exceção, não aparece em log. Só corrói exatamente a propriedade que justifica o sistema existir.

O código está todo em github.com/0xEtern4lW0lf/konhecia, com README de instalação e a arquitetura em diagrama.