Avaliando RAG: recall@k, nDCG e LLM-as-judge
- ⬜🎯 Hybrid Search + Reranking: do BM25 ao cross-encoder(Engenharia AI-Native)
Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.
Um RAG sem eval harness é um RAG que você acha que funciona. Pipeline bonito, demo convincente, e 3 meses depois ninguém sabe por que a qualidade caiu. Avaliação não é overhead — é o instrumento que transforma RAG em engenharia. Este módulo é o mapa das métricas certas, como montar golden dataset, e como rodar eval em CI todo PR.
As duas dimensões que importam
┌─────────────────────┐
│ Query │
└─────────┬───────────┘
│
┌──────────┴──────────┐
▼ ▼
╔════════════════╗ ╔════════════════╗
║ Retrieval ║ ║ Generation ║
║ métricas ║ ║ métricas ║
╠════════════════╣ ╠════════════════╣
║ recall@k ║ ║ faithfulness ║
║ MRR ║ ║ context prec. ║
║ nDCG@k ║ ║ answer rel. ║
║ hit rate ║ ║ hallucination ║
║ context recall ║ ║ completeness ║
╚════════════════╝ ╚════════════════╝
│ │
└─────────┬───────────┘
▼
┌──────────────────────┐
│ Score agregado + │
│ drill-down por tipo │
└──────────────────────┘
Métricas de retrieval — quando o que você mede é posição
| Métrica | O que mede | Quando usar |
|---|---|---|
| hit rate @ k | Fração de queries cujo gabarito aparece em top-k (binário) | Smoke test inicial, diagnóstico rápido |
| recall @ k | Fração dos docs relevantes recuperados no top-k | Quando há múltiplos docs relevantes por query |
| precision @ k | Fração do top-k que é relevante | Quando top-k é pequeno e você quer zero ruído |
| MRR (Mean Reciprocal Rank) | Média de 1/rank do primeiro hit | Queries com exatamente 1 doc relevante (FAQ) |
| nDCG @ k | Discounted Cumulative Gain normalizado | Quando posição no top-k importa muito (padrão em RAG) |
| context recall (RAGAS) | Quantos dos "pedaços" do gabarito estão no contexto recuperado | Gabarito é uma resposta longa fragmentada em claims |
# Métricas de retrieval — implementação mínima
import math
def hit_rate_at_k(retrieved: list[str], gold: list[str], k: int) -> float:
return 1.0 if any(d in gold for d in retrieved[:k]) else 0.0
def recall_at_k(retrieved: list[str], gold: list[str], k: int) -> float:
return len(set(retrieved[:k]) & set(gold)) / max(len(gold), 1)
def mrr(retrieved: list[str], gold: list[str]) -> float:
for i, d in enumerate(retrieved, start=1):
if d in gold:
return 1.0 / i
return 0.0
def dcg_at_k(retrieved: list[str], gold: list[str], k: int) -> float:
return sum(
(1.0 if d in gold else 0.0) / math.log2(i + 1)
for i, d in enumerate(retrieved[:k], start=1)
)
def ndcg_at_k(retrieved: list[str], gold: list[str], k: int) -> float:
ideal = dcg_at_k(gold[:k], gold, k)
return dcg_at_k(retrieved, gold, k) / ideal if ideal > 0 else 0.0Métricas de generation — quando o que você mede é a resposta
| Métrica | O que mede | Como calcular |
|---|---|---|
| Faithfulness | Toda claim da resposta está no contexto? | Extrai claims da resposta, checa cada uma contra o contexto (LLM ou NLI) |
| Context precision | % dos chunks recuperados que foram úteis | Para cada chunk, LLM decide se contribui para a resposta |
| Answer relevance | A resposta responde à pergunta? | Gera queries hipotéticas a partir da resposta, mede similaridade com a query original |
| Completeness | A resposta cobre todos os aspectos da pergunta? | LLM compara resposta com gabarito, lista o que faltou |
| Hallucination rate | % de claims inventadas (não suportadas) | 1 − faithfulness, útil para SLO |
LLM-as-judge: como usar sem se enganar
LLM-as-judge é o método mais escalável para métricas que exigiriam humano (faithfulness, answer relevance). Mas não é verdade revelada — é uma estimativa. Quatro regras evitam cair em armadilhas.
- Juiz de família diferente do gerador. Se o RAG usa Claude, julgue com GPT (ou inverso). Reduz viés de "mesma voz".
- Calibre com humanos. Avalie 50-100 itens manualmente, correlacione com o juiz. Se correlação for <0.7, melhore a rubrica antes de confiar.
- Rubrica explícita, não escore aberto. "Escala 0-4, onde 0=... 1=... 4=..." vence "dê nota de 0 a 10".
- Ground truth quando existe. Passe gabarito para o juiz. "Compare com referência" é muito mais confiável que "avalie em abstrato".
# Prompt de LLM-as-judge para faithfulness — com rubrica e ground truth
from anthropic import Anthropic
client = Anthropic()
JUDGE_PROMPT = """Você é um juiz objetivo avaliando se uma resposta é FIEL ao contexto fornecido.
Uma resposta é FIEL se toda afirmação factual nela pode ser derivada diretamente do contexto. Opiniões, interpretações ou conexões não presentes no contexto tornam a resposta NÃO FIEL.
<contexto>
{context}
</contexto>
<pergunta>
{question}
</pergunta>
<resposta>
{answer}
</resposta>
Liste cada afirmação factual da resposta. Para cada uma, diga: SUPORTADA (está no contexto), INFERIDA (razoável mas não explícita) ou NÃO SUPORTADA (não está no contexto).
Ao final, dê o veredito em JSON:
{{"faithfulness_score": 0.0 a 1.0, "unsupported_claims": ["..."]}}
faithfulness_score = claims SUPORTADAS / total de claims factuais."""
def judge_faithfulness(context: str, question: str, answer: str) -> dict:
r = client.messages.create(
model="claude-sonnet-4-6", # juiz ≠ gerador
max_tokens=1024,
messages=[{
"role": "user",
"content": JUDGE_PROMPT.format(
context=context, question=question, answer=answer
),
}],
)
# parse do JSON final (em produção use structured output)
import json, re
match = re.search(r"\{[^{}]*faithfulness_score[^{}]*\}", r.content[0].text)
return json.loads(match.group(0)) if match else {"faithfulness_score": 0.0}RAGAS: o framework que padronizou o eval de RAG
RAGAS é lib Python open-source que implementa as métricas canônicas com LLM-as-judge por trás. Vale como ponto de partida — você ganha rapidez no setup e perde algum controle fino. Para produção madura, costuma evoluir para eval custom (fica mais transparente).
# pip install ragas datasets
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
)
from datasets import Dataset
# Seu golden set: query, contexto recuperado, resposta gerada, ground truth
data = Dataset.from_dict({
"question": ["Como cancelo minha conta?", ...],
"contexts": [["<chunk1>", "<chunk2>"], ...],
"answer": ["Para cancelar, acesse ...", ...],
"ground_truth": ["Em Configurações > Conta > Encerrar.", ...],
})
result = evaluate(
data,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)
print(result)
# → {"faithfulness": 0.87, "answer_relevancy": 0.92, ...}ragas.llms.LangchainLLMWrapper) — senão você avalia em inglês por dentro e os scores ficam enviesados.Golden dataset: como montar o seu em 1 dia
- Colete queries reais. Últimas 500 queries dos logs do produto. Se ainda não está em produção, gere 100-200 queries a partir dos documentos (prompt de "faça pergunta que este doc responderia").
- Curate 100 diversas. Amostre cobrindo: queries curtas/longas, fáceis/ambíguas, com match lexical/semântico, dentro e fora da base ("negative" — deve retornar "não sei").
- Escreva gabarito à mão. Para cada query: doc_id relevante(s) + resposta esperada em 1-3 frases. Isso é trabalho humano, sem atalho. 100 itens: 4-6h de trabalho bem feito.
- Versione como código. JSONL no repo, com schema fixo. Nunca edite em planilha que pode ser perdida.
- Revise trimestralmente. A distribuição de queries evolui; queries antigas perdem relevância. 15 min/trimestre para podar e adicionar.
// golden_set.jsonl — um item por linha
{
"id": "q_001",
"query": "Como resetar minha senha?",
"type": "how-to",
"relevant_doc_ids": ["doc_42", "doc_118"],
"ground_truth_answer": "Na tela de login, clique em 'esqueci a senha'. Você receberá um email com link válido por 1h.",
"difficulty": "easy",
"should_refuse": false
}
{
"id": "q_027",
"query": "Qual a receita da empresa em 2025?",
"type": "out-of-scope",
"relevant_doc_ids": [],
"ground_truth_answer": "Não tenho essa informação na base.",
"difficulty": "medium",
"should_refuse": true
}Eval harness em CI: fail the build on regression
Eval offline é inútil se não roda a cada mudança. Integre no CI todo PR que toca retrieval, prompt ou chunking. Promova uma baseline; PR que degrada >2pp em métrica crítica falha.
# .github/workflows/rag-eval.yml
name: RAG Eval
on:
pull_request:
paths: ["src/rag/**", "prompts/**", "ingest/**"]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- name: Run RAG eval on golden set
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: python -m eval.run --out eval_results.json
- name: Compare with baseline
run: |
python -m eval.compare \
--baseline eval/baselines/main.json \
--current eval_results.json \
--threshold 0.02
- name: Post results to PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const r = JSON.parse(fs.readFileSync('eval_results.json'));
const body = `### RAG eval\n\n` +
`- recall@5: ${r.recall_at_5.toFixed(3)}\n` +
`- nDCG@5: ${r.ndcg_at_5.toFixed(3)}\n` +
`- faithfulness: ${r.faithfulness.toFixed(3)}\n`;
github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number, body
});Debug: quando a métrica cai, o que olhar primeiro
| Sintoma | Primeira suspeita | Como confirmar |
|---|---|---|
| recall@k despenca | Chunking ou embedder mudou | Roda eval só de retrieval; compara com versão anterior do índice |
| recall@k OK, faithfulness cai | Prompt do generator mudou | Git blame no prompt; A/B com versão antiga do prompt |
| Answer relevance cai, faithfulness OK | Contexto vem mas LLM ignora | Checa ordem do contexto, prompt de "use apenas o contexto" |
| Queries out-of-scope começam a ser respondidas | Threshold de refusal sumiu | Testa "should_refuse" queries isoladamente |
| Métricas estáveis mas qualidade percebida caiu | Golden set está estagnado | Amostra 30 queries novas dos logs, avalia à mão |
Perguntas típicas
❓ Posso confiar em BLEU/ROUGE para RAG?
❓ Qual o tamanho mínimo útil de golden set?
❓ Eval online (em produção) substitui eval offline?
❓ Quanto custa rodar eval harness semanal?
Quiz rápido
4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito