Como classificar texto com embeddings em vez de LLM dentro do agente (e quando o LLM ainda é necessário)

Ilustração de pontos de texto agrupados por similaridade de cosseno representando classificação por embeddings
Resposta rápida

Quando o agente só precisa escolher entre rótulos que já existem (fila do ticket, intenção, duplicata), tu pode gerar embeddings de exemplos rotulados e comparar a mensagem nova por similaridade de cosseno, sem pedir a decisão a um LLM generalista. O LLM continua redigindo a resposta e resolvendo os casos que ficam abaixo do limiar. O limiar não tem valor universal: ele precisa ser calibrado no seu próprio conjunto rotulado, e a troca só vale depois de um teste comparativo com os seus dados

Fala aí, beleza? Se o teu agente faz uma chamada inteira de LLM só pra decidir se a mensagem é do financeiro ou do suporte, esse post é pra você 🙂

A ideia aqui é a mesma do padrão quem decide não é quem redige: no Jev a decisão vai para um modelo de decisão tipada, e aqui ela vai para uma busca vetorial. São alternativas diferentes para o mesmo problema

E não é gambiarra: a documentação de embeddings da OpenAI lista a classificação, em que o texto recebe o rótulo mais parecido, como um dos usos comuns

O recorte é este: rótulos conhecidos, exemplos reais já rotulados e um LLM que continua no fluxo só onde ele faz falta

Em que pontos do agente o embedding entra no lugar do LLM

O embedding entra em toda etapa que responde à pergunta: com qual dos rótulos que eu já tenho este texto mais se parece?

Na prática, isso aparece em três lugares do fluxo

Triagem de tickets: qual fila recebe?

Entra o texto do ticket, sai o nome da fila

O ticket vira um vetor, é comparado com os vetores dos tickets antigos já rotulados, e os vizinhos mais próximos (k-NN, os k exemplos mais parecidos) votam na fila

Quem redige depois é o LLM, ou o humano da fila, já sabendo o contexto

Roteamento de intenção: qual ferramenta ou subagente?

Entra a mensagem do usuário, sai a intenção, e a intenção aponta para uma ferramenta ou subagente

É o mesmo mecanismo da triagem, só que o rótulo é um destino dentro do agente (buscar pedido, cancelar assinatura, falar com humano e por aí vai)

Quem redige é o subagente escolhido, que recebe só a mensagem que é da conta dele

Detecção de duplicata: isso já apareceu antes?

Entra o item novo, sai sim ou não (e, se sim, qual item parecido já existe)

Aqui não tem rótulo: o item novo é comparado com os itens existentes, e se o cosseno passar do limiar ele é marcado como repetido

Quem redige é o LLM, se for preciso avisar o usuário ou juntar os dois itens

O que você precisa antes de começar

São três coisas, e nenhuma delas é PC da Nasa haha

  • Uma chave de API de um fornecedor de embeddings: OpenAI, Gemini API ou Voyage AI
  • Um conjunto de textos reais já rotulados pela equipe (tickets antigos, mensagens com a intenção marcada, pares de duplicatas confirmadas)
  • Um lote separado, também rotulado, que fica de fora dos exemplos e serve só para teste e calibração

Se liga num detalhe: a Anthropic não tem modelo de embedding próprio

A documentação do Claude sobre embeddings aponta a Voyage AI como opção e recomenda avaliar vários fornecedores

Então, se o teu agente roda com Claude, a parte vetorial vem de outro lugar, e tudo bem

Modelos de embedding para classificação: dimensões, preço e parâmetro de tarefa

A tabela abaixo junta só o que está confirmado na documentação de cada fornecedor (preços de entrada de texto, em dólar, por 1 milhão de tokens)

Modelo Fornecedor Dimensões Preço por 1M tokens Parâmetro de tarefa e observações
text-embedding-3-small OpenAI 1536 (padrão) US$ 0,02 dimensions reduz o vetor
text-embedding-3-large OpenAI 3072 (padrão) US$ 0,13 dimensions reduz o vetor
gemini-embedding-001 Google (Gemini API) 3072 (padrão), recomendadas 3072, 1536 ou 768 US$ 0,15 online, US$ 0,075 via Batch API, com camada gratuita task_type=CLASSIFICATION, output_dimensionality reduz o vetor
gemini-embedding-2 Google (Gemini API e Vertex AI) consulte a documentação US$ 0,20 online, US$ 0,10 em batch GA, multimodal, prefixo de tarefa no próprio texto
voyage-4-large Voyage AI 2048, 1024 (padrão), 512 ou 256 US$ 0,12 200 milhões de tokens grátis por conta, input_type
voyage-4 Voyage AI 2048, 1024 (padrão), 512 ou 256 US$ 0,06 200 milhões de tokens grátis por conta, input_type
voyage-4-lite Voyage AI 2048, 1024 (padrão), 512 ou 256 US$ 0,02 200 milhões de tokens grátis por conta, input_type

