Konhecia: o que deu certo e o que deu errado construindo um RAG corporativo local
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 devenvlocal. - 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:7bpara geração (~4,7 GB) enomic-embed-textpara 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:
- Infraestrutura Docker + esqueleto FastAPI — healthcheck já verificando as três dependências externas.
- Camada de dados — Postgres, SQLAlchemy assíncrono, Alembic, repositórios.
- Pipeline de ingestão — PDF → chunks → embeddings → Qdrant, com CLI própria.
- Chain RAG com LCEL — recuperação, histórico, prompt anti-alucinação.
- Endpoints FastAPI com streaming — upload, chat via SSE, histórico.
- 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:
| Arquivo | Testes | Cobre |
|---|---|---|
test_pipeline.py | 3 | chunking, roundtrip de ingestão+busca, idempotência |
test_repositories.py | 2 | persistência de conversas/mensagens, get_or_create |
test_rag.py | 7 | helpers puros, janela de histórico, limiar, recusa, roundtrip real |
test_api.py | 7 | autenticaçã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.