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
- Uma tabela
document_chunkscom colunavector(1536)indexada com HNSW e uma colunatsvectorgerada automaticamente, com stemming em português e sem acentos. - Um cliente de embeddings da OpenAI feito com
Http::do Laravel. - Uma classe
HybridSearchque executa as duas buscas e funde com RRF em uma única ida ao banco. - 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. 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.
php artisan make:migration create_document_chunks_table
<?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.
OPENAI_API_KEY=sk-... OPENAI_EMBEDDING_MODEL=text-embedding-3-small
// 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:
{ "model": "text-embedding-3-small", "input": ["Qual a taxa no débito?", "Como antecipar recebíveis?"] }
<?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?
php artisan make:command EmbedChunks
<?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 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:
ROW_NUMBERfora da subquery. Se eu colocar a window function junto doORDER 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.websearch_to_tsqueryem vez deto_tsquery. Ela aceita texto livre do usuário (aspas,-palavra,or) sem estourar erro de sintaxe.SET LOCALdentro de transação. Ohnsw.ef_searchcontrola quantos vizinhos o índice examina; ele precisa ser maior que o número de candidatos. Ohnsw.iterative_scan, do pgvector 0.8, faz o índice continuar buscando quando o filtro portenant_iddescarta resultados. Orelaxed_orderpode devolver a lista levemente fora de ordem, mas como renumero pordistancelogo depois, não faz diferença.- Pesos como parâmetro. Com
semanticWeight = 1ekeywordWeight = 0eu 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.
php artisan make:command CompareSearch
<?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; } }
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).
[ {"query": "erro 51 na maquininha", "expected_chunk_id": 412}, {"query": "quanto custa passar no débito", "expected_chunk_id": 88} ]
<?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.
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.
Erros comuns
generation expression is not immutableao criar a coluna gerada. Acontece quando você escreveto_tsvector('portuguese', unaccent(content)). Resolva com a configuraçãopt_unaccentdo passo 1, que coloca ounaccentcomo dicionário.- Full-text não encontra nada. Quase sempre é configuração diferente entre índice e consulta: a coluna usa
pt_unaccente a query usaportugueseousimple. Os lexemas não batem. Use a mesma configuração dos dois lados. syntax error in tsquery. Você usouto_tsquerycom texto do usuário. Troque porwebsearch_to_tsqueryouplainto_tsquery.- 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. Usehnsw.iterative_scan(pgvector 0.8+) eef_searchmaior que o número de candidatos. Em versões antigas, oSET LOCAL hnsw.iterative_scanfalha, então confira a versão da extensão comSELECT extversion FROM pg_extension WHERE extname = 'vector'. expected 1536 dimensions, not 3072. Trocaram paratext-embedding-3-largesem 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.- 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.
- 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.