Algumas observações que a tabela não mostra:

  • O dimensions da OpenAI só funciona na família text-embedding-3 e em modelos posteriores
  • O task_type=CLASSIFICATION do gemini-embedding-001 gera embeddings otimizados para classificar texto em rótulos predefinidos, e a própria Google cita análise de sentimento e detecção de spam como exemplos
  • O gemini-embedding-2 é multimodal: texto, imagem, vídeo, áudio e documentos ficam no mesmo espaço vetorial
  • A Voyage indica o voyage-4-large para a melhor qualidade, o voyage-4-lite para a menor latência e o menor custo, e o voyage-4 como meio-termo
  • A família Voyage 4 foi lançada em 15/01/2026 com espaço de embedding compartilhado entre os modelos

O que muda em custo, latência e manutenção?

No custo, a conta muda de lugar: os exemplos rotulados viram vetores uma vez e ficam guardados, e cada mensagem nova custa só os tokens dela no preço da tabela

O preço do LLM que tu usaria no lugar depende do modelo e do teu contrato, então a comparação certa é com a tua fatura real, não com número de internet

Na latência, uma chamada de embedding devolve um vetor, sem gerar texto, e a comparação com os exemplos é uma conta local

Isso tende a ser mais leve que gerar uma resposta, mas meça no teu ambiente antes de prometer qualquer coisa pro time

Na manutenção, o trabalho deixa de ser ajustar prompt e passa a ser cuidar dos exemplos: rótulo novo ganha exemplos novos, exemplo errado sai da base

Passo a passo: classificar texto com embeddings e limiar de cosseno

Bora ver na prática? O código abaixo é um exemplo didático, independente de fornecedor: a função embed() é um ponto de troca onde entra a chamada do SDK que você escolheu

  1. Monte os exemplos rotulados

Pegue poucos exemplos por rótulo, mas reais e variados: mensagem curta, mensagem longa, com erro de digitação, com gíria, tudo que o teu usuário escreve de verdade

Separe desde já o lote de validação, que não entra na base de exemplos

Erro comum deste passo: escrever só frases inventadas e bonitas (tipo ‘Gostaria de solicitar o reembolso do meu pedido’). O usuário real escreve ‘cade meu dinheiro???’ e a base não reconhece kkk

  1. Gere os embeddings com o parâmetro de tarefa certo

Cada fornecedor tem o seu jeito de dizer qual é a tarefa:

  • gemini-embedding-001: task_type=CLASSIFICATION
  • gemini-embedding-2: a Google recomenda colocar a tarefa no próprio texto, com o prefixo task: classification | query: {conteúdo}
  • Voyage: input_type aceita null (padrão), query ou document. Com query ou document, a Voyage acrescenta um prompt voltado para busca, e os vetores gerados com e sem input_type são compatíveis entre si
  • OpenAI: não tem parâmetro de tarefa, o texto vai como está

No gemini-embedding-2, o prefixo vai em TODOS os textos, exemplos e consulta:

def com_prefixo(texto):
    return f'task: classification | query: {texto}'

exemplos_prefixados = [com_prefixo(t) for t in textos_exemplo]
consulta_prefixada = com_prefixo(mensagem_nova)

Erro comum deste passo: aplicar o prefixo só na consulta e esquecer dos exemplos. A documentação pede o mesmo formato nos dois lados, de forma consistente. Tome cuidado!

  1. Calcule a similaridade e escolha entre k-NN e rótulo mais parecido

E o que é similaridade de cosseno? É o quanto dois vetores apontam pra mesma direção: quanto mais perto de 1, mais parecidos os textos

Os embeddings da OpenAI já vêm com norma 1 (inclusive quando você usa dimensions), então o cosseno é só o produto escalar. Para os outros fornecedores, normalizar antes não custa nada e deixa a conta igual pra todo mundo

