# Como reranquear os resultados do RAG no Laravel com o rerank da Voyage AI

> Passo a passo para colocar um reranker entre o pgvector e o Claude no Laravel: buscar candidatos, reordenar com a Voyage AI, cortar por score e medir o ganho.

Por Lucas Roberto · IA · publicado em 2026-10-25 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-reranquear-os-resultados-do-rag-no-laravel-com-o-rerank-da-voyage-ai

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](/blog/como-fazer-rag-com-pgvector-no-laravel-embeddings-busca-vetorial-e-respostas-com-claude).
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

```dotenv
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`:

```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
<?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
<?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:

```json
{
  "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
<?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`:

```php
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
<?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](/blog/como-fazer-busca-hibrida-no-laravel-com-pgvector-e-full-text-search-do-postgresql), 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
<?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](/ferramentas/calculadora-custo-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
<?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;
    }
}
```

```bash
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:

```php
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](/blog/como-avaliar-a-busca-do-rag-no-laravel-com-golden-set-recall-at-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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
