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:

  1. Laravel 11 ou 12 com PHP 8.2+.
  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.
  3. Uma chave de API da Voyage AI (VOYAGE_API_KEY).
  4. 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:

  1. 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.
  2. 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.
  3. array_values. Garante que os índices enviados são 0..n-1. Se você mandar um array com chaves soltas, o index devolvido 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:

  1. Rode o golden set só com a busca vetorial, no top 5. Anote recall@5 e MRR.
  2. Rode com RAG_CANDIDATES=40 e rerank, no top 5. Compare.
  3. 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.
  4. 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.
  5. 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

  1. Confundir index com ID do chunk. A API devolve a posição no array documents enviado. 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() e array_values() e mapeie pela mesma Collection que gerou o array.
  2. 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.
  3. 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.
  4. Threshold copiado de outro lugar. relevance_score nã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.
  5. 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.
  6. Filtrar permissão depois do rerank. O reranker não conhece tenant, papel ou documento privado. Filtre na query dos candidatos, nunca depois.
  7. 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.
  8. 401 depois do deploy. Geralmente é config:cache rodado antes de a variável VOYAGE_API_KEY existir no ambiente. Rode php artisan config:clear e 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.