Aí você escolhe a regra: pegar o rótulo do exemplo mais parecido, ou deixar os k vizinhos mais próximos votarem (k-NN), que segura melhor um exemplo esquisito na base

import numpy as np

# Exemplo didático: troque embed() pela chamada do SDK do seu fornecedor
def embed(textos):
    raise NotImplementedError('chame a API de embeddings aqui')

def normalizar(m):
    m = np.asarray(m, dtype=float)
    return m / np.linalg.norm(m, axis=1, keepdims=True)

# textos reais do seu histórico, já rotulados pela equipe
exemplos = [
    ('fui cobrado duas vezes no cartao', 'financeiro'),
    ('nao consigo entrar na minha conta', 'acesso'),
    # ...
]
textos_exemplo, rotulos = zip(*exemplos)
E = normalizar(embed(list(textos_exemplo)))  # gera uma vez e guarda

def classificar(mensagem, k=5, limiar=None):
    q = normalizar(embed([str(mensagem)]))[0]
    sims = E @ q                      # cosseno, porque tudo tem norma 1
    top = np.argsort(sims)[::-1][:k]
    votos = {}
    for i in top:
        r, s = rotulos[int(i)], float(sims[int(i)])
        votos.update({r: votos.get(r, 0) + s})
    rotulo = max(votos, key=votos.get)
    melhor = sims[top[0]]
    if limiar is not None and melhor < limiar:
        return None, melhor           # None = manda pro LLM
    return rotulo, melhor

Com k=1 você tem o rótulo mais parecido puro. Para duplicata, a lógica é a mesma sem votação: compara o item novo com os existentes e olha se o maior cosseno passa do limiar

  1. Escolha o limiar no lote de validação

O limiar é a nota mínima para o embedding decidir sozinho. Abaixo dele, a mensagem vai pro LLM

Varra vários valores dentro das similaridades que aparecem no teu próprio lote e veja, para cada um, quanto acerta e quanto manda pro fallback:

def avaliar(validacao, limiar):
    aceitos, acertos, fallback = 0, 0, 0
    for texto, esperado in validacao:
        rotulo, _ = classificar(texto, limiar=limiar)
        if rotulo is None:
            fallback += 1
        else:
            aceitos += 1
            acertos += int(rotulo == esperado)
    return acertos / max(aceitos, 1), fallback / len(validacao)

melhores = [classificar(t)[1] for t, _ in validacao]
for limiar in np.quantile(melhores, np.linspace(0, 1, 11)):
    print(round(float(limiar), 3), avaliar(validacao, limiar))

O resultado esperado é uma tabelinha: limiar, taxa de acerto dos casos aceitos e fração enviada ao LLM. Você escolhe o ponto em que o acerto te deixa tranquilo e o fallback cabe no orçamento

Erro comum deste passo: copiar o limiar de outro projeto ou de um tutorial. Nenhum fornecedor publica um limiar universal, e o valor muda com o modelo, a dimensão e os teus dados

  1. Faça o teste comparativo mínimo

Passa o MESMO lote rotulado pelos dois caminhos: o classificador por embedding (com o limiar escolhido) e a chamada de LLM que você usa hoje

Compare três coisas:

  • acertos de cada caminho
  • quantos casos o embedding mandou pro fallback
  • custo: tokens do lote vezes o preço da tabela, contra o que o LLM custou no mesmo lote

Checagem final: monte a matriz de acertos por rótulo (esperado na linha, previsto na coluna) no teu próprio conjunto

from collections import Counter

matriz = Counter()
for texto, esperado in validacao:
    previsto, _ = classificar(texto, limiar=limiar_escolhido)
    matriz[(esperado, previsto or 'LLM')] += 1

for (esperado, previsto), n in sorted(matriz.items()):
    print(esperado, '->', previsto, n)

Se um rótulo específico vive caindo no fallback ou sendo confundido com outro, é ali que faltam exemplos. Simples assim 😀

Onde a classificação por embedding falha

O embedding não é bala de prata, e esses são os três lugares onde ele costuma escorregar

Caso ambíguo: dois rótulos quase empatados

Sintoma: a mensagem fica entre dois rótulos e a resposta muda com qualquer detalhe (‘quero cancelar porque cobraram errado’ é cancelamento ou financeiro?)

