Para saber se o RAG do seu app Laravel está buscando os trechos certos, monte um golden set (perguntas com os chunks que deveriam voltar) e rode um comando Artisan que calcula recall@k e MRR para cada estratégia de busca. Assim, trocar o tamanho do chunk, o modelo de embedding ou a busca vetorial pela híbrida passa a ser uma decisão baseada em número, e não no feeling de três perguntas testadas na mão.
Aprendi isso do jeito chato. Quando a resposta do agente sai errada, quase sempre a culpa cai no prompt ou no modelo. Na prática, boa parte das respostas ruins vem de um retrieval que nem trouxe o trecho certo para o contexto. Nenhum prompt conserta um contexto que não tem a informação.
O que você vai ter funcionando no final
- Uma tabela com o golden set, alimentada por perguntas reais e por perguntas sintéticas geradas com Claude.
- Uma interface
Retrieverque permite plugar estratégias diferentes (vetorial, híbrida, com filtro etc.). - Uma classe que calcula hit rate, recall@k e MRR.
- O comando
php artisan rag:evalque imprime as métricas, salva o histórico e retorna erro quando a qualidade cai.
Pré-requisitos:
- Laravel 11 ou 12, PHP 8.2+.
- PostgreSQL 15+ com a extensão pgvector e uma tabela
chunksjá populada (id,document_id,chunk_index,content,embedding vector(1536)). Se ainda não tem isso, comece pelo tutorial de RAG com pgvector no Laravel. - Chave da OpenAI para embeddings (
text-embedding-3-small) e chave da Anthropic para gerar perguntas sintéticas. - Redis como driver de cache (recomendado, não obrigatório).
Antes de seguir, três definições rápidas:
- Golden set: conjunto fixo de perguntas, cada uma com a lista de chunks que são a resposta correta.
- Recall@k: das respostas esperadas, quantas apareceram entre os k primeiros resultados da busca.
- MRR (Mean Reciprocal Rank): média de 1/posição do primeiro resultado correto. Se o chunk certo veio em 1º, vale 1; em 2º, 0,5; em 5º, 0,2; fora do top-k, 0.
1. Como guardar o golden set no banco?
A decisão mais importante aqui é como identificar um chunk. O id autoincremento é péssimo: qualquer reindexação apaga e recria linhas, e o golden set inteiro aponta para o nada. Uso uma chave estável no formato document_id:chunk_index.
php artisan make:migration create_rag_eval_tables
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('rag_eval_queries', function (Blueprint $table) { $table->id(); $table->text('question'); $table->jsonb('expected_keys'); // ["42:0", "42:1"] $table->string('source')->default('manual'); // manual | synthetic $table->boolean('active')->default(true); $table->timestamps(); }); Schema::create('rag_eval_runs', function (Blueprint $table) { $table->id(); $table->string('strategy'); $table->unsignedSmallInteger('k'); $table->unsignedInteger('total_queries'); $table->decimal('hit_rate', 5, 4); $table->decimal('recall', 5, 4); $table->decimal('mrr', 5, 4); $table->string('git_sha')->nullable(); $table->jsonb('failures'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('rag_eval_runs'); Schema::dropIfExists('rag_eval_queries'); } };
namespace App\Models; use Illuminate\Database\Eloquent\Model; class RagEvalQuery extends Model { protected $fillable = ['question', 'expected_keys', 'source', 'active']; protected $casts = [ 'expected_keys' => 'array', 'active' => 'boolean', ]; }
Crie também o model RagEvalRun com $guarded = [] e cast de failures para array.
Se a sua reindexação muda o chunk_index quando o documento é editado, isso precisa entrar no fluxo de sincronização. Comento como lidar com isso em reindexação incremental de embeddings com pgvector.
2. Como gerar perguntas sintéticas para o golden set com Claude?
O ideal é ter perguntas reais, tiradas dos logs do agente e marcadas à mão. Mas no começo você não tem volume, e marcar 100 perguntas manualmente ninguém faz. A saída é sortear chunks e pedir ao Claude que escreva perguntas que aquele trecho responde. A chave esperada é o próprio chunk.
Config em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5'), ], 'openai' => [ 'key' => env('OPENAI_API_KEY'), 'embedding_model' => env('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'), ],
Uso tool calling com tool_choice forçado para garantir que a resposta venha como JSON válido no formato do schema:
namespace App\Console\Commands; use App\Models\RagEvalQuery; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; class RagGoldenGenerate extends Command { protected $signature = 'rag:golden-generate {--sample=50}'; protected $description = 'Gera perguntas sintéticas a partir de chunks sorteados'; public function handle(): int { $chunks = DB::table('chunks') ->whereRaw('length(content) > 300') ->inRandomOrder() ->limit((int) $this->option('sample')) ->get(['document_id', 'chunk_index', 'content']); foreach ($chunks as $chunk) { $questions = $this->ask_claude($chunk->content); foreach ($questions as $question) { RagEvalQuery::create([ 'question' => $question, 'expected_keys' => ["{$chunk->document_id}:{$chunk->chunk_index}"], 'source' => 'synthetic', ]); } } $this->info('Perguntas geradas: '.RagEvalQuery::where('source', 'synthetic')->count()); return self::SUCCESS; } private function ask_claude(string $content): array { $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ])->timeout(60)->retry(2, 1000)->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), 'max_tokens' => 1024, 'system' => 'Você cria perguntas de avaliação para um sistema de busca. ' .'Escreva perguntas que um cliente real digitaria, com palavras próprias, ' .'sem copiar frases do trecho. Cada pergunta deve ser respondível apenas com o trecho.', 'tools' => [[ 'name' => 'registrar_perguntas', 'description' => 'Registra as perguntas geradas para o trecho.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'perguntas' => [ 'type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'maxItems' => 3, ], ], 'required' => ['perguntas'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => 'registrar_perguntas'], 'messages' => [[ 'role' => 'user', 'content' => "<trecho>{$content}</trecho>", ]], ])->throw(); $toolUse = collect($response->json('content'))->firstWhere('type', 'tool_use'); return $toolUse['input']['perguntas'] ?? []; } }
Rode php artisan rag:golden-generate --sample=50 e depois leia as perguntas. Desative (active = false) as que ficaram genéricas demais ou que outros chunks também respondem. Esse filtro manual de 15 minutos vale mais que dobrar o tamanho do set. Gerar perguntas para algumas centenas de chunks com Haiku custa pouco; se quiser estimar antes, a calculadora de custo de IA faz a conta por modelo.
3. Como plugar estratégias de busca diferentes?
A avaliação só é útil se você conseguir comparar. Por isso o comando não conhece SQL; ele recebe um Retriever que devolve as chaves ordenadas por relevância.
namespace App\Rag; interface Retriever { /** @return list<string> chaves "document_id:chunk_index" em ordem de relevância */ public function retrieve(string $question, int $k): array; }
O embedder tem cache, porque o golden set roda dezenas de vezes e não faz sentido pagar o embedding da mesma pergunta toda vez:
namespace App\Rag; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; final class OpenAiEmbedder { public function embed(string $text): array { $model = config('services.openai.embedding_model'); return Cache::rememberForever("emb:{$model}:".sha1($text), fn () => Http::withToken(config('services.openai.key')) ->timeout(20) ->retry(3, 500) ->post('https://api.openai.com/v1/embeddings', [ 'model' => $model, 'input' => $text, ]) ->throw() ->json('data.0.embedding')); } }
Repare que o nome do modelo está na chave do cache. Se você trocar de modelo de embedding para comparar, não quer reaproveitar vetores do modelo antigo.
namespace App\Rag; use Illuminate\Support\Facades\DB; final class VectorRetriever implements Retriever { public function __construct(private OpenAiEmbedder $embedder) {} public function retrieve(string $question, int $k): array { $vector = '['.implode(',', $this->embedder->embed($question)).']'; return DB::table('chunks') ->selectRaw("concat(document_id, ':', chunk_index) as chunk_key") ->orderByRaw('embedding <=> ?::vector', [$vector]) ->limit($k) ->pluck('chunk_key') ->all(); } }
Registre as estratégias em config/rag.php:
return [ 'retrievers' => [ 'vector' => App\Rag\VectorRetriever::class, 'hybrid' => App\Rag\HybridRetriever::class, ], ];
O HybridRetriever é a mesma ideia combinando pgvector e full-text com RRF, como mostrei em busca híbrida com pgvector e full-text search do PostgreSQL. Basta ele devolver as chaves no mesmo formato.
4. Como calcular recall@k, MRR e hit rate?
As três métricas respondem perguntas diferentes:
| Métrica | Pergunta que responde | Quando olhar |
|---|---|---|
| Hit rate@k | Veio pelo menos um chunk certo no top-k? | Visão geral, fácil de explicar |
| Recall@k | Quanto do que era necessário veio? | Perguntas que dependem de vários trechos |
| MRR | O chunk certo veio no topo ou lá embaixo? | Quando você corta o contexto ou faz rerank |
namespace App\Rag; final class RetrievalMetrics { /** * @param list<string> $retrieved * @param list<string> $expected * @return array{hit: float, recall: float, rr: float} */ public static function score(array $retrieved, array $expected): array { $expected = array_values(array_unique($expected)); $found = array_intersect($expected, $retrieved); $firstRank = null; foreach ($retrieved as $position => $key) { if (in_array($key, $expected, true)) { $firstRank = $position + 1; break; } } return [ 'hit' => $firstRank !== null ? 1.0 : 0.0, 'recall' => count($found) / max(count($expected), 1), 'rr' => $firstRank !== null ? 1 / $firstRank : 0.0, ]; } }
Teste com Pest, porque essa classe é pura e um bug aqui invalida todo o resto:
use App\Rag\RetrievalMetrics; it('calcula recall e reciprocal rank', function () { $score = RetrievalMetrics::score(['9:0', '42:1', '42:0'], ['42:0', '42:1', '7:3']); expect($score['hit'])->toBe(1.0) ->and($score['recall'])->toEqualWithDelta(2 / 3, 0.0001) ->and($score['rr'])->toBe(0.5); }); it('zera tudo quando nada esperado volta', function () { expect(RetrievalMetrics::score(['1:0'], ['2:0'])) ->toBe(['hit' => 0.0, 'recall' => 0.0, 'rr' => 0.0]); });
5. Como rodar a avaliação com um comando Artisan?
namespace App\Console\Commands; use App\Models\RagEvalQuery; use App\Models\RagEvalRun; use App\Rag\RetrievalMetrics; use Illuminate\Console\Command; class RagEval extends Command { protected $signature = 'rag:eval {--strategy=vector} {--k=5} {--max-drop=0.02}'; protected $description = 'Mede a qualidade do retrieval contra o golden set'; public function handle(): int { $strategy = $this->option('strategy'); $k = (int) $this->option('k'); $retriever = app(config("rag.retrievers.{$strategy}")); $queries = RagEvalQuery::where('active', true)->get(); $totals = ['hit' => 0, 'recall' => 0, 'rr' => 0]; $failures = []; $this->withProgressBar($queries, function (RagEvalQuery $query) use ($retriever, $k, &$totals, &$failures) { $retrieved = $retriever->retrieve($query->question, $k); $score = RetrievalMetrics::score($retrieved, $query->expected_keys); foreach ($totals as $metric => $sum) { $totals[$metric] = $sum + $score[$metric]; } if ($score['hit'] === 0.0) { $failures[] = ['id' => $query->id, 'question' => $query->question, 'got' => $retrieved]; } }); $n = max($queries->count(), 1); $previous = RagEvalRun::where('strategy', $strategy)->where('k', $k)->latest()->first(); $run = RagEvalRun::create([ 'strategy' => $strategy, 'k' => $k, 'total_queries' => $queries->count(), 'hit_rate' => round($totals['hit'] / $n, 4), 'recall' => round($totals['recall'] / $n, 4), 'mrr' => round($totals['rr'] / $n, 4), 'git_sha' => trim((string) @shell_exec('git rev-parse --short HEAD')) ?: null, 'failures' => $failures, ]); $this->newLine(2); $this->table(['métrica', 'atual', 'anterior'], [ ['hit_rate@'.$k, $run->hit_rate, $previous?->hit_rate ?? '-'], ['recall@'.$k, $run->recall, $previous?->recall ?? '-'], ['mrr', $run->mrr, $previous?->mrr ?? '-'], ]); if ($previous && ($previous->recall - $run->recall) > (float) $this->option('max-drop')) { $this->error('Recall caiu além do limite em relação à última execução.'); return self::FAILURE; } return self::SUCCESS; } }
Como testar:
php artisan rag:eval --strategy=vector --k=5 php artisan rag:eval --strategy=hybrid --k=5
A coluna failures é a parte mais valiosa. Abra as perguntas que erraram e olhe o que voltou no lugar. Normalmente você encontra um destes padrões: chunk cortado no meio de uma tabela, documento duplicado em duas versões, ou pergunta com termo técnico exato (código de erro, nome de plano) que a busca vetorial sozinha não pega.
6. Como usar a avaliação para decidir mudanças no RAG?
O fluxo que sigo sempre que vou mexer em algo que afeta o retrieval:
- Rodo
rag:evalna estratégia atual para ter a linha de base registrada. - Faço a mudança (tamanho do chunk, overlap, modelo de embedding, pesos da híbrida) em uma base de staging reindexada.
- Rodo de novo com os mesmos
--ke comparo hit rate, recall e MRR. - Leio as falhas novas, não só os números agregados. Uma melhora de 3 pontos que quebrou todas as perguntas sobre estorno não é melhora.
- Só então reindexo produção.
Vale também agendar uma execução semanal para pegar degradação silenciosa, como documentos novos que poluem resultados antigos:
// routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('rag:eval --strategy=hybrid --k=5')->weeklyOn(1, '06:00');
Essa avaliação mede só a busca. Se a resposta final do modelo está boa já é outra camada, que cubro em monitorar a qualidade do agente com LLM-as-judge. Separar as duas é o que permite saber de quem é a culpa quando algo sai errado.
Erros comuns
- Golden set apontando para chunks que não existem mais. Se a reindexação muda o
chunk_index, o recall despenca sem a busca ter piorado. Antes de rodar, verifique se todas asexpected_keysexistem na tabelachunkse marque as órfãs. Se o seu chunking muda muito, avalie no nível de documento (document_id) em vez de chunk. - Perguntas sintéticas que copiam o texto do trecho. O modelo tende a reaproveitar frases do chunk, e aí qualquer busca lexical acerta tudo. O resultado é uma híbrida que parece perfeita no eval e decepciona em produção. Por isso o prompt pede palavras próprias e por isso vale misturar perguntas reais tiradas dos logs.
- Índice HNSW com
ef_searchbaixo. O índice HNSW do pgvector é aproximado; com o padrão dehnsw.ef_search = 40e k alto, parte dos vizinhos certos fica de fora. Se o recall com índice for bem menor que sem índice, testeSET hnsw.ef_search = 100(ouSET LOCALdentro de uma transação) antes de culpar o embedding. - Rodar sem o filtro de tenant. Em app multi-tenant, a busca de produção filtra por cliente. Se o eval roda sem filtro, ele compete com chunks de todo mundo e mede um cenário que não existe. Guarde o
tenant_idna pergunta e aplique o mesmo filtro noRetriever. - Uma única chave esperada quando várias respondem. Se dois documentos explicam o mesmo procedimento, marcar só um penaliza a busca por acertar. Adicione todas as chaves válidas em
expected_keys. - Comparar execuções com k diferentes. Recall@10 sempre parece melhor que recall@5. Compare só execuções com o mesmo k, como o comando já faz ao buscar a anterior.
Perguntas frequentes
Quantas perguntas o golden set precisa ter?
Comece com 50 a 100 perguntas revisadas cobrindo os assuntos principais. Com menos que isso, uma ou duas perguntas mudam o resultado em vários pontos percentuais. Vá adicionando perguntas reais que falharam em produção.
Devo otimizar recall@k ou MRR?
Recall@k primeiro: se o trecho certo não chega ao contexto, o modelo não tem como responder. MRR passa a importar quando você reduz o número de chunks enviados ao LLM para economizar tokens ou quando adiciona um reranker.
Posso usar um LLM para julgar a relevância em vez de manter um golden set?
Pode, como complemento, mas um juiz gera ruído e custo a cada execução. O golden set é determinístico e barato de rodar, o que o torna ideal para comparar estratégias e barrar regressões.
Qual valor de k usar na avaliação?
Use o mesmo número de chunks que você realmente manda para o modelo em produção. Medir com k=20 quando o prompt recebe 5 trechos dá um número bonito que não reflete o que o usuário vê.
Isso substitui testar o código do RAG?
Não. Testes unitários garantem que o código funciona; o eval mede qualidade sobre dados reais. Os dois rodam em momentos diferentes.
Conclusão prática
Se você tem um RAG em produção e nunca mediu o retrieval, faça hoje o mínimo: gere 50 perguntas sintéticas, revise à mão, rode rag:eval na estratégia atual e guarde esse número. A partir daí, toda mudança em chunking, embedding ou busca passa a ter uma pergunta objetiva antes do deploy: o recall subiu ou caiu? Leia as falhas uma por uma na primeira execução; é ali que aparecem os problemas de chunking que nenhum ajuste de prompt resolveria.