DocChat — Como construímos um SaaS de RAG com IA local e Stripe
Tempo estimado de leitura: 7 minutos
No começo de 2026, a gente tinha um problema concreto: clientes chegavam com PDFs — contratos, relatórios, atas — e pediam “me responde o que está aqui”. Dava pra abrir um por um, mas escalar isso pra dezenas de documentos simultâneos não era viável manualmente.
A resposta natural era RAG (Retrieval-Augmented Generation). Mas a pergunta seguinte era: dá pra fazer isso rodando 100% local, sem depender de API externa, e ainda cobrar por meio de um checkout Stripe?
Nasceu o DocChat — um SaaS de chat com documentos onde você faz upload de um PDF, faz perguntas em linguagem natural, e o modelo responde com base no conteúdo do documento. Tudo rodando localmente num Mac Studio M2 Ultra.
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) |
| Vector DB | ChromaDB (persistente) |
| Auth | JWT (HS256) + bcrypt |
| Pagamentos | Stripe Checkout |
| Armazenamento | SQLite (após migração de JSON) |
A escolha do Qwen 32B não foi aleatória. Testamos Mistral, Llama 3 e DeepSeek antes. O Qwen 3 Coder se destacou por três razões: excelente compreensão de português (surpreendentemente melhor que modelos do mesmo porte), capacidade de seguir instruções de contexto longo sem alucinar, e rodagem confortável em 64 GB unificados do M2 Ultra com 4-bit quantization via Ollama.
Como funciona o RAG local
O fluxo é direto:
- Upload do PDF → Extração de texto com PyPDF, chunking com
langchain-text-splitters(tamanho 1000 caracteres, overlap 200) - Embeddings → Cada chunk vira um vetor 768-d via
nomic-embed-textrodando local - Armazenamento → ChromaDB persiste os vetores em disco (
chroma_db/) - Query → Quando o usuário pergunta algo, a pergunta é embedada e o ChromaDB faz busca por similaridade (top-5 chunks mais relevantes)
- Geração → Os chunks viram contexto no prompt do Qwen, que responde apenas com base no conteúdo 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"]}
O latency médio do M2 Ultra: ~4 segundos por pergunta para chunks de até 5000 tokens de contexto. Aceitável para uma ferramenta de análise, não para chat em tempo real.
Stripe integrado com checkout funcional
A monetização veio com Stripe Checkout — dois planos:
- Pro (R$29/mês): Upload de 50 documentos, 500 perguntas/mês
- Enterprise (R$99/mês): Upload ilimitado, perguntas ilimitadas, workspaces multiusuário
O fluxo é padrão Stripe: frontend chama /create-checkout no backend, que devolve uma URL de sessão. O usuário é redirecionado, paga, e o webhook Stripe atualiza o plano no banco. O detalhe que deu trabalho foi gerenciar o estado entre o webhook e a resposta síncrona — Stripe pode levar alguns segundos para disparar o webhook, então o frontend faz polling no endpoint /auth/me até ver o plano atualizado.
@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.")
Testamos com cartão 4242 4242 4242 4242 — checkout funcional, webhook recebendo eventos, upgrade de plano automático.
Os desafios
1. RAG local head-to-head com GPU cloud
O principal desafio técnico foi performance. Qwen 32B em CPU (M2 Ultra é unificado, mas ainda assim não é GPU dedicada) dá conta, mas o gargalo real é o embedding + busca vetorial para cada pergunta. A solução foi cachear embeddings de perguntas repetidas e pré-calcular embeddings de documentos no momento do upload, não no momento da pergunta.
2. Segurança — 11 vulnerabilidades corrigidas
Fizemos uma auditoria de segurança completa e encontramos 11 vulnerabilidades. As mais críticas:
- JWT Secret regenerado a cada restart — Sem arquivo
.jwt_secretpersistente, todo restart invalidava todos os tokens. Todos os usuários logged out de uma vez. - CORS com wildcard
*+ credentials — Combinação inválida que browsers rejeitam, mas proxies e clients não-browser aceitam. Qualquer site malicioso podia fazer requisições autenticadas. - Sem rate limiting em auth — Ataque de brute-force a 1000 requests/minuto, sem bloqueio.
- bcrypt com apenas 8 rounds — OWASP recomenda 12+. 8 rounds em hardware moderno quebra senhas simples em minutos.
- Upload sem limite de tamanho — Um PDF de 100 GB encheria o disco sem impedimento.
- Token JWT em localStorage — JavaScript de qualquer script third-party na página podia ler o token.
Todas foram corrigidas antes do primeiro deploy. A auditoria completa está no repositório e virou checklist para projetos seguintes.
3. Migração de JSON file-based para SQLite
O projeto original armazenava cada usuário em um arquivo JSON separado (users/{email}.json). Funcionava para 19 usuários, mas não escalava:
- Sem concorrência — duas requests simultâneas corrompiam o arquivo
- Sem atomicidade — crash no meio da escrita quebrava o dado
- Sem transações — sem rollback, sem backup consistente
Migramos para SQLite com fallback automático para JSON em caso de falha, e uma função de migração que leu os 19 arquivos existentes e inseriu no banco.
Resultados
- Checkout Stripe funcional — Upgrade de plano, downgrade, cancelamento, tudo testado
- 23 usuários no banco (19 da fase JSON + 4 novos durante testes)
- RAG local rodando — Qwen 32B responde com base em documentos de até 100 páginas
- 11 VULNs corrigidas — auditoria completa, risco residual baixo
- Multi-workspace — usuários Enterprise podem criar workspaces com times e coleções de documentos compartilhadas
O que aprendemos
Rodar um modelo 32B localmente é viável para um SaaS B2B de nicho, mas não escala para milhares de usuários simultâneos sem uma GPU dedicada. Para o volume do DocChat (dezenas de usuários, perguntas esporádicas), o M2 Ultra segura bem — CPU fica em ~40% durante inferência, RAM em ~35 GB dos 64 GB disponíveis.
A lição maior foi sobre segurança em SaaS feito às pressas. O DocChat começou como um protótipo funcional em dois dias. Funcionava, mas as 11 vulnerabilidades mostravam que funcionalidade não é o mesmo que prontidão para produção. A auditoria de segurança foi o momento de maior retorno sobre investimento de tempo em todo o projeto.
Experimente
O DocChat está rodando em http://localhost:3004 no ambiente de desenvolvimento. Faça upload de um PDF, faça perguntas, e veja o RAG local em ação. O checkout Stripe está funcional com cartão de teste 4242 4242 4242 4242.
Stack completa: FastAPI + Qwen 3 Coder 32B (Ollama) + ChromaDB + Next.js 16 + Stripe + SQLite. Código disponível em ~/work/haruo/web/docchat/.
Sobre a Haruo: Haruo é uma software house full-stack especializada em produtos web, APIs e automação com IA. haruo.dev
Quer construir um SaaS de IA com RAG local? Fale com a Haruo →
Vamos construir algo incrível juntos?
Na Haruo, transformamos ideias em produtos digitais que geram resultados reais. Conte com nossa equipe de engenharia e IA.
Falar com a Haruo →