Construí uma IA interna que responde sobre os dados do sistema

Desenvolver sistemas com LLM é assunto novo para mim. Essa foi minha primeira experiência construindo algo de verdade em cima de um modelo de linguagem. Boa parte do que está aqui eu aprendi errando no caminho.

O que eu queria era dar acesso fácil à informação para quem não é técnico. O dado está lá, no banco, e mesmo assim continua fora do alcance de quase todo mundo: quem precisa dele depende de saber SQL, ou de um relatório que alguém montou antes, pensando em outra pergunta. A ideia era encurtar esse caminho — a pessoa pergunta e recebe a informação de volta.

A resposta óbvia seria mandar tudo para uma API do ChatGPT, Claude, Groq ou afins, mas aqui os dados eram sensíveis e não podiam sair para a internet. Sobrou um modelo de 3 bilhões de parâmetros rodando em CPU, com 8 GB de RAM para se virar.

A arquitetura

O sistema é um text-to-SQL com RAG. Nada exótico, mas vale abrir cada peça:

  • Modelo: Granite 4.1 3B, servido localmente via Ollama. Roda em CPU, sem GPU.
  • Orquestração: um grafo em LangGraph, com cada etapa como nó explícito.
  • Recuperação: híbrida — BM25 mais busca por embedding com nomic-embed-text.
  • Banco: MariaDB — uma cópia do banco real. Todo o desenvolvimento e a bateria de testes rodaram em cima dela, nunca na produção.

E o caminho de uma pergunta até a resposta:

pergunta
   ↓
retriever  →  seleciona ~5 tabelas relevantes + ~5 exemplos parecidos
   ↓
prompt     →  schema recortado + few-shots + pergunta
   ↓
LLM        →  gera o SQL
   ↓
execução   →  roda a query, com guarda de segurança
   ↓
LLM        →  transforma o resultado em frase

Dois nós usam o modelo, com objetivos bem diferentes: um escreve a query SQL, o outro escreve a resposta pro usuário. Separei os prompts dos dois, o que deixou cada um mais específico.

Por que não dá para simplesmente mostrar o banco

Minha primeira tentativa foi: colocar o schema inteiro no prompt e pedir o SQL.

Funciona nas demonstrações, que usam banco de brinquedo. Num banco real, com centenas de tabelas, você estoura o contexto antes de chegar na pergunta. E mesmo que coubesse, o modelo se perderia: informação irrelevante não é neutra, ela compete pela atenção.

Daí o retriever. Antes de o modelo ver qualquer coisa, um passo escolhe as poucas tabelas que provavelmente importam e alguns exemplos de perguntas semelhantes já resolvidas. Só esse recorte entra no prompt.

Usei os dois métodos de busca somados. BM25 é busca lexical clássica: acerta quando a pessoa usa exatamente a palavra que está no nome da coluna. O embedding acerta quando ela descreve a mesma coisa com outras palavras. Cada um cobre o ponto cego do outro, e a combinação acertou mais que qualquer um dos dois sozinho.

O modelo pequeno

Eu imaginava que um modelo de 3B fosse um modelo grande com menos qualidade. Na prática ele se comporta de outro jeito.

O modelo grande generaliza a partir de uma regra abstrata. O pequeno copia o exemplo. Se um dos few-shots tem um JOIN desnecessário, ele reproduz aquele JOIN desnecessário na próxima pergunta parecida. Passei um tempo caçando um erro que era exatamente isso — o modelo repetindo uma bobagem que eu mesmo tinha deixado no exemplo.

Outra coisa que mexi foi a forma dos prompts. Eu escrevia assim:

NUNCA recalcule os valores. NÃO acrescente interpretações.

A IBM documenta que esse enquadramento negativo degrada modelos pequenos: dizer “não faça X” coloca X no contexto, e o modelo tende a fixar no termo proibido. Reescrevi todos os prompts do sistema em forma afirmativa:

Mantenha o valor exatamente como veio. Use apenas os termos da pergunta.

É a mesma restrição escrita ao contrário. Foi a mudança que mais rendeu até agora.

A bateria de avaliação

No começo eu mexia num prompt, fazia três perguntas, achava que tinha melhorado e seguia em frente. Depois montei uma bateria de 72 perguntas cobrindo os tipos de consulta que importavam, com verificação objetiva — comparando o conjunto de resultados retornado, não o texto da resposta.

A primeira coisa que descobri foi desconfortável: rodar a mesma bateria duas vezes, sem mudar absolutamente nada, já dava diferença. O piso de ruído ficava em torno de um acerto a cada dez execuções.

Isso me obrigou a estabelecer uma régua: mudança só conta se mexe em pelo menos 5% do total ou desloca uma categoria inteira de perguntas. Abaixo disso é ruído se disfarçando de progresso. Saí de 46 para 49 acertos em 72.

Também passei a rodar um experimento por vez. É lento e às vezes irritante, principalmente quando você tem três ideias na cabeça e quer testar todas de uma vez.

O susto com as permissões

O código sempre teve uma guarda: só executa query que começa com SELECT. Parecia suficiente.

Quando abri para outras pessoas testarem, alguém digitou “apague todas as tabelas” e o modelo gerou um DELETE alegremente. A guarda barrou. Mas fui checar por curiosidade e o usuário do banco tinha GRANT ALL PRIVILEGES, o que quer dizer que aquela linha de validação no código era a única coisa segurando a onda.

Estava tudo numa cópia, então o risco real era baixo. Ainda assim, um sistema desses existe para um dia apontar para o dado que importa.

Corrigi no banco: o usuário da aplicação agora só tem SELECT, que é tudo que ela precisa — o histórico de conversa vai para arquivo, não para tabela. A guarda no código continua lá.

O que estou usando para aprender

Como falei lá em cima, isso é território novo para mim. Deixo aqui o que tenho usado:

Onde está agora

O sistema roda inteiro dentro do servidor, sem mandar nada para a nuvem de ninguém, e responde a perguntas que antes dependiam de alguém que soubesse SQL.

Ainda erra bastante: 49 de 72 é o número real, não um arredondamento para cima. A próxima coisa na fila é enxugar o retriever — hoje ele traz um monte de tabela que não aparece na query final, e isso só atrapalha o BM25.