Avaliando o RAG com o Jev como juiz

Olá meus Unicórnios! 🦄✨
No artigo do RAG, o nosso atendente virtual respondeu três perguntas, eu li as três respostas, achei ótimas e fui dormir feliz. 😴
Só que "eu li e achei ótimas" não é um método. Na hora em que você troca o modelo, mexe na instrução ou cadastra mais cinquenta produtos, quem garante que as três continuam boas? E as perguntas que eu nem pensei em fazer?
Hoje a gente dá nota ao RAG. Com um gabarito de perguntas, uma conferência da busca que não usa IA nenhuma e um juiz corrigindo cada resposta. E o juiz não é mais um LLM: é o Jev, da TypeSafe AI, um modelo que não escreve texto nenhum, só decide. ⚖️
Antes deste: RAG na prática com o Qdrant e a OpenAIO atendente virtual que este artigo avalia, com a mesma coleção e as mesmas funções.blog.palomamacetko.com.brEste artigo reaproveita tudo de lá: a coleção produtos no Qdrant, com os oito produtos, o modelo de embeddings e a instrução do atendente. Se ainda não tem o Qdrant de pé, comece pela instalação na VPS e pela busca semântica com embeddings.
📝 O gabarito vem antes de qualquer juiz
Professor nenhum corrige prova sem gabarito. O primeiro passo, e o mais importante, é escrever uma lista de perguntas com a resposta que você espera:
// O gabarito: cada pergunta com o produto que DEVERIA aparecer.
// "nenhum" quer dizer que a resposta certa e "nao temos".
const CASOS = [
{ pergunta: "quero um pc para jogos ate 2000 reais", esperado: "PC Gamer Ryzen 5" },
{ pergunta: "tem alguma cadeira boa para quem passa o dia sentado", esperado: "Cadeira gamer" },
{ pergunta: "preciso de um notebook barato para estudar", esperado: "Notebook Acer" },
{ pergunta: "tela para jogos de corrida", esperado: "Monitor 27" },
{ pergunta: "maquina potente para editar video", esperado: "PC Gamer Ryzen 7" },
{ pergunta: "computador simples com processador i3", esperado: "nenhum" },
{ pergunta: "voces tem impressora laser colorida", esperado: "nenhum" }
];
Repare em três coisas nessa lista, porque cada uma está ali por um motivo:
- As perguntas são do jeito que o cliente escreve, sem acento, sem o nome do produto. "Tela para jogos de corrida" e não "Monitor 27 polegadas". Gabarito com o nome do produto na pergunta mede uma busca que ninguém faz.
- Tem caso em que a resposta certa é "não temos". A impressora não existe no catálogo. E o "computador simples com processador i3" é a pegadinha: ele existe, mas está com estoque zero, e o filtro do artigo anterior o tira da busca. A resposta certa é dizer que não tem.
- O esperado é um pedaço do nome, não o nome inteiro. "PC Gamer Ryzen 5" basta para identificar o produto, e não quebra se alguém corrigir um detalhe no cadastro.
🔎 A primeira nota não precisa de IA
Lembra da frase que mandava em tudo no artigo do RAG? O que a busca não achar, o modelo não vai saber. Então antes de corrigir a resposta, eu confiro se o produto esperado chegou até o modelo:
// Nota da BUSCA, sem IA nenhuma: em que posicao o produto esperado veio?
function avaliarBusca(caso, produtos) {
if (caso.esperado === "nenhum") {
return "nao se aplica";
}
for (let i = 0; i < produtos.length; i++) {
if (produtos[i].payload.nome.includes(caso.esperado)) {
return "achou em " + (i + 1) + "o lugar";
}
}
return "NAO ACHOU";
}
É uma função boba, de propósito: um for pelos resultados do Qdrant procurando o nome. Ela não gasta token, não depende de chave e não tem opinião. Ou o produto veio, ou não veio.
E o script roda assim mesmo sem chave nenhuma: nesse caso ele avalia só a busca, que é de graça. Foi exatamente o que eu rodei primeiro:
$ node avaliar.js
PERGUNTA: quero um pc para jogos ate 2000 reais
esperado: PC Gamer Ryzen 5 busca: achou em 1o lugar
PERGUNTA: tem alguma cadeira boa para quem passa o dia sentado
esperado: Cadeira gamer busca: achou em 1o lugar
PERGUNTA: preciso de um notebook barato para estudar
esperado: Notebook Acer busca: achou em 2o lugar
PERGUNTA: tela para jogos de corrida
esperado: Monitor 27 busca: achou em 2o lugar
PERGUNTA: maquina potente para editar video
esperado: PC Gamer Ryzen 7 busca: NAO ACHOU
PERGUNTA: computador simples com processador i3
esperado: nenhum busca: nao se aplica
PERGUNTA: voces tem impressora laser colorida
esperado: nenhum busca: nao se aplica
BUSCA: 4 de 5 acharam o produto esperado
Olha que interessante. 👀 Quatro de cinco parece bom, mas a linha que importa é a do "maquina potente para editar video": o PC Gamer Ryzen 7, com 32 GB e RTX 4060, que é exatamente o que esse cliente precisa, não ficou entre os três primeiros.
Fui ver o que a busca trouxe no lugar dele: o monitor, o PC Gamer Ryzen 5 e o computador de escritório. Nenhum dos nomes tem "vídeo", "editar" ou "potente", e o modelo de embeddings ficou sem ponte entre "editar vídeo" e "RTX 4060". Quem sabe que placa de vídeo boa serve para edição é gente, não a frase cadastrada.
E agora a parte que me fez escrever este artigo inteiro: se eu olhasse só a resposta final, eu ia culpar o GPT. 😳 Ele receberia três produtos que não servem, responderia o melhor que pudesse, e eu ia ficar mexendo na instrução do atendente para consertar um problema que mora no cadastro. É por isso que são duas notas separadas.
Repare também nos dois "achou em 2º lugar". Não são erro, mas são aviso: no "tela para jogos de corrida", quem veio em primeiro foi a cadeira gamer, a mesma que já tinha dado trabalho nos artigos anteriores. Com limit: 3 o monitor ainda entra no contexto. Com limit: 1, ele sumiria.
🧑⚖️ O juiz: o Jev, da TypeSafe
Agora a resposta. Ler sete respostas na mão até dá, mas ninguém lê setenta toda vez que muda o prompt. O caminho comum seria entregar a correção a outro LLM, que escreve um parágrafo e um "sim" no fim. Só que corrigir prova não é escrever: é decidir. E é exatamente isso que o Jev faz.
O Jev é o que a TypeSafe chama de modelo "System One": ele não gera texto, toma decisões estruturadas, rápidas e calibradas. A empresa afirma que ele é até 200 vezes mais rápido e 400 vezes mais barato que um LLM em tarefas de classificação (o número é dela, não meu). A ideia foi apresentada pela LangChain no post Building a harness with Jev.
Você manda duas coisas: um state, que é o texto a ser julgado, e um mapa de questions, cada uma com um nome, um tipo e a instrução. São três tipos: choice (escolher entre opções), score (dar nível numa escala) e noul, que é o que interessa aqui: a probabilidade de uma afirmação ser verdadeira. Este é o exemplo da documentação da TypeSafe:
curl -X POST https://api.typesafe.ai/v1/systemone \
-H "Authorization: Bearer $TYPESAFE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "jev-latest",
"state": "...",
"questions": {
"is_urgent": {
"type": "noul",
"instructions": "The message conveys urgency or time-sensitivity"
}
}
}'
E o formato da resposta, também da documentação (cortei as outras perguntas do exemplo):
{
"model": "jev-1.13.0",
"answers": {
"is_urgent": {
"type": "noul",
"noul": 1.0
}
},
"usage": {
"input_tokens": 392,
"output_tokens": 65
}
}
Repare que eu pedi jev-latest e a resposta diz qual versão rodou de fato (jev-1.13.0). Vale imprimir isso num log: se a nota mudar de um dia para o outro sem você mexer em nada, é o primeiro lugar para olhar. 👀
Existe uma integração pronta para LangChain em Python, o langchain-typesafe. Como o nosso atendente é Node, eu chamo a API direto, com o fetch que já vem no Node 18, sem instalar nada:
async function chamarJev(estado, perguntas) {
const resposta = await fetch(ENDERECO_JEV, {
method: "POST",
headers: {
"Authorization": "Bearer " + CHAVE_JEV,
"Content-Type": "application/json"
},
body: JSON.stringify({ model: "jev-latest", state: estado, questions: perguntas })
});
if (!resposta.ok) {
const erro = await resposta.text();
throw new Error("O Jev recusou: " + resposta.status + " " + erro);
}
const dados = await resposta.json();
return dados.answers;
}
O if (!resposta.ok) não é enfeite: o fetch não lança erro sozinho num 401. Sem essa checagem, uma chave errada só apareceria depois, como um "Cannot read properties of undefined" lá no dados.answers, que não ajuda em nada.
⚖️ Fiel e correta, numa chamada só
Agora a função que monta a prova. O state leva tudo o que o juiz precisa ver, e as duas perguntas vão juntas:
async function pedirNota(caso, contexto, respostaRag) {
// O "state" e tudo o que o juiz precisa ver para decidir.
const estado =
"PRODUTOS:\n" + contexto +
"\n\nPERGUNTA: " + caso.pergunta +
"\n\nPRODUTO ESPERADO: " + caso.esperado +
"\n\nRESPOSTA DO ATENDENTE: " + respostaRag;
// Duas perguntas "noul" (verdadeiro ou falso) na MESMA requisicao.
const respostas = await chamarJev(estado, {
fiel: {
type: "noul",
instructions: "Todo produto, preco e detalhe citado na RESPOSTA DO ATENDENTE aparece na lista de PRODUTOS"
},
correta: {
type: "noul",
instructions: "A RESPOSTA DO ATENDENTE recomenda o PRODUTO ESPERADO; se o esperado for nenhum, ela diz que a loja nao tem"
}
});
// Cada "noul" e a probabilidade (de 0 a 1) de a frase ser verdadeira.
return {
fiel: respostas.fiel.noul,
correta: respostas.correta.noul
};
}
Cada pedaço dessa função existe por causa de um jeito de dar errado:
- O juiz recebe o contexto, não só a pergunta. Sem a lista de produtos ele não tem como saber se "R$ 1950" é verdade ou invenção. Ele julgaria pelo que parece plausível, que é exatamente o defeito que a gente quer pegar.
- Duas perguntas, não uma. Fiel é não inventar nada fora do contexto. Correta é recomendar o produto do gabarito. Lembra do teste da garantia no artigo anterior? "1 ano de garantia e fonte de 500W" sobre o produto certo seria correta e infiel. Uma nota só esconderia isso numa média.
- As duas na mesma requisição. O Jev responde várias perguntas sobre o mesmo
stateem paralelo. Uma chamada por caso, não duas. - A instrução é uma afirmação, não uma pergunta. O
nouldevolve a chance de a frase ser verdadeira, então eu escrevo a frase que eu quero que seja verdade: "todo produto, preço e detalhe citado aparece na lista".
🎯 Por que probabilidade é melhor que sim ou não
Um LLM como juiz devolve "sim" ou "não". O Jev devolve um número de 0 a 1, e quem decide onde fica a linha é você:
if (nota.fiel >= LIMIAR) {
fieis = fieis + 1;
}
if (nota.correta >= LIMIAR) {
corretas = corretas + 1;
}
Com o LIMIAR em 0,5, uma resposta com fiel=0.51 passa e uma com fiel=0.99 também. No sim ou não as duas seriam idênticas. Aqui não: a de 0,51 passou raspando, e é ela que vai reprovar primeiro quando você mexer no prompt. Por isso o script imprime os dois números de cada caso, e não só o placar no fim.
E como a TypeSafe descreve as decisões do Jev como calibradas (um 0,9 deve valer como nove chances em dez), dá para mexer no limiar com critério. Para "fiel", onde inventar preço é o pior erro de um atendente, faz sentido ser mais exigente que 0,5. 🦄
🔑 Quando a chave está errada
A chave da OpenAI (do atendente) e a do Jev vêm do ambiente, nunca do código. E vale mostrar o que acontece quando uma delas está errada, porque é o primeiro erro que todo mundo encontra:
$ OPENAI_API_KEY="chave-errada" node avaliar.js
PERGUNTA: quero um pc para jogos ate 2000 reais
esperado: PC Gamer Ryzen 5 busca: achou em 1o lugar
A avaliacao parou no meio: A OpenAI recusou: 401 {
"error": {
"message": "Incorrect API key provided: chave-errada. You can find your API key at https://platform.openai.com/account/api-keys.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
Repare que a busca do primeiro caso rodou normalmente, e a avaliação parou na primeira chamada paga, com o motivo por extenso. Com a chave do Jev errada acontece o mesmo, só que a mensagem começa com "O Jev recusou".
Aqui eu deixei o erro parar tudo. Chave errada não é um caso ruim, é a avaliação inteira impossível: não faz sentido seguir e reprovar sete casos por um motivo que não tem nada a ver com o atendente.
📜 O script inteiro
As funções gerarVetor, buscarProdutos, montarContexto e pedirResposta são as do artigo anterior, com a mesma instrução do atendente:
const { pipeline, env } = require("@xenova/transformers");
const { QdrantClient } = require("@qdrant/js-client-rest");
env.cacheDir = "/opt/qdrant/modelos-js";
const ENDERECO = "http://localhost:6333";
const CHAVE_QDRANT = "SUA_CHAVE_AQUI";
const COLECAO = "produtos";
const MODELO_EMBEDDING = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
const MODELO_GPT = "gpt-4o-mini";
const CHAVE_OPENAI = process.env.OPENAI_API_KEY;
const CHAVE_JEV = process.env.TYPESAFE_API_KEY;
const ENDERECO_JEV = "https://api.typesafe.ai/v1/systemone";
// Acima deste valor, a resposta passa.
const LIMIAR = 0.5;
// O gabarito: cada pergunta com o produto que DEVERIA aparecer.
// "nenhum" quer dizer que a resposta certa e "nao temos".
const CASOS = [
{ pergunta: "quero um pc para jogos ate 2000 reais", esperado: "PC Gamer Ryzen 5" },
{ pergunta: "tem alguma cadeira boa para quem passa o dia sentado", esperado: "Cadeira gamer" },
{ pergunta: "preciso de um notebook barato para estudar", esperado: "Notebook Acer" },
{ pergunta: "tela para jogos de corrida", esperado: "Monitor 27" },
{ pergunta: "maquina potente para editar video", esperado: "PC Gamer Ryzen 7" },
{ pergunta: "computador simples com processador i3", esperado: "nenhum" },
{ pergunta: "voces tem impressora laser colorida", esperado: "nenhum" }
];
async function gerarVetor(modelo, texto) {
const saida = await modelo(texto, { pooling: "mean", normalize: true });
return Array.from(saida.data);
}
async function buscarProdutos(cliente, modelo, pergunta) {
const vetor = await gerarVetor(modelo, pergunta);
const resposta = await cliente.query(COLECAO, {
query: vetor,
filter: { must: [{ key: "tem_estoque", match: { value: true } }] },
limit: 3,
with_payload: true
});
return resposta.points;
}
function montarContexto(produtos) {
const linhas = [];
for (const item of produtos) {
const d = item.payload;
linhas.push("- " + d.nome + " | R$ " + d.preco + " | estoque: " + d.estoque);
}
return linhas.join("\n");
}
// Nota da BUSCA, sem IA nenhuma: em que posicao o produto esperado veio?
function avaliarBusca(caso, produtos) {
if (caso.esperado === "nenhum") {
return "nao se aplica";
}
for (let i = 0; i < produtos.length; i++) {
if (produtos[i].payload.nome.includes(caso.esperado)) {
return "achou em " + (i + 1) + "o lugar";
}
}
return "NAO ACHOU";
}
async function pedirResposta(pergunta, contexto) {
const instrucao =
"Voce e o atendente de uma loja de informatica. " +
"Responda em portugues, em no maximo 3 frases, usando SOMENTE os produtos da lista. " +
"Cite o preco. Se nenhum servir, diga que nao encontrou e nao invente produto.";
const resposta = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer " + CHAVE_OPENAI,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: MODELO_GPT,
messages: [
{ role: "system", content: instrucao },
{ role: "user", content: "Produtos disponiveis:\n" + contexto + "\n\nPergunta do cliente: " + pergunta }
],
temperature: 0
})
});
if (!resposta.ok) {
const erro = await resposta.text();
throw new Error("A OpenAI recusou: " + resposta.status + " " + erro);
}
const dados = await resposta.json();
return dados.choices[0].message.content;
}
async function chamarJev(estado, perguntas) {
const resposta = await fetch(ENDERECO_JEV, {
method: "POST",
headers: {
"Authorization": "Bearer " + CHAVE_JEV,
"Content-Type": "application/json"
},
body: JSON.stringify({ model: "jev-latest", state: estado, questions: perguntas })
});
if (!resposta.ok) {
const erro = await resposta.text();
throw new Error("O Jev recusou: " + resposta.status + " " + erro);
}
const dados = await resposta.json();
return dados.answers;
}
async function pedirNota(caso, contexto, respostaRag) {
// O "state" e tudo o que o juiz precisa ver para decidir.
const estado =
"PRODUTOS:\n" + contexto +
"\n\nPERGUNTA: " + caso.pergunta +
"\n\nPRODUTO ESPERADO: " + caso.esperado +
"\n\nRESPOSTA DO ATENDENTE: " + respostaRag;
// Duas perguntas "noul" (verdadeiro ou falso) na MESMA requisicao.
const respostas = await chamarJev(estado, {
fiel: {
type: "noul",
instructions: "Todo produto, preco e detalhe citado na RESPOSTA DO ATENDENTE aparece na lista de PRODUTOS"
},
correta: {
type: "noul",
instructions: "A RESPOSTA DO ATENDENTE recomenda o PRODUTO ESPERADO; se o esperado for nenhum, ela diz que a loja nao tem"
}
});
// Cada "noul" e a probabilidade (de 0 a 1) de a frase ser verdadeira.
return {
fiel: respostas.fiel.noul,
correta: respostas.correta.noul
};
}
async function main() {
const modelo = await pipeline("feature-extraction", MODELO_EMBEDDING);
const cliente = new QdrantClient({ url: ENDERECO, apiKey: CHAVE_QDRANT });
let buscasCertas = 0;
let buscasAvaliadas = 0;
let fieis = 0;
let corretas = 0;
for (const caso of CASOS) {
const produtos = await buscarProdutos(cliente, modelo, caso.pergunta);
const contexto = montarContexto(produtos);
const busca = avaliarBusca(caso, produtos);
console.log("");
console.log("PERGUNTA: " + caso.pergunta);
console.log("esperado: " + caso.esperado + " busca: " + busca);
if (busca !== "nao se aplica") {
buscasAvaliadas = buscasAvaliadas + 1;
if (busca !== "NAO ACHOU") {
buscasCertas = buscasCertas + 1;
}
}
// Sem chave, a avaliacao para na busca, que nao custa nada.
if (!CHAVE_OPENAI) {
continue;
}
const respostaRag = await pedirResposta(caso.pergunta, contexto);
const nota = await pedirNota(caso, contexto, respostaRag);
console.log("resposta: " + respostaRag);
console.log("juiz: fiel=" + nota.fiel.toFixed(2) + " correta=" + nota.correta.toFixed(2));
if (nota.fiel >= LIMIAR) {
fieis = fieis + 1;
}
if (nota.correta >= LIMIAR) {
corretas = corretas + 1;
}
}
console.log("");
console.log("BUSCA: " + buscasCertas + " de " + buscasAvaliadas + " acharam o produto esperado");
if (!CHAVE_OPENAI) {
console.log("Sem OPENAI_API_KEY: avaliei so a busca. Para o juiz, rode assim:");
console.log(' OPENAI_API_KEY="sua-chave" TYPESAFE_API_KEY="sua-chave-jev" node avaliar.js');
return;
}
console.log("FIEIS: " + fieis + " de " + CASOS.length);
console.log("CORRETAS: " + corretas + " de " + CASOS.length);
}
main().catch(function (erro) {
console.log("A avaliacao parou no meio: " + erro.message);
});
Salve como avaliar.js na mesma pasta do perguntar.js e rode. Sem as chaves, avalia só a busca:
node avaliar.js
Com as duas chaves, avalia a busca, gera a resposta e chama o Jev para cada caso, imprimindo no fim as três contagens. Se você ainda não sabe criar a variável de ambiente, é só escrever as duas antes do comando, na mesma linha, trocando pelas suas chaves:
OPENAI_API_KEY="sua-chave" TYPESAFE_API_KEY="sua-chave-jev" node avaliar.js
A chave do Jev sai do painel da TypeSafe, em console.typesafe.ai/keys. Cada caso faz uma chamada à OpenAI (o atendente) e uma ao Jev (o juiz). A nota da busca vem primeiro e de graça: se ela já reprovou, você conserta o cadastro antes de gastar um centavo.
O que esse script devolve é um número que você compara com ele mesmo. Mudou a instrução, trocou o modelo, cadastrou produto novo? Roda de novo. Se o "4 de 5" virou "3 de 5", alguma coisa piorou, e o caso que reprovou diz qual. 🦄
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
O que é o Jev?
state e perguntas, e ele devolve, por exemplo, a probabilidade de uma afirmação ser verdadeira. A empresa afirma que ele é até 200 vezes mais rápido e 400 vezes mais barato que um LLM em classificação.O que é uma pergunta noul?
"noul": 0.999. No juiz do RAG são duas: fiel e correta, enviadas na mesma requisição.Por que avaliar a busca separado da resposta?
Qual a diferença entre resposta fiel e resposta correta?
Por que usar probabilidade em vez de sim ou não?
Leia também
Bisbilhoscópio: monitorando Windows Server e IIS
Conheça o Bisbilhoscópio, meu monitor de Windows Server, IIS, SQL Server e MySQL com agente somente leitura e alertas em português. 1º servidor grátis.
ClickHouse: instalando na VPS e usando com Node.js
Como instalar o ClickHouse numa VPS Ubuntu, proteger com senha, criar uma tabela de acessos e consultar 10 milhões de linhas pelo Node.js.
Pensieve: notas com API, MCP e Skill para a sua IA
Como funciona o Pensieve, app gratuito de notas com lembretes, e como usar a API REST, o servidor MCP e a Skill para a sua IA anotar por você.


