Relato anonimizado. Nome do cliente e do produto omitidos por cláusula de não concorrência. A arquitetura e os padrões de stack ficam abertos para discussão.
Contexto
O cliente era um banco tier-1 latino-americano, e isso muda tudo. Ambiente regulado de ponta a ponta: LGPD valendo, auditoria interna trancando cada release e janela de deploy que só abre depois do comitê de mudanças bater o martelo. Nada de subir em produção numa terça à tarde porque deu vontade.
A missão do time interno era triar documentos fiscais — IRPF, declaração de imposto de renda de pessoa física — em escala, com um agente LLM no meio. O agente precisava classificar o tipo de declaração, extrair os campos estruturados, levantar a mão quando algo cheirava a anomalia e, principalmente, registrar cada decisão para que um auditor conseguisse refazer o caminho anos depois.
Os números que balizavam tudo: pico de ~10K declarações/dia e orçamento de latência de ≤8s por declaração no caminho do agente. O que não coubesse nesse envelope ia para uma fila de revisão humana, tratada de forma assíncrona.
Onde estava o problema
Três frentes, e nenhuma delas é “o LLM erra às vezes”.
A primeira é a bagunça do material de entrada. As declarações chegavam em PDF, imagem escaneada, XML estruturado (um formato mais antigo) e até e-mail em texto corrido. Pior: o mesmo campo podia morar em cinco lugares diferentes dependendo do ano da declaração e da categoria do declarante. Não existe “parser único” para isso.
A segunda é o que torna o domínio difícil de verdade: alucinação em contexto regulado não é bug, é incidente. Uma classificação errada não para nela — cascateia para faixa de imposto errada, que vira multa e, potencialmente, apontamento do regulador. Um LLM dizendo “tenho 87% de confiança” pode ser ótimo num chatbot, mas aqui, sozinho, não vale como evidência.
A terceira é a mais ingrata: replay de auditoria. O auditor tem que conseguir reconstruir qualquer decisão cinco anos ou mais depois. Isso significa guardar, de forma imutável, tudo — a entrada, cada hit de retrieval, o prompt exato, a resposta do modelo, o score de confiança e qualquer override que um humano tenha feito por cima.
As regras do jogo
As restrições não eram negociáveis, então melhor encará-las de frente:
- LGPD acima de tudo. Nenhum dado pessoal sai da tenancy Azure do cliente. Zero chamada para API de LLM público — toda a inferência roda dentro do deployment Azure OpenAI do próprio cliente.
- Gate de auditoria em cada deploy. Aprovação do comitê de mudanças mais 72h de soak em staging antes de qualquer release. Hotfix emergencial não existe nesse mundo.
- Soft real-time: 8s de p95 no caminho do agente, 30s de p99.
- Dependência limpa. Nada de pacote Python que puxe binary wheel de índice fora do padrão — a revisão de segurança barra na hora.
- Single-cloud na prática, multi-cloud no projeto. Runtime só em Azure, mas a biblioteca de módulos Terraform foi cross-built para GCP pensando em portabilidade lá na frente.
A arquitetura
flowchart LR
A[Document ingest] --> B[Format normalizer]
B --> C[Chunker + embedder]
C --> D[(Postgres + pgvector)]
D --> E[Retrieval router]
E --> F[Azure OpenAI gpt-4-class]
F --> G[Citation extractor]
G --> H[Confidence scorer]
H -->|high conf| I[Auto-decide + log]
H -->|low conf| J[Human-review queue]
I --> K[(Audit log<br/>append-only)]
J --> K
F -.eval harness.-> L[Offline regression]O grosso do trabalho recai sobre cinco peças.
O format normalizer é a primeira linha de defesa contra a heterogeneidade. Ele transforma PDF, imagem, XML e e-mail num envelope JSON canônico — {filing_id, year, filer_category, raw_text, raw_pages[], metadata} — e tudo a jusante passa a falar essa língua só. PDF e imagem vão para OCR no Azure Document Intelligence (que a segurança já tinha pré-aprovado). XML passa por um parser XSD pinado. E-mail cai num extrator regex estrito, com uma regra honesta: se o regex não achar um campo obrigatório, o documento vai direto para “precisa de humano” em vez de chutar.
O chunker + embedder faz chunking por janela de sentenças (300 tokens, 50 de overlap), mas com um detalhe que importa: a detecção de fronteira é consciente do ano. Formulário tributário brasileiro tem divisor de seção rígido, fácil de reconhecer — então a gente usa isso a favor. Os embeddings saem do text-embedding-3-large do Azure OpenAI e vão parar no Postgres com pgvector, com um índice por ano para já escopar o retrieval.
O retrieval router faz busca k-NN escopada por ano e por categoria de declarante, devolvendo os top-8 hits. E aqui mora um ponto que parece detalhe e não é: cada hit volta com metadado explícito do método de retrieval, para o audit log saber depois exatamente qual modelo de embedding e qual versão de índice produziram aquele conjunto.
O citation extractor é o coração da defesa contra alucinação. Ele obriga o LLM a responder no formato {decision, confidence, citations[]}, em que cada citation é um span apontando para o documento original. E o pulo do gato: o span é validado post-hoc. Se o modelo citar um trecho que não existe na fonte, a resposta é rejeitada e o prompt é refeito (no máximo 2 retries) antes de cair para a revisão humana. Não tem citação válida, não tem decisão automática.
Por fim, o audit log, que é onde o “regulado” vira código. Uma tabela Postgres append-only, particionada por ano. Cada linha carrega o hash da entrada (SHA-256 do JSON canônico), o hash do conjunto de hits, o prompt inteiro com a versão do template, a resposta completa do modelo, a confiança computada, a decisão final e — se rolou escalonamento — quem revisou. O schema é versionado com migrations flyway, e os logs são replicados toda noite para uma conta Azure Storage separada, sob política WORM (write-once-read-many) que trava a retenção em 5 anos.
Por que decidimos assim
Três escolhas merecem o holofote — e cada uma cobrou seu preço.
pgvector, não um banco vetorial dedicado. Foi a primeira briga, e a operação ganhou da performance. Com uma extensão só do Postgres a gente cobre retrieval, audit log e estado operacional de uma tacada: uma história de backup, uma de HA, uma de controle de acesso. Na nossa taxa de pico (~50 retrievals/s), pgvector com índice HNSW segura a latência sobrando. O trade-off, eu coloco na mesa sem rodeio: acima de uns ~500/s a coisa muda de figura, e Pinecone ou Qdrant começam a abrir vantagem, como mostra o benchmark de bancos vetoriais da Supabase de 2025 . Não chegamos nem perto desse número — então a simplicidade venceu.
Citação forçada no prompt, com validação post-hoc dos spans. Exigir a citação e conferir cada span contra a fonte custa uns ~600ms a mais quando precisa de um retry (um round trip extra), e mesmo assim foi o melhor dinheiro de latência que a gente gastou: a taxa de decisão alucinada despencou. A ideia bebe direto do design da API de citações da Anthropic — a mesma lógica, só que feita na unha via prompt engineering em cima dos modelos gpt-4-class do Azure OpenAI. Trade-off aceito de olhos abertos, porque quem manda no projeto é o gate de auditoria.
Retrieval escopado por ano. Lei tributária no Brasil muda de um ano para o outro, e não é pouco. Um prompt de declaração de 2018 recuperando exemplo de 2024 erra rápido e com confiança — o pior tipo de erro. A solução foi tratar o ano como dimensão de índice de primeira classe: o router rejeita hit que cruza ano, a não ser que a query peça isso explicitamente. O custo é honesto — o índice HNSW por ano quase dobra de tamanho em disco. Em troca, contaminação entre anos no eval foi a zero.
O que ficou de aprendizado
Obrigue o modelo a citar e valide o span. Se tem uma única alavanca contra alucinação, é essa. Custa latência e compra decisão que se defende na frente do regulador — barganha óbvia num banco.
Audit log append-only não se discute, se constrói no dia 1. Auditoria parafusada depois vira lacuna nos dados, e lacuna nos dados é apontamento de auditoria na certa.
Escopo de retrieval > tamanho de modelo. Vale repetir porque é contraintuitivo: o modelo menor, com retrieval apertado, ganhou do maior em acurácia, latência e custo ao mesmo tempo.
O gate de deploy é o eval harness, não o teste unitário. Regressão contra um golden set congelado (~500 declarações rotuladas à mão) pega drift de prompt mais rápido que qualquer outra coisa. Roda em todo PR e também num batch noturno contra os últimos 30 dias de tráfego real.
Revisão humana é feature, não plano B. Mandar decisão de baixa confiança para um humano e capturar o raciocínio dele foi o que construiu o dataset que, por sua vez, faz o eval harness melhorar com o tempo. O loop se fecha.
A stack
- Linguagem: Python 3.12
- API: FastAPI (single-process, handlers síncronos — async não agregou nada nesse throughput)
- Armazenamento: Azure Database for PostgreSQL Flexible Server 16 + pgvector 0.7
- LLM: Azure OpenAI Service — deployment
gpt-4-classna tenant do cliente,text-embedding-3-largepara os embeddings - OCR: Azure Document Intelligence (preview aprovado pela segurança)
- Auditoria: tabela Postgres append-only + replicação noturna para Azure Blob Storage com política de imutabilidade
- Infra: Terraform (biblioteca modular reaproveitada no target GCP paralelo — Cloud SQL + Vertex AI + Cloud Storage)
- CI/CD: Azure DevOps Pipelines (YAML multi-stage, com environments + approvals)
- Observabilidade: Azure Monitor + workspace Log Analytics, com dashboards customizados para histograma de confiança e p99 de latência de retrieval
- Eval harness: harness Python feito em casa; ~500 declarações rotuladas à mão como golden set; roda no CI de PR e em cron noturno
Quando vale copiar esse padrão
- Domínio regulado — banco, jurídico, saúde, tributário — em que replay de auditoria é obrigação
- Corpus com marcador de fronteira natural (ano, categoria, jurisdição) para escopar o retrieval
- Folga de orçamento para uma fila de revisão interna, com humano no loop nas decisões de baixa confiança
Quando ele só atrapalha
- Chatbot de consumo open-domain — é exagero; o overhead de auditoria e citação destrói a latência da UX
- Throughput acima de 500 retrievals/s — aí troque a camada de retrieval por um banco vetorial dedicado
- Multi-tenant com dado cruzando tenants — o modelo de tabela única do pgvector vira bagunça rápido