Para reranquear os resultados do RAG no Laravel, você busca de 30 a 50 candidatos no pgvector e manda a pergunta com esses trechos para um endpoint de rerank, como o /v1/rerank da Voyage AI. Depois usa só os trechos com melhor relevance_score como contexto para o Claude. A busca vetorial fica responsável pelo recall, o reranker pela precisão, e o Claude recebe menos texto e um texto melhor.
No final deste tutorial você vai ter um pipeline RetrieveAndRerank que busca candidatos no PostgreSQL com pgvector e reordena com a Voyage AI. Ele corta por score mínimo e, se a API de rerank falhar, volta para a ordem vetorial. Também vai ter um comando Artisan que mostra lado a lado a ordem antes e depois do rerank e um teste com Http::fake.
Pré-requisitos:
- Laravel 11 ou 12 com PHP 8.2+.
- PostgreSQL com a extensão pgvector e uma tabela de chunks com embeddings já populada. Se ainda não tem, comece pelo post de RAG com pgvector no Laravel.
- Uma chave de API da Voyage AI (
VOYAGE_API_KEY). - Nenhum pacote extra: tudo é feito com o
Http::do Laravel.
Por que a busca vetorial sozinha erra a ordem dos resultados?
Embedding é um vetor que representa o significado de um texto. A busca vetorial compara o vetor da pergunta com o vetor de cada chunk, que é um pedaço de documento indexado. Esse modelo se chama bi-encoder: pergunta e documento viram vetores separadamente, sem que um "veja" o outro. Isso é rápido e escala para milhões de linhas, mas perde nuances. "Como cancelar a assinatura" e "como assinar o plano anual" ficam próximos no espaço vetorial, porque falam do mesmo assunto, mas respondem perguntas opostas.
Um reranker (normalmente um cross-encoder) recebe a pergunta e o documento juntos e devolve uma nota de relevância para o par. É bem mais preciso e bem mais caro por item, por isso não dá para rodar contra a base inteira. A arquitetura padrão tem dois estágios:
| Estágio | Ferramenta | Objetivo | Quantidade |
|---|---|---|---|
| Recuperação | pgvector (ou busca híbrida) | Não deixar o trecho certo de fora (recall) | 30 a 50 candidatos |
| Reordenação | Reranker | Colocar o trecho certo no topo (precisão) | 3 a 8 trechos |
| Geração | Claude | Responder usando só o que sobrou | — |
Na prática, o sintoma que leva ao reranker é este: o trecho certo aparece no top 20 da busca vetorial, mas não no top 5 que você manda para o LLM. Quando isso acontece, aumentar o top_k do contexto só enche o prompt de ruído. Reordenar resolve melhor.
1. Configurar a chave e o modelo no .env
VOYAGE_API_KEY=pa-xxxxxxxxxxxxxxxx VOYAGE_RERANK_MODEL=rerank-2 VOYAGE_TIMEOUT=5 RAG_CANDIDATES=40 RAG_TOP_K=5 RAG_MIN_SCORE=0
Em config/services.php:
'voyage' => [ 'key' => env('VOYAGE_API_KEY'), 'rerank_model' => env('VOYAGE_RERANK_MODEL', 'rerank-2'), 'timeout' => (int) env('VOYAGE_TIMEOUT', 5), ],
E um config/rag.php para os parâmetros do pipeline:
<?php return [ 'candidates' => (int) env('RAG_CANDIDATES', 40), 'top_k' => (int) env('RAG_TOP_K', 5), 'min_score' => (float) env('RAG_MIN_SCORE', 0), ];
O min_score começa em zero de propósito. O valor certo depende do modelo e dos seus dados, e você vai calibrar no passo 7. Confira na documentação da Voyage os modelos de rerank disponíveis e os limites de tokens por requisição antes de fixar o nome.
2. Criar a interface do reranker
O domínio não deve saber se quem reordena é a Voyage, a Cohere ou um modelo próprio. Uma interface pequena resolve:
<?php namespace App\Rag\Rerank; interface Reranker { /** * @param list<string> $documents * @return list<array{index: int, score: float}> ordenado do mais para o menos relevante */ public function rerank(string $query, array $documents, int $topK): array; }
Repare que o retorno usa index, a posição no array enviado, e não o ID do chunk. É assim que as APIs de rerank respondem, e esse detalhe causa o erro mais comum da integração (veja a seção de erros).
3. Implementar o VoyageReranker com Http::
A requisição para a Voyage é um POST simples:
{ "query": "como cancelar a assinatura?", "documents": ["trecho 1...", "trecho 2..."], "model": "rerank-2", "top_k": 5 }
A resposta traz uma lista data com index e relevance_score para cada documento, já ordenada por relevância. Mesmo assim, eu reordeno do meu lado para não depender disso.
<?php namespace App\Rag\Rerank; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class VoyageReranker implements Reranker { public function rerank(string $query, array $documents, int $topK): array { if ($documents === []) { return []; } $response = Http::withToken(config('services.voyage.key')) ->acceptJson() ->connectTimeout(2) ->timeout(config('services.voyage.timeout')) ->retry(2, 300, function ($exception) { if ($exception instanceof ConnectionException) { return true; } return $exception instanceof RequestException && ($exception->response->status() === 429 || $exception->response->serverError()); }) ->post('https://api.voyageai.com/v1/rerank', [ 'query' => $query, 'documents' => array_values($documents), 'model' => config('services.voyage.rerank_model'), 'top_k' => min($topK, count($documents)), ]) ->throw(); return collect($response->json('data', [])) ->map(fn (array $item) => [ 'index' => (int) $item['index'], 'score' => (float) $item['relevance_score'], ]) ->sortByDesc('score') ->values() ->all(); } }
Três decisões aqui:
- Timeout curto. O padrão do client HTTP do Laravel é 30 segundos. Num atendimento pelo WhatsApp, esperar 30s pelo reranker é pior do que responder com a ordem vetorial. Cinco segundos já é generoso.
- Retry só para erro transitório. Conexão caída, 429 e 5xx valem nova tentativa. 400 e 401 não: são bug ou chave errada, e repetir só gasta tempo.
array_values. Garante que os índices enviados são 0..n-1. Se você mandar um array com chaves soltas, oindexdevolvido não bate com nada.
Registre o binding no AppServiceProvider:
use App\Rag\Rerank\Reranker; use App\Rag\Rerank\VoyageReranker; public function register(): void { $this->app->bind(Reranker::class, VoyageReranker::class); }
4. Buscar os candidatos no pgvector
O primeiro estágio precisa devolver mais candidatos do que você vai usar. Assumo uma tabela chunks com tenant_id, document_title, content e embedding vector(...), e um Embedder que você já usa para gerar o embedding da pergunta.
<?php namespace App\Rag; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class VectorSearch { /** @param list<float> $queryEmbedding */ public function candidates(array $queryEmbedding, int $tenantId, int $limit): Collection { $vector = '['.implode(',', $queryEmbedding).']'; $rows = DB::select( 'select id, document_title, content, embedding <=> ?::vector as distance from chunks where tenant_id = ? order by embedding <=> ?::vector limit ?', [$vector, $tenantId, $vector, $limit] ); return collect($rows)->values(); } }
O filtro por tenant_id fica aqui, antes do rerank. O reranker não sabe nada de permissão: ele ordena o que você mandar. Se um chunk de outro cliente entrar na lista de candidatos, ele pode muito bem ir parar no topo. Se você já faz busca híbrida com pgvector e full-text search, use o resultado do RRF como candidatos. O reranker funciona ainda melhor com candidatos vindos de duas fontes.
5. Montar o pipeline RetrieveAndRerank com fallback
Agora juntamos os dois estágios. Se o reranker falhar, o pipeline devolve os primeiros candidatos na ordem vetorial. Fica pior, mas responde.
<?php namespace App\Rag; use App\Rag\Rerank\Reranker; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Throwable; class RetrieveAndRerank { public function __construct( private Embedder $embedder, private VectorSearch $search, private Reranker $reranker, ) {} public function search(string $query, int $tenantId, ?int $topK = null): Collection { $topK ??= config('rag.top_k'); $candidates = $this->search->candidates( $this->embedder->embedQuery($query), $tenantId, config('rag.candidates'), ); if ($candidates->isEmpty()) { return collect(); } $documents = $candidates ->map(fn ($c) => $c->document_title."\n\n".$c->content) ->all(); try { $ranked = $this->reranker->rerank($query, $documents, $topK); } catch (Throwable $e) { Log::warning('rerank.failed', ['error' => $e->getMessage(), 'tenant_id' => $tenantId]); return $candidates->take($topK)->each(fn ($c) => $c->rerank_score = null); } $minScore = config('rag.min_score'); return collect($ranked) ->filter(fn (array $r) => $r['score'] >= $minScore) ->map(function (array $r) use ($candidates) { $chunk = $candidates[$r['index']]; $chunk->rerank_score = $r['score']; return $chunk; }) ->values(); } }
O texto enviado ao reranker inclui o título do documento. Chunk solto, sem contexto, é o que mais derruba a qualidade do rerank. Um trecho que diz "o prazo é de 7 dias" não significa nada sem saber se veio da política de reembolso ou do manual de entrega.
O resultado vai direto para o prompt do Claude. Menos trechos e mais relevantes também significam menos tokens de entrada por chamada. Dá para estimar a diferença no mês com a calculadora de custo de IA, comparando 5 trechos contra os 15 ou 20 que você mandaria sem rerank.
6. Criar um comando Artisan para comparar antes e depois
Antes de ligar em produção, quero ver a reordenação com meus próprios olhos. Este comando mostra a posição vetorial original de cada trecho e a nota do reranker:
<?php namespace App\Console\Commands; use App\Rag\RetrieveAndRerank; use Illuminate\Console\Command; use Illuminate\Support\Str; class RagCompare extends Command { protected $signature = 'rag:compare {query} {--tenant=1} {--top=5}'; protected $description = 'Mostra os trechos do RAG antes e depois do rerank'; public function handle(RetrieveAndRerank $pipeline): int { $results = $pipeline->search( $this->argument('query'), (int) $this->option('tenant'), (int) $this->option('top'), ); $this->table( ['#', 'chunk', 'distância', 'score rerank', 'trecho'], $results->map(fn ($c, $i) => [ $i + 1, $c->id, round($c->distance, 4), $c->rerank_score === null ? '—' : round($c->rerank_score, 4), Str::limit($c->content, 80), ])->all() ); return self::SUCCESS; } }
php artisan rag:compare "como cancelo minha assinatura?" --tenant=1
Quando o rerank está fazendo diferença, a coluna de distância fica fora de ordem: um chunk com distância maior aparece no topo porque o reranker entendeu que ele responde a pergunta. Se as duas ordens saem sempre iguais, ou seus candidatos são poucos ou a busca vetorial já estava boa o suficiente para esse tipo de pergunta.
Para o teste automatizado, finja a API e verifique o mapeamento de índices:
use App\Rag\Rerank\VoyageReranker; use Illuminate\Support\Facades\Http; it('ordena pelo score e preserva o índice enviado', function () { Http::fake([ 'api.voyageai.com/*' => Http::response([ 'data' => [ ['index' => 2, 'relevance_score' => 0.91], ['index' => 0, 'relevance_score' => 0.35], ], ]), ]); $result = (new VoyageReranker)->rerank('pergunta', ['a', 'b', 'c'], 2); expect($result[0])->toBe(['index' => 2, 'score' => 0.91]) ->and($result[1]['index'])->toBe(0); Http::assertSent(fn ($request) => $request['top_k'] === 2 && $request['documents'] === ['a', 'b', 'c']); });
7. Medir o ganho e calibrar o score mínimo
Reranker sem medição é fé. Use o mesmo golden set (lista de perguntas com os chunks que deveriam aparecer) que você já usa para avaliar a busca. Se ainda não tem, o post sobre avaliar a busca do RAG com golden set, recall@k e MRR mostra como montar. Recall@k é a fração de perguntas em que o chunk certo aparece entre os k primeiros. MRR (mean reciprocal rank) é a média de 1/posição do primeiro chunk certo.
O roteiro que sigo:
- Rode o golden set só com a busca vetorial, no top 5. Anote recall@5 e MRR.
- Rode com
RAG_CANDIDATES=40e rerank, no top 5. Compare. - Confira o recall@40 da busca vetorial sozinha. Ele é o teto do reranker: se o chunk certo não está nos 40 candidatos, não há reordenação que salve.
- Para o
min_score, liste os scores dos chunks corretos e dos incorretos no golden set e escolha um corte que elimine a maioria dos incorretos sem perder os corretos. Esse número vale para aquele modelo e aqueles dados. Se trocar de modelo, calibre de novo. - Meça a latência p95 do rerank em produção e compare com o orçamento de tempo da resposta.
Se o MRR não sobe, não ligue o reranker só porque é a recomendação do momento. Ele adiciona uma chamada de rede, um fornecedor e um custo por requisição.
Erros comuns
- Confundir
indexcom ID do chunk. A API devolve a posição no arraydocumentsenviado. Se você monta o array a partir de uma Collection com chaves não sequenciais, ou reordena antes de mapear, o trecho errado vai para o prompt. Sempre use->values()earray_values()e mapeie pela mesma Collection que gerou o array. - Poucos candidatos. Mandar 5 candidatos para pegar o top 5 não reordena nada de útil. O ganho vem de buscar mais (30 a 50) e filtrar.
- Candidatos demais. Mandar 300 trechos longos aumenta a latência e o custo e pode bater no limite de tokens por requisição. Se precisar de muitos candidatos, diminua o tamanho dos chunks antes de aumentar a quantidade.
- Threshold copiado de outro lugar.
relevance_scorenão é uma probabilidade comparável entre modelos nem entre provedores. Um corte que funcionou num tutorial com outro modelo pode descartar tudo no seu caso, e o Claude acaba respondendo "não encontrei informação" para perguntas que a base cobre. - Timeout padrão de 30 segundos. Sem
->timeout(), uma lentidão da API trava o worker e o usuário. Defina timeout curto e tenha fallback. - Filtrar permissão depois do rerank. O reranker não conhece tenant, papel ou documento privado. Filtre na query dos candidatos, nunca depois.
- Chunk sem contexto. Trechos que começam com "Além disso," ou "Nesse caso," recebem score baixo porque não se sustentam sozinhos. Prefixe com título do documento e da seção.
- 401 depois do deploy. Geralmente é
config:cacherodado antes de a variávelVOYAGE_API_KEYexistir no ambiente. Rodephp artisan config:cleare gere o cache de novo.
Perguntas frequentes
Preciso de reranker se já faço busca híbrida?
Não obrigatoriamente. A busca híbrida melhora o recall, porque junta termos exatos e semântica. O reranker melhora a ordem no topo. Se o seu MRR com busca híbrida já está bom no golden set, o reranker pode não compensar a latência extra. Meça antes.
Posso usar o próprio Claude como reranker?
Pode: você manda a pergunta e os candidatos numerados e pede uma lista ordenada em saída estruturada. Funciona, mas costuma ser mais lento e mais caro por requisição do que um modelo de rerank dedicado, e a nota não é tão estável. Uso essa saída só quando preciso de critério de negócio que um reranker genérico não captura.
Quantos candidatos devo mandar para o rerank?
Comece com 30 a 50 e ajuste olhando o recall@N da busca vetorial: N deve ser grande o suficiente para que o chunk certo quase sempre esteja entre os candidatos. Acima disso você só paga latência.
O rerank da Voyage funciona bem em português?
Os modelos de rerank atuais da Voyage são multilíngues, mas a única resposta confiável para o seu caso é o golden set em português, com as perguntas do jeito que os seus usuários escrevem, inclusive com erros de digitação.
Dá para trocar a Voyage pela Cohere depois?
Sim, e é para isso que existe a interface Reranker. A Cohere também tem endpoint de rerank que devolve índice e score de relevância. Basta uma nova implementação e trocar o binding no AppServiceProvider. Recalibre o min_score, porque a escala de score muda.
Conclusão prática
Comece pelo comando rag:compare com 10 perguntas reais que hoje recebem resposta ruim. Se o trecho certo estiver nos 40 candidatos mas fora do top 5, o reranker vai resolver e o golden set vai mostrar o ganho em MRR. Se o trecho certo nem aparece nos candidatos, o problema está no chunking ou na busca, e nenhum reranker conserta isso. Ligue o rerank com timeout curto, fallback para a ordem vetorial e min_score calibrado nos seus dados.