iapythonnextjsstriperag

RAG 100% local com Qwen 32B: o desenho, e os 12 achados que travaram o lançamento

Douglas Haruo 7 minutes 14/07/2026

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

ComponenteTecnologia
BackendFastAPI (Python 3.11)
FrontendNext.js 16 (App Router)
LLMQwen 3 Coder 32B (Ollama, local)
Embeddingsnomic-embed-text (Ollama)
Banco vetorialChromaDB (persistente)
AuthJWT (HS256) + bcrypt
PagamentosStripe Checkout
ArmazenamentoSQLite (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

  1. Upload do PDF → extração de texto com PyPDF, divisão em pedaços com langchain-text-splitters (1000 caracteres, sobreposição de 200)
  2. Embeddings → cada pedaço vira um vetor de 768 dimensões via nomic-embed-text rodando local
  3. Armazenamento → o ChromaDB persiste os vetores em disco
  4. Consulta → a pergunta é embedada e o ChromaDB busca por similaridade (os 5 pedaços mais próximos)
  5. 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.getenv caía num os.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 →