Causa: falta margem entre o primeiro e o segundo rótulo mais parecidos

Solução: limiar mais fallback: o que não passa da nota mínima vai pro LLM decidir

Como prevenir: além do limiar, olhe a diferença entre o primeiro e o segundo colocado no lote de validação, e trate empate apertado como caso de fallback

Rótulo novo ou redefinido

Sintoma: depois que o time criou uma fila nova ou mudou o que cabe em cada fila, os erros aumentam

Causa: os exemplos ficaram desatualizados e ainda descrevem o mundo antigo

Solução: atualizar os exemplos e reembedar a base

Ao trocar de modelo ou de dimensão, refaça TODOS os vetores. Misturar vetores de origens diferentes só é seguro onde a documentação garante compatibilidade, como no espaço compartilhado da família Voyage 4 e nos vetores Voyage gerados com e sem input_type

Como prevenir: guarde junto de cada vetor o modelo e a dimensão que o geraram, e trate mudança de rótulo como mudança de código: com revisão e novo teste no lote de validação

Necessidade de justificativa

Sintoma: alguém pergunta ‘por que esse ticket foi pra essa fila?’ e tudo que você tem é um número

Causa: o cosseno mede parecença, não explica a decisão

Solução: quando a justificativa faz parte do requisito, a decisão volta para o LLM ou para um modelo de decisão tipada, e a comparação de Jev contra LLM de chat ajuda a escolher entre os dois

Como prevenir: mapeie antes quais etapas precisam de explicação (auditoria, cliente contestando, compliance) e não coloque embedding puro nelas

Quando o LLM continua necessário (e quando um modelo menor basta)

O LLM não sai do agente, ele só para de fazer o trabalho de triagem

Ele continua redigindo a resposta, justificando decisões quando alguém pede e resolvendo os casos que ficaram abaixo do limiar

E nem toda tarefa que sobra pede o modelo mais caro: vale testar modelos menores nas etapas mais simples, sempre com o mesmo lote de validação como régua

Se tu quer conhecer mais do canal, tem um vídeo do Hora de Codar sobre o FreeLLMAPI, que fala de acesso a APIs de LLM:

Conteúdo complementar do Hora de Codar para conhecer o canal; não é uma demonstração específica desta notícia ou versão.

Próximo passo: rodar o teste no seu próprio dataset

Escolhe UMA etapa do agente, a triagem por exemplo, e separa um lote rotulado só dela

Roda a comparação do passo 5, olha a matriz de acertos e só troca a chamada se os números do teu conjunto justificarem

Se no meio do caminho tu perceber que aquela etapa precisa de decisão tipada com justificativa, o caminho é o Jev, e o catálogo de decisões do Jev mostra como documentar cada chamada quando o time cresce

Até o próximo post! 🙂

Perguntas frequentes

Preciso de um banco de dados vetorial para classificar texto com embeddings?

Não necessariamente. Enquanto a base de exemplos rotulados for pequena, dá pra guardar os vetores numa estrutura simples na memória, como no exemplo em numpy do passo a passo. Um banco vetorial dedicado entra em cena quando essa base cresce demais pra caber e ser comparada rápido em memória.

Quantos exemplos por rótulo são necessários para começar a classificar com embeddings?

Não existe um número fixo publicado pelos fornecedores. O jeito certo é começar com poucos exemplos reais e variados por rótulo, como no passo 1, e usar o lote de validação pra ver se o acerto já está bom o suficiente.


Quer aprender mais sobre inteligência artificial e programação na prática? Conheça o canal Hora de Codar no YouTube e acompanhe os próximos vídeos.

Escrito por | Matheus Battisti

Matheus Battisti
Fundador da Hora de Codar

Programador apaixonado pelo mundo das tecnologias, sempre buscando em aprender e se aprofundar em linguagens, frameworks e o que mais for necessário para executar um bom trabalho. Agora tem uma nova missão que é de passar seu conhecimento adiante para formar novos programadores e especializar mais os que já são.

Subscribe
Notify of
guest

0 Comentários
Oldest
Newest Most Voted

Formações

Formação Vibe Coding

Formação Vibe Coding

Do Prompt ao Produto: Crie Software Real com IA

  • 474 aulas
  • 20 projetos
  • 39h 30min

Blog | Mais populares