RAG 100% local com Qwen 32B: o desenho, e os 12 achados que travaram o lançamento
Dá para fazer RAG (geração aumentada por recuperação) rodando 100% local, sem chamada para API de terceiro, e ainda assim ter cobrança funcionando? A resposta é sim, e o caminho está abaixo.
A parte mais útil do post, porém, não é o caminho. É a auditoria de segurança que rodei depois que tudo já funcionava, e os 12 achados que ela devolveu.
Se você veio atrás de “como construí um SaaS”, este não é o post. É “como construí a parte técnica de um SaaS, o que ela me ensinou, e onde ela travou”.
A stack
| Componente | Tecnologia |
|---|---|
| Backend | FastAPI (Python 3.11) |
| Frontend | Next.js 16 (App Router) |
| LLM | Qwen 3 Coder 32B (Ollama, local) |
| Embeddings | nomic-embed-text (Ollama) |
| Banco vetorial | ChromaDB (persistente) |
| Auth | JWT (HS256) + bcrypt |
| Pagamentos | Stripe Checkout |
| Armazenamento | SQLite (depois de migrar de JSON) |
A escolha do Qwen 32B não foi aleatória: testei Mistral, Llama 3 e DeepSeek antes. O Qwen 3 Coder se destacou por três razões, compreensão de português melhor do que eu esperava para o porte dele, capacidade de seguir instrução de contexto longo sem inventar, e cabe confortavelmente na memória unificada de um Mac Studio M2 Ultra com quantização de 4 bits via Ollama.
Como funciona o RAG local
- Upload do PDF → extração de texto com PyPDF, divisão em pedaços com
langchain-text-splitters(1000 caracteres, sobreposição de 200) - Embeddings → cada pedaço vira um vetor de 768 dimensões via
nomic-embed-textrodando local - Armazenamento → o ChromaDB persiste os vetores em disco
- Consulta → a pergunta é embedada e o ChromaDB busca por similaridade (os 5 pedaços mais próximos)
- Geração → os pedaços viram contexto no prompt do Qwen, que responde apenas com base no que foi recuperado
# A espinha dorsal do chat (versão simplificada)
@app.post("/chat")
async def chat(req: ChatRequest, user_id: str = Depends(get_current_user)):
# 1. Embed a pergunta
embedding = ollama.embeddings(model="nomic-embed-text", prompt=req.question)
# 2. Busca similaridade no ChromaDB
results = collection.query(
query_embeddings=[embedding["embedding"]],
n_results=5,
where={"doc_id": req.doc_id}
)
# 3. Monta contexto + pergunta
context = "\n\n".join(results["documents"][0])
system_prompt = f"Responda com base no contexto abaixo.\n\nCONTEXTO:\n{context}"
# 4. LLM responde
response = ollama.chat(
model="qwen32b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": req.question},
],
)
return {"answer": response["message"]["content"]}
Latência observada na minha máquina, em uso manual: alguns segundos por pergunta para contexto da ordem de milhares de tokens. Aceitável para ferramenta de análise, não para chat em tempo real. É observação de uso, não benchmark, não há harness, nem repetição, nem denominador, então não publico um número.
Stripe: cobrança que funciona sem produto no ar
Foram dois planos configurados, um limitado por quantidade de documento e pergunta, outro sem esses limites e com workspace compartilhado. Os preços ficam de fora deste post de propósito: preço de um produto que nunca foi vendido não é informação, é ficção de catálogo.
O fluxo é o padrão do Stripe: o frontend chama /create-checkout no backend, que
devolve uma URL de sessão; o usuário é redirecionado, paga, e o webhook atualiza
o plano no banco. O detalhe que deu trabalho foi o intervalo entre o retorno
síncrono e o webhook, o Stripe pode levar alguns segundos para disparar, então o
frontend fica consultando /auth/me até ver o plano novo.
@app.post("/create-checkout")
@limiter.limit("3/minute")
def create_checkout(req: CheckoutRequest, user_id: str = Depends(get_current_user)):
try:
session = stripe.checkout.Session.create(
customer_email=user_id,
payment_method_types=["card"],
line_items=[{"price": price_id, "quantity": 1}],
mode="subscription",
success_url="http://localhost:3004/success",
cancel_url="http://localhost:3004/pricing",
metadata={"user_id": user_id},
)
return {"url": session.url}
except stripe.error.StripeError as e:
logger.error(f"Stripe error: {e}")
raise HTTPException(400, "Erro ao processar pagamento. Tente novamente.")
Note as URLs de retorno: localhost. Elas são a assinatura literal do estado do
projeto, o checkout inteiro foi exercitado com o cartão de teste
4242 4242 4242 4242, contra um sucesso que só existe na minha máquina.
O que travou o projeto: a auditoria
Aqui está a parte que vale mais do que todo o resto.
O DocChat saiu de um protótipo funcional em dois dias. Funcionava. Em 2026-06-18 rodei uma auditoria de segurança completa sobre ele e o relatório listou 12 achados, dos quais três de severidade alta:
- Segredo do JWT regerado a cada restart, o
os.getenvcaía numos.urandom(32).hex()de fallback, então todo restart invalidava todos os tokens e deslogava todo mundo de uma vez. - CORS com curinga
*junto de credenciais, combinação que o browser rejeita, mas que proxy e cliente fora do browser aceitam. - Sem limite de tentativa na autenticação, ataque de dicionário na velocidade da rede, sem atraso, sem bloqueio.
E mais nove: bcrypt com 8 rodadas (o recomendado é 12 ou mais), upload sem limite
de tamanho nem validação de conteúdo, injeção de prompt com entrada crua indo
para o LLM, token JWT em localStorage, ausência de cifragem em repouso,
enumeração de usuário no cadastro, expiração de token longa demais, risco de
travessia de caminho no acesso a arquivo, e erro do Stripe vazando mensagem
interna para o cliente.
Só os três de prioridade zero foram corrigidos. Os outros nove continuam abertos no relatório, com o plano de remediação escrito e não executado. É por isso que este post não tem um “e aí subimos para produção”: subir com nove achados abertos seria a decisão errada, e não subir foi a certa.
A lição, que eu levei para todo projeto depois deste: funcionalidade não é prontidão. Duas coisas separam um protótipo que funciona de um produto que pode receber um estranho, e nenhuma das duas aparece na demo.
A migração que valeu a pena mesmo assim
O projeto original guardava cada usuário num arquivo JSON separado
(users/{email}.json). Funcionava para as contas de teste, e não escalaria para
nada:
- Sem concorrência, duas requisições simultâneas corrompiam o arquivo
- Sem atomicidade, uma queda no meio da escrita quebrava o dado
- Sem transação, sem rollback, sem backup consistente
Migrei para SQLite, com uma função de migração que leu os arquivos existentes e inseriu no banco, e um fallback para JSON em caso de falha. É a mudança mais barata do projeto e a que eu repetiria primeiro.
O que fica
Rodar um modelo de 32 bilhões de parâmetros localmente é perfeitamente viável para uma ferramenta de nicho, e o gargalo real não é o modelo, é o embedding e a busca vetorial a cada pergunta. A solução foi pré-calcular os embeddings do documento no momento do upload, e não no momento da pergunta.
O resto do aprendizado não é sobre RAG: é que a distância entre “funciona na minha máquina” e “pode receber usuário” se mede em achados de auditoria, não em features. O DocChat parou nessa distância, e este post existe para dizer isso em voz alta em vez de descrever um lançamento que não aconteceu.
Nota de contexto
O DocChat nunca foi lançado. Ele roda em containers na minha máquina, tem checkout de Stripe configurado em modo de teste, e nunca teve um usuário que não fosse eu. Não há cliente, não há receita, não há volume. O que existe é código que funciona, um relatório de auditoria com nove achados ainda abertos, e o conjunto de decisões técnicas acima.
Douglas Haruo, engenheiro de software há 15 anos, cinco deles em risco e fraude numa fintech de pagamentos. haruo.dev
Precisa de um projeto técnico sob medida?
Arquitetura, TypeScript, APIs e automação, do protótipo à produção. Quem responde o seu e-mail é quem escreve o código, e o prazo que eu prometo é o prazo que eu consigo cumprir.
Me manda uma mensagem →