· 9 min de leitura

Pinhão: o adapter Gemma 4 que não finge ser modelo novo

No rastro da polêmica Rio 3.5, documentei o Pinhão: LoRA público sobre Gemma 4, dataset sintético, vault local, eval pequena e limites explícitos.

Capa editorial do Pinhão: a frase 'Pinhão não finge ser modelo novo' ao lado de um fluxo Gemma 4 base, LoRA adapter e Obsidian RAG local.
O ponto do experimento não é parecer maior. É dizer exatamente o que ele é.

Começando pela polêmica

A semana do Rio 3.5 Open foi uma aula pública sobre uma coisa simples: em modelo aberto, procedência não é rodapé. É parte do artefato.

O caso virou barulho porque a release foi apresentada com ambição de foundation model brasileiro, e a comunidade foi atrás dos pesos. A issue da Nex acusou a publicação de ser uma mistura aproximada de 0.6 Nex-N2-Pro + 0.4 Qwen3.5-397B-A17B, com o modelo ainda se identificando como Nex quando removiam o prompt de sistema. Depois, a própria página do HF passou a dizer que o Rio 3.5 foi construído via merge de Nex e Qwen, seguido de distillation, e que houve upload incorreto da versão base mergeada em vez do modelo final. A nota do IplanRIO no X entrou nessa mesma tentativa de explicação pública.

Legalmente, tecnicamente, politicamente: dá para discutir muita coisa. Mas a lição operacional é menos glamourosa e mais útil:

Se você publica pesos, publique também a genealogia. O modelo base, o adapter, o dataset, o eval, as limitações e o que não foi feito.

Esse post é o meu anti-clímax dessa polêmica. Em vez de anunciar um “modelo novo”, eu treinei e publiquei o Pinhão Gemma 4 RAG Adapter: um LoRA/PEFT pequeno, sobre google/gemma-4-E4B-it, feito para melhorar formato de resposta num RAG local de Obsidian. Não é foundation model. Não é memória privada compactada. Não é soberania nacional em 20 steps. É só um adapter com recibo.

O que ele é

Pinhão é um adapter público para moldar comportamento de resposta em um fluxo de segundo cérebro:

  • responder curto em PT-BR;
  • citar fontes no formato [S#];
  • recusar exatamente com não tenho contexto suficiente;
  • não inventar IDs de fonte;
  • manter o conhecimento factual no RAG local, não nos pesos.

Essa última linha é a arquitetura inteira. O adapter não substitui o retrieval. Ele só tenta reduzir a quantidade de prompt necessário para manter o formato certo. A fonte de verdade continua sendo o Obsidian local, indexado em SQLite, com os trechos escolhidos pelo rag_ask.py.

Então a frase honesta é: Pinhão muda estilo e contrato de saída. Não adiciona memória privada.

O caminho de inferência que eu quis validar ficou assim:

Obsidian (local)
    -> SQLite + embeddings (local)
    -> rag_ask.py seleciona trechos [S1..Sn] (local)
    -> túnel SSH (só prompt montado)
    -> RunPod: Gemma 4 + LoRA Pinhão
    -> resposta PT-BR com [S#]

Vault, índice e embeddings não cruzam o túnel. Só o contexto já recortado vai para a GPU.

Como o experimento nasceu

A restrição inicial era melhor que qualquer arquitetura bonita: não treinar ainda, não baixar modelo pesado localmente, não subir vault/SQLite/embeddings para RunPod, e não fabricar dataset com cara de nota privada.

O primeiro plano era mecânico — smoke de recibos para extrair JSON. Funcionava, mas era inútil para o problema real. A virada foi cortar esse rumo: recibos ficaram só como smoke de pipeline; o dataset útil virou um contrato sintético de RAG.

ArquivoPapel
data/gemma4-receipts-train.jsonl200 linhas sintéticas para smoke mecânico
data/gemma4-receipts-eval.jsonl40 linhas holdout para o smoke
data/gemma4-rag-contract-train.jsonl160 linhas sintéticas para comportamento RAG
data/gemma4-rag-contract-eval.jsonl40 linhas holdout para eval
scripts/make_rag_contract_dataset.pygerador do contrato sintético
scripts/qlora_receipts_train.py --check-configvalida loader/config sem treinar

Detalhe que parece pequeno e não é: os caminhos do dataset viraram synthetic/projects/..., não wiki/projects/.... O primeiro rascunho parecia nota real falsa. Dataset sintético tem que parecer sintético, inclusive no nome.

Os scripts e JSONLs ficaram no laboratório privado; o model card público descreve o dataset, a eval e um sketch mínimo de carregamento do adapter.

O dataset que vale

O contrato RAG ensina tarefas pequenas, mas importantes:

  • responder só com suporte nos trechos dados;
  • citar apenas [S1], [S2], [S3] quando essas fontes existem;
  • recusar quando o contexto é fraco;
  • dizer quando há conflito entre fontes;
  • responder parcialmente quando só parte da pergunta tem suporte;
  • manter resposta curta em português.

Isso é menos sexy que “treinei um modelo”. Também é mais real. Fine-tuning útil quase nunca começa com conhecimento novo. Começa com comportamento repetitivo que você está cansado de enfiar no prompt.

Uma linha típica do JSONL (simplificada) parece isto:

{
  "messages": [
    {"role": "user", "content": "Contexto:\n[S1] O smoke validou health e POST.\n[S2] Nada do vault saiu do laptop.\n\nPergunta: o que o smoke validou?"},
    {"role": "assistant", "content": "Validou health e chat completions [S1]. Vault ficou local [S2]."}
  ]
}

Antes e depois

As métricas fazem mais sentido com um exemplo concreto. Mesmo tipo de pergunta, mesmo contexto sintético:

Contexto:
[S1] O serving smoke validou um servidor HTTP mínimo.
[S2] O mini-eval HTTP fez 5/5 no contrato estrito.

Pergunta: o que o smoke validou?

Base (padrão típico):
model
O smoke validou health check e o endpoint de chat.
thought
Resposta longa, sem [S1]/[S2] consistentes.

Adapter (servido, após cleanup):
Validou health e chat completions [S1, S2]. Nada do vault saiu da máquina [S1].

A base não inventou citação fantasma — isso já era 40/40 nos dois lados. O problema era formato: vazava trilha interna, resposta longa demais, citação inconsistente. Foi isso que o contrato de 160 linhas tentou comprimir nos pesos.

Prompt longo vs. LoRA

Na eval, base e adapter viram o mesmo contexto RAG montado no prompt. A base ficou em 18/40 no contrato de resposta; o adapter, em 37/40. Não rodei sweep exaustivo de system prompt — seria outro experimento — mas o gap persistiu mesmo depois de cleanup no serving.

Leitura pragmática: dá para empurrar parte do comportamento no prompt, sim. O LoRA comprou estabilidade de formato com menos texto de sistema repetido a cada chamada. Para um assistente que roda o dia inteiro em cima do vault, isso importa mais do que parece.

O treino

A primeira tentativa real foi no RunPod. A A5000 não estava disponível; a A40 de 48 GB resolveu. O treino em si levou ~119 s após cache; o pod ficou ligado mais tempo por download de pesos e setup — ordem de centavos no compute de treino, não de dezenas de dólares.

Escolhi google/gemma-4-E4B-it porque já era o candidato do laboratório para RAG local: instruction-tuned, cabível em 4-bit numa A40, e o wrapper multimodal exige cuidado — o LoRA precisa ficar só em model.language_model.*, longe das partes de visão.

ItemValor
Basegoogle/gemma-4-E4B-it
FormatoLoRA / PEFT (r=16, lora_alpha=32, dropout=0.05)
Alvomódulos model.language_model.* (q/k/v/o + MLP)
GPURunPod A40 48 GB
Datasetdata/gemma4-rag-contract-train.jsonl
Linhas160 sintéticas
Steps20
Runtime após setup/cache118,7 s
Train loss final1.341
Stacktorch 2.11.0+cu128, transformers 5.12.1, TRL 1.6.0, PEFT 0.19.1

Gemma 4 aqui teve suas pequenas pontas. transformers 4.57.3 deu problema de processor/tokenizer. O caminho estável foi AutoModelForMultimodalLM, fallback de AutoTokenizer para texto, e LoRA restrito ao language model para não brigar com o wrapper multimodal.

O adapter subiu primeiro privado no Hugging Face. Depois virou público com model card e gráfico de eval: brunogonzaga/pinhao-gemma4-rag-adapter.

A parte que impede autoengano

Treinar é fácil de romantizar. Eval é onde a história perde maquiagem.

A avaliação comparou base vs adapter em 40 linhas sintéticas holdout. O resultado bruto:

Gráfico de barras comparando Gemma 4 base e adapter Pinhão em 40 linhas sintéticas: answer contract 18/40 contra 37/40, exact refusal 33/40 contra 40/40, expected citations 27/40 contra 37/40, served strict 18/40 contra 37/40.
O adapter melhorou comportamento de resposta. Não virou oráculo.
MétricaBaseAdapter
Answer contract18/4037/40
Raw strict14/4020/40
Served strict com cleanup18/4037/40
Recusa exata33/4040/40
Citações esperadas27/4037/40
Citações inventadas0/400/40

Leitura honesta: o adapter melhora o contrato de resposta, principalmente recusa e citação. Mas a geração crua ainda vazava marcadores como model, thought e fragmentos finais de recusa — daí raw strict ficar em 20/40 mesmo com adapter.

Os 3 que sobraram

Dos 40 casos holdout, 3 ainda falharam no served strict com adapter. Concentraram-se nas tarefas mais difíceis do contrato: resposta parcial (pergunta composta com suporte só em metade) e conflito entre fontes (duas linhas [S#] com informação inconsistente). São exatamente os buckets com menos exemplos no treino de 160 linhas. Não é falha misteriosa — é amostra pequena encontrando o limite.

Cleanup no serving

Separar raw de served foi deliberado. Treino mede o adapter; produção mede o que o usuário vê depois de cortar lixo de geração.

O servidor HTTP mínimo aplicou três coisas:

  1. Stop markers — cortar em model, thought ou recusa duplicada no fim;
  2. Token cap conservador — impedir resposta que recomeça o contrato inteiro;
  3. Strip de whitespace — normalizar o que sobrou antes de devolver JSON OpenAI.

Esqueleto do cleanup (simplificado):

STOP_MARKERS = ("model", "thought")

def serve_cleanup(text: str) -> str:
    lowered = text.lower()
    cut_at = len(text)
    for marker in STOP_MARKERS:
        idx = lowered.find(marker)
        if idx != -1:
            cut_at = min(cut_at, idx)
    return text[:cut_at].strip()

Com isso, served strict subiu de 20/40 (raw adapter) para 37/40. Bom o suficiente para smoke. Não bom o suficiente para fingir produção sem eval maior.

O smoke de verdade

A parte que me interessava não era “consigo chamar o adapter?”. Era o caminho completo abaixo — local até o retrieval, remoto só na geração:

rag_ask.py local -> retrieval local -> túnel SSH -> RunPod adapter server
Terminal rodando rag_ask.py com OPENAI_BASE_URL local, OPENAI_MODEL pinhao-gemma4-rag e resposta em PT-BR com citações S1 e S2 sobre o serving smoke do adapter Pinhão.
O print que vale entrar: o caminho real funcionando, com RAG local e resposta citada.

Depois disso, criei um servidor HTTP mínimo compatível com OpenAI e testei o caminho completo:

  • /health respondeu ok;
  • POST /v1/chat/completions respondeu limpo;
  • mini-eval HTTP fez 5/5 no contrato estrito;
  • rag_ask.py --provider openai rodou localmente;
  • o túnel SSH levou só o prompt com contexto selecionado;
  • vault, SQLite, embeddings e notas reais ficaram locais;
  • o servidor foi parado depois do smoke.

Esse é o ponto de privacidade que eu queria preservar desde o começo. Eu posso usar GPU remota para geração. Não preciso mandar meu segundo cérebro inteiro para ela.

Por que isso entra na polêmica

O problema de releases de IA não é usar base alheia. O ecossistema inteiro é empilhamento: base model, tokenizer, dataset público, dataset privado, SFT, DPO, merge, LoRA, quantização, serving hack. O problema é vender a pilha como se fosse uma pedra única caída do céu.

Pinhão é pequeno, então a régua é fácil de cumprir. Mesmo assim, a régua é a mesma:

PerguntaResposta do Pinhão
Qual é o modelo base?google/gemma-4-E4B-it
É modelo completo?Não. É adapter LoRA/PEFT
Que dados usou?160 linhas sintéticas de contrato RAG
Usou vault real?Não
O que melhora?Formato, recusa, citação, brevidade
O que não melhora?Conhecimento factual sem retrieval
Tem eval?Sim, holdout sintético de 40 linhas
Tem limitação explícita?Sim: pequeno, sintético, precisa cleanup, não produção

Essa tabela é chata. Por isso ela importa.

Checklist anti-polêmica

Checklist genérico, com três itens que este experimento forçou na prática:

  1. Nomeie o base model — e linke o artefato, não só o nome.
  2. Diga se é full weights, merge, adapter, quantização ou serving wrapper.
  3. Separe métrica raw de métrica served; cleanup faz parte do produto.
  4. Publique o dataset ou descreva por que não pode (Pinhão: sintético, paths synthetic/).
  5. Liste explicitamente o que não entrou (vault, SQLite, embeddings, PII).
  6. Tenha holdout, mesmo pequeno — e diga onde ainda falha.
  7. Mostre métrica que ainda é feia depois do treino (raw strict do adapter: 20/40, apesar de 37/40 served).
  8. Desenhe o caminho de inferência real, incluindo o que fica local.
  9. Documente privacidade como arquitetura (túnel leva prompt, não vault).
  10. Escreva limitações antes de escrever marketing.

No Pinhão, o resultado mais importante não é 37/40. É a linha pública: adapter, dado sintético, formata resposta RAG; não contém meu vault.

Fechando

Eu gosto do nome Pinhão porque soa local sem performar grandiosidade. É semente, não floresta.

A polêmica do Rio 3.5 não deveria ensinar que ninguém pode remixar modelo. Deveria ensinar o oposto: pode remixar, adaptar, destilar, publicar — só não esconda a receita atrás do nome.

Pinhão é a minha versão pequena dessa regra: menos pose, mais procedência.


Artefato público: brunogonzaga/pinhao-gemma4-rag-adapter, com model card, gráfico de eval, descrição do dataset, sketch de carregamento, limitações e nota de privacidade. Scripts e JSONLs ficaram no laboratório privado; a procedência documentada é pública.