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

  1. Uma tabela com o golden set, alimentada por perguntas reais e por perguntas sintéticas geradas com Claude.
  2. Uma interface Retriever que permite plugar estratégias diferentes (vetorial, híbrida, com filtro etc.).
  3. Uma classe que calcula hit rate, recall@k e MRR.
  4. O comando php artisan rag:eval que 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 chunks já 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:

  1. Rodo rag:eval na estratégia atual para ter a linha de base registrada.
  2. Faço a mudança (tamanho do chunk, overlap, modelo de embedding, pesos da híbrida) em uma base de staging reindexada.
  3. Rodo de novo com os mesmos --k e comparo hit rate, recall e MRR.
  4. 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.
  5. 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

  1. 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 as expected_keys existem na tabela chunks e marque as órfãs. Se o seu chunking muda muito, avalie no nível de documento (document_id) em vez de chunk.
  2. 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.
  3. Índice HNSW com ef_search baixo. O índice HNSW do pgvector é aproximado; com o padrão de hnsw.ef_search = 40 e k alto, parte dos vizinhos certos fica de fora. Se o recall com índice for bem menor que sem índice, teste SET hnsw.ef_search = 100 (ou SET LOCAL dentro de uma transação) antes de culpar o embedding.
  4. 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_id na pergunta e aplique o mesmo filtro no Retriever.
  5. 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.
  6. 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.