# Como fazer busca híbrida no Laravel com pgvector e full-text search do PostgreSQL

> Passo a passo para combinar busca vetorial (pgvector) e full-text do PostgreSQL com Reciprocal Rank Fusion no Laravel e parar de errar siglas, códigos e termos exatos no RAG.

Por Lucas Roberto · IA · publicado em 2026-10-16 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-fazer-busca-hibrida-no-laravel-com-pgvector-e-full-text-search-do-postgresql

Busca híbrida é rodar, na mesma consulta, uma busca vetorial com pgvector e uma busca full-text do PostgreSQL, e depois fundir os dois rankings com Reciprocal Rank Fusion (RRF). No Laravel, isso cabe em uma query SQL com duas CTEs e um `FULL OUTER JOIN`, sem Elasticsearch e sem serviço extra. Resolve o defeito clássico do RAG que entende paráfrase mas erra termo exato: código de erro, sigla como MDR, nome de plano, número de lei.

Esse problema aparece rápido em base de ajuda de fintech. O cliente pergunta "o que é o erro 51 na maquininha" e a busca semântica devolve um texto genérico sobre transações recusadas, porque para o embedding "51" é quase ruído. Já a busca por palavra-chave acha o chunk certo na hora, mas se perde quando a pessoa escreve "quanto vocês cobram quando eu passo no débito" em vez de "taxa de débito". Uma cobre o buraco da outra.

## O que você vai ter funcionando no final

1. Uma tabela `document_chunks` com coluna `vector(1536)` indexada com HNSW e uma coluna `tsvector` gerada automaticamente, com stemming em português e sem acentos.
2. Um cliente de embeddings da OpenAI feito com `Http::` do Laravel.
3. Uma classe `HybridSearch` que executa as duas buscas e funde com RRF em uma única ida ao banco.
4. Um comando Artisan para comparar os rankings e outro para medir hit@5 e MRR com perguntas reais.

Pré-requisitos:

| Item | Versão / detalhe |
|---|---|
| PHP | 8.2+ |
| Laravel | 11 ou 12 |
| PostgreSQL | 15+ com as extensões `vector` (pgvector 0.8+) e `unaccent` (vem no contrib) |
| Chave de API | `OPENAI_API_KEY` para o endpoint de embeddings |
| Base de conteúdo | Documentos já quebrados em chunks |

Se você ainda não tem o pipeline de chunking e geração de resposta, comece pelo [tutorial de RAG com pgvector no Laravel](/blog/como-fazer-rag-com-pgvector-no-laravel-embeddings-busca-vetorial-e-respostas-com-claude). Aqui eu troco só a etapa de recuperação; a parte de montar o prompt e chamar o Claude continua igual.

Três definições rápidas antes do código. **Embedding** é um vetor de números que representa o significado de um texto; textos parecidos geram vetores próximos. **Full-text search** no PostgreSQL é a busca por lexemas (palavras normalizadas por stemming) usando o tipo `tsvector` e consultas `tsquery`. **RRF** é uma forma de juntar rankings diferentes somando `1 / (k + posição)` de cada documento em cada lista, sem precisar normalizar scores que estão em escalas incompatíveis.

## 1. Como criar a tabela com vetor e tsvector no PostgreSQL?

O detalhe que mais trava gente aqui é o acento. Usar `unaccent()` direto numa coluna gerada dá erro, porque a função não é `IMMUTABLE`. O jeito certo é criar uma configuração de text search própria que aplica `unaccent` antes do stemming em português. Assim "cartão", "cartao" e "cartões" viram o mesmo lexema.

```bash
php artisan make:migration create_document_chunks_table
```

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
        DB::statement('CREATE EXTENSION IF NOT EXISTS unaccent');

        DB::statement(<<<'SQL'
            DO $$
            BEGIN
                IF NOT EXISTS (SELECT 1 FROM pg_ts_config WHERE cfgname = 'pt_unaccent') THEN
                    CREATE TEXT SEARCH CONFIGURATION pt_unaccent (COPY = portuguese);
                    ALTER TEXT SEARCH CONFIGURATION pt_unaccent
                        ALTER MAPPING FOR hword, hword_part, word
                        WITH unaccent, portuguese_stem;
                END IF;
            END
            $$;
        SQL);

        DB::statement(<<<'SQL'
            CREATE TABLE document_chunks (
                id bigserial PRIMARY KEY,
                tenant_id bigint NOT NULL,
                document_id bigint NOT NULL,
                title text,
                content text NOT NULL,
                embedding vector(1536),
                search_vector tsvector GENERATED ALWAYS AS (
                    setweight(to_tsvector('pt_unaccent', coalesce(title, '')), 'A') ||
                    setweight(to_tsvector('pt_unaccent', content), 'B')
                ) STORED,
                created_at timestamp(0) NULL,
                updated_at timestamp(0) NULL
            )
        SQL);

        DB::statement('CREATE INDEX document_chunks_tenant_idx ON document_chunks (tenant_id)');
        DB::statement('CREATE INDEX document_chunks_embedding_idx ON document_chunks USING hnsw (embedding vector_cosine_ops)');
        DB::statement('CREATE INDEX document_chunks_search_idx ON document_chunks USING gin (search_vector)');
    }

    public function down(): void
    {
        DB::statement('DROP TABLE IF EXISTS document_chunks');
        DB::statement('DROP TEXT SEARCH CONFIGURATION IF EXISTS pt_unaccent');
    }
};
```

O `setweight` dá peso maior ao título (A) que ao corpo (B), e o `ts_rank_cd` respeita isso na hora de ordenar. **HNSW** é o índice de vizinhos aproximados do pgvector: rápido, mas aproximado, e isso vai importar no passo 4. Se você já tem a tabela do post de RAG, basta um `ALTER TABLE ... ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (...) STORED` e o índice GIN.

## 2. Como gerar embeddings da OpenAI no Laravel sem SDK?

A Anthropic não tem endpoint próprio de embeddings, então uso o da OpenAI com o modelo `text-embedding-3-small`, que devolve 1536 dimensões por padrão. Chamo por HTTP mesmo, sem depender de pacote.

```dotenv
OPENAI_API_KEY=sk-...
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
```

```php
// config/services.php
'openai' => [
    'key' => env('OPENAI_API_KEY'),
    'embedding_model' => env('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
],
```

A requisição é um JSON simples:

```json
{
  "model": "text-embedding-3-small",
  "input": ["Qual a taxa no débito?", "Como antecipar recebíveis?"]
}
```

```php
<?php

namespace App\Services\Search;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;

final class EmbeddingClient
{
    /**
     * @param  list<string>  $texts
     * @return list<list<float>>
     */
    public function embed(array $texts): array
    {
        $response = Http::withToken(config('services.openai.key'))
            ->acceptJson()
            ->timeout(30)
            ->retry(3, 1000, function (Throwable $e): bool {
                if (! $e instanceof RequestException) {
                    return true; // erro de conexão, vale tentar de novo
                }

                return in_array($e->response->status(), [429, 500, 502, 503], true);
            })
            ->post('https://api.openai.com/v1/embeddings', [
                'model' => config('services.openai.embedding_model'),
                'input' => array_values($texts),
            ]);

        return collect($response->json('data'))
            ->sortBy('index')
            ->pluck('embedding')
            ->values()
            ->all();
    }

    /** @return list<float> */
    public function embedOne(string $text): array
    {
        return $this->embed([$text])[0];
    }

    public static function toPgVector(array $vector): string
    {
        return '[' . implode(',', $vector) . ']';
    }
}
```

Ordeno por `index` porque é o campo que garante a correspondência entre entrada e saída. E não retento 400: se o input passou do limite de tokens, tentar de novo só gasta tempo.

## 3. Como indexar os chunks existentes em lote?

```bash
php artisan make:command EmbedChunks
```

```php
<?php

namespace App\Console\Commands;

use App\Services\Search\EmbeddingClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class EmbedChunks extends Command
{
    protected $signature = 'chunks:embed';
    protected $description = 'Gera embeddings para chunks sem vetor';

    public function handle(EmbeddingClient $client): int
    {
        DB::table('document_chunks')
            ->whereNull('embedding')
            ->select(['id', 'title', 'content'])
            ->chunkById(100, function ($rows) use ($client) {
                $texts = $rows->map(
                    fn ($row) => trim(($row->title ?? '') . "\n\n" . $row->content)
                )->values()->all();

                $vectors = $client->embed($texts);

                DB::transaction(function () use ($rows, $vectors) {
                    foreach ($rows->values() as $i => $row) {
                        DB::update(
                            'UPDATE document_chunks SET embedding = CAST(? AS vector) WHERE id = ?',
                            [EmbeddingClient::toPgVector($vectors[$i]), $row->id]
                        );
                    }
                });

                $this->info("Lote até id {$rows->last()->id} indexado");
            });

        return self::SUCCESS;
    }
}
```

O `chunkById` é seguro aqui mesmo filtrando por `embedding IS NULL`, porque pagina por `id > último` e não por offset. Uso `CAST(? AS vector)` em vez de `?::vector` para não brigar com o parser de placeholders do PDO. A coluna `search_vector` não precisa de nada: o PostgreSQL recalcula sozinho em todo insert e update. Em produção, eu disparo esse mesmo trecho num job sempre que um documento é criado ou editado.

## 4. Como escrever a query híbrida com Reciprocal Rank Fusion?

A ideia: cada CTE devolve os N melhores candidatos com a posição (`rank`) de cada um. O `FULL OUTER JOIN` junta as listas, e quem aparece nas duas soma pontos das duas. O `k = 60` é o valor proposto no artigo original de RRF e funciona bem como ponto de partida.

```php
<?php

namespace App\Services\Search;

use Illuminate\Support\Facades\DB;

final class HybridSearch
{
    private const RRF_K = 60;

    public function __construct(private EmbeddingClient $embeddings) {}

    public function search(int $tenantId, string $query, int $limit = 8): array
    {
        $vector = $this->embeddings->embedOne($query);

        return $this->searchByVector($tenantId, $query, $vector, $limit);
    }

    public function searchByVector(
        int $tenantId,
        string $query,
        array $vector,
        int $limit = 8,
        int $candidates = 40,
        float $semanticWeight = 1.0,
        float $keywordWeight = 1.0,
    ): array {
        $k = self::RRF_K;

        $sql = <<<SQL
            WITH semantic AS (
                SELECT id, ROW_NUMBER() OVER (ORDER BY distance) AS rank
                FROM (
                    SELECT c.id, c.embedding <=> CAST(? AS vector) AS distance
                    FROM document_chunks c
                    WHERE c.tenant_id = ? AND c.embedding IS NOT NULL
                    ORDER BY distance
                    LIMIT ?
                ) s
            ),
            keyword AS (
                SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS rank
                FROM (
                    SELECT c.id, ts_rank_cd(c.search_vector, tsq) AS score
                    FROM document_chunks c,
                         websearch_to_tsquery('pt_unaccent', ?) AS tsq
                    WHERE c.tenant_id = ? AND c.search_vector @@ tsq
                    ORDER BY score DESC
                    LIMIT ?
                ) kw
            )
            SELECT c.id, c.document_id, c.title, c.content,
                   s.rank AS semantic_rank,
                   k.rank AS keyword_rank,
                   COALESCE(CAST(? AS float) / ({$k} + s.rank), 0)
                 + COALESCE(CAST(? AS float) / ({$k} + k.rank), 0) AS score
            FROM semantic s
            FULL OUTER JOIN keyword k ON k.id = s.id
            JOIN document_chunks c ON c.id = COALESCE(s.id, k.id)
            ORDER BY score DESC
            LIMIT ?
        SQL;

        $bindings = [
            EmbeddingClient::toPgVector($vector), $tenantId, $candidates,
            $query, $tenantId, $candidates,
            $semanticWeight, $keywordWeight,
            $limit,
        ];

        return DB::transaction(function () use ($sql, $bindings, $candidates) {
            DB::statement('SET LOCAL hnsw.ef_search = ' . max(40, $candidates * 2));
            DB::statement('SET LOCAL hnsw.iterative_scan = relaxed_order');

            return DB::select($sql, $bindings);
        });
    }
}
```

Algumas decisões que valem explicar:

1. **`ROW_NUMBER` fora da subquery.** Se eu colocar a window function junto do `ORDER BY ... LIMIT`, o planner pode calcular a janela sobre a tabela inteira e ignorar o índice HNSW. Ordenar e limitar primeiro, numerar depois, mantém o index scan.
2. **`websearch_to_tsquery` em vez de `to_tsquery`.** Ela aceita texto livre do usuário (aspas, `-palavra`, `or`) sem estourar erro de sintaxe.
3. **`SET LOCAL` dentro de transação.** O `hnsw.ef_search` controla quantos vizinhos o índice examina; ele precisa ser maior que o número de candidatos. O `hnsw.iterative_scan`, do pgvector 0.8, faz o índice continuar buscando quando o filtro por `tenant_id` descarta resultados. O `relaxed_order` pode devolver a lista levemente fora de ordem, mas como renumero por `distance` logo depois, não faz diferença.
4. **Pesos como parâmetro.** Com `semanticWeight = 1` e `keywordWeight = 0` eu tenho busca puramente semântica, e vice-versa. Isso serve para avaliar no passo 6.

## 5. Como testar a busca híbrida pelo terminal?

Antes de ligar no agente, quero ver as duas posições lado a lado. É aí que dá para entender por que um chunk subiu.

```bash
php artisan make:command CompareSearch
```

```php
<?php

namespace App\Console\Commands;

use App\Services\Search\HybridSearch;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

class CompareSearch extends Command
{
    protected $signature = 'search:compare {query} {--tenant=1} {--limit=8}';
    protected $description = 'Mostra o ranking híbrido com as posições de cada busca';

    public function handle(HybridSearch $search): int
    {
        $rows = $search->search(
            (int) $this->option('tenant'),
            $this->argument('query'),
            (int) $this->option('limit'),
        );

        $this->table(
            ['id', 'título', 'semântica', 'full-text', 'score'],
            collect($rows)->map(fn ($r) => [
                $r->id,
                Str::limit($r->title ?? $r->content, 50),
                $r->semantic_rank ?? '-',
                $r->keyword_rank ?? '-',
                number_format($r->score, 5),
            ])->all(),
        );

        return self::SUCCESS;
    }
}
```

```bash
php artisan search:compare "erro 51 na maquininha" --tenant=1
php artisan search:compare "quanto vocês cobram quando passo no débito" --tenant=1
```

Na primeira, o padrão típico é o chunk certo aparecer com `full-text` em 1 e `semântica` lá embaixo ou ausente. Na segunda, o contrário. O que você quer ver é o chunk certo no topo do score final nos dois casos.

## 6. Como avaliar se a busca híbrida é melhor que a semântica pura?

Não confie no olho. Monte um arquivo com perguntas reais, tiradas de tickets e conversas de suporte, e o id do chunk que deveria vir. Algumas dezenas já mostram tendência. Eu meço duas coisas: **hit@5** (o chunk esperado está entre os 5 primeiros?) e **MRR** (média de `1 / posição` do chunk esperado, que premia quem acerta no topo).

```json
[
  {"query": "erro 51 na maquininha", "expected_chunk_id": 412},
  {"query": "quanto custa passar no débito", "expected_chunk_id": 88}
]
```

```php
<?php

namespace App\Console\Commands;

use App\Services\Search\EmbeddingClient;
use App\Services\Search\HybridSearch;
use Illuminate\Console\Command;

class EvalSearch extends Command
{
    protected $signature = 'search:eval {--tenant=1}';
    protected $description = 'Compara hit@5 e MRR entre semântica, full-text e híbrida';

    public function handle(HybridSearch $search, EmbeddingClient $embeddings): int
    {
        $cases = json_decode(
            file_get_contents(base_path('tests/Fixtures/search_eval.json')),
            true,
            flags: JSON_THROW_ON_ERROR,
        );

        $modes = ['semântica' => [1.0, 0.0], 'full-text' => [0.0, 1.0], 'híbrida' => [1.0, 1.0]];
        $stats = array_fill_keys(array_keys($modes), ['hits' => 0, 'rr' => 0.0]);

        foreach ($cases as $case) {
            $vector = $embeddings->embedOne($case['query']);

            foreach ($modes as $mode => [$sw, $kw]) {
                $rows = $search->searchByVector(
                    (int) $this->option('tenant'), $case['query'], $vector,
                    limit: 10, semanticWeight: $sw, keywordWeight: $kw,
                );

                $ids = collect($rows)->filter(fn ($r) => $r->score > 0)->pluck('id')->values();
                $pos = $ids->search($case['expected_chunk_id']);

                if ($pos !== false) {
                    $stats[$mode]['rr'] += 1 / ($pos + 1);
                    $stats[$mode]['hits'] += $pos < 5 ? 1 : 0;
                }
            }
        }

        $total = count($cases);
        $this->table(['modo', 'hit@5', 'MRR'], collect($stats)->map(fn ($s, $mode) => [
            $mode,
            number_format($s['hits'] / $total, 2),
            number_format($s['rr'] / $total, 3),
        ])->values()->all());

        return self::SUCCESS;
    }
}
```

O filtro `score > 0` evita que, no modo full-text, os candidatos semânticos com peso zero preencham a lista e inflem o resultado. Calculo o embedding uma vez por pergunta e reaproveito nos três modos. Se você já tem uma suíte de evals para o LLM, esse comando encaixa no mesmo fluxo que descrevi em [como testar código que usa LLM com respostas gravadas e evals](/blog/como-testar-codigo-que-usa-llm-no-laravel-httpfake-respostas-gravadas-e-evals).

Com esse placar em mãos, ajustar fica objetivo. Se a sua base é cheia de códigos e nomes próprios, subir `keywordWeight` para 1.5 costuma ajudar; se as perguntas são conversacionais, o contrário. Mudou o peso, roda o eval de novo.

E tem um ganho de custo indireto: recuperação melhor permite mandar menos chunks para o Claude sem perder a resposta. Passar de 8 para 4 chunks por pergunta corta uma boa fatia do input; dá para estimar o impacto mensal na [calculadora de custo de IA](/ferramentas/calculadora-custo-ia).

## Erros comuns

1. **`generation expression is not immutable`** ao criar a coluna gerada. Acontece quando você escreve `to_tsvector('portuguese', unaccent(content))`. Resolva com a configuração `pt_unaccent` do passo 1, que coloca o `unaccent` como dicionário.
2. **Full-text não encontra nada.** Quase sempre é configuração diferente entre índice e consulta: a coluna usa `pt_unaccent` e a query usa `portuguese` ou `simple`. Os lexemas não batem. Use a mesma configuração dos dois lados.
3. **`syntax error in tsquery`.** Você usou `to_tsquery` com texto do usuário. Troque por `websearch_to_tsquery` ou `plainto_tsquery`.
4. **Busca semântica devolvendo menos resultados que o `LIMIT`.** O HNSW filtra depois de achar os vizinhos; em tenants pequenos numa tabela grande, quase todos são descartados. Use `hnsw.iterative_scan` (pgvector 0.8+) e `ef_search` maior que o número de candidatos. Em versões antigas, o `SET LOCAL hnsw.iterative_scan` falha, então confira a versão da extensão com `SELECT extversion FROM pg_extension WHERE extname = 'vector'`.
5. **`expected 1536 dimensions, not 3072`.** Trocaram para `text-embedding-3-large` sem migrar a coluna. Mudar de modelo exige nova coluna, reindexação completa e novo índice. Vetores de modelos diferentes não são comparáveis.
6. **Usar o score do RRF como limiar de "não sei".** O RRF só reflete posição, não relevância absoluta: o primeiro colocado sempre tem score alto, mesmo quando nada é relevante. Para decidir se o agente deve admitir que não sabe, olhe a distância vetorial do melhor resultado ou deixe o modelo avaliar o contexto.
7. **Consulta só com stopwords** ("como que faz isso"). O PostgreSQL emite um NOTICE e a tsquery fica vazia; a CTE de keyword volta sem linhas. Não é bug: a parte semântica segura sozinha, e é justamente para isso que a fusão existe.

## Perguntas frequentes

### Preciso de Elasticsearch ou OpenSearch para busca híbrida?

Não para a maioria dos apps Laravel. Com pgvector e o full-text nativo do PostgreSQL você tem as duas buscas no mesmo banco, na mesma transação e com o mesmo filtro de tenant. Só considero outro motor quando o volume ou a necessidade de relevância fina (sinônimos, fuzzy pesado) passa do que o PostgreSQL entrega bem.

### Qual valor de k usar no Reciprocal Rank Fusion?

Comece com 60, o valor do artigo original. Ele suaviza a diferença entre o 1º e o 5º lugar. Mexer nos pesos de cada busca costuma dar mais resultado que mexer no k.

### Dá para gerar embeddings com o Claude?

A Anthropic não oferece endpoint próprio de embeddings; a documentação dela recomenda a Voyage AI. Você pode usar Voyage, OpenAI ou outro provedor para os vetores e continuar usando o Claude para gerar a resposta. Só não misture provedores na mesma coluna.

### Vale adicionar um reranker depois da busca híbrida?

Pode valer, mas eu só adiciono depois que o eval mostra que o chunk certo está entre os 20 primeiros e não entre os 5. Reranker melhora ordem, não recupera o que a busca não trouxe, e adiciona latência e custo por pergunta.

### Quantos candidatos buscar em cada lista?

Entre 20 e 50 é um bom intervalo. Pouco demais e um documento bom numa lista não ganha a chance de somar pontos com a outra; muito demais e você paga em latência sem ganho. Ajuste olhando o hit@5 do comando de eval.

## Conclusão prática

Se o seu RAG já está em produção, não reescreva nada: adicione a coluna `search_vector` gerada, o índice GIN e troque a função de recuperação pela query com RRF. É uma migration e uma classe. Antes de mexer, rode o `search:eval` com o modo semântico puro para ter a linha de base; depois compare com o híbrido usando as mesmas perguntas. Em bases com siglas, códigos de erro e nomes de produto, que é o caso de qualquer fintech ou SaaS de nicho, a diferença costuma aparecer logo nas primeiras rodadas, e a partir daí o ajuste de pesos vira decisão baseada em número, não em impressão.

---

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