RAG na prática: busca vetorial com PHP, PostgreSQL e pgvector
Como dar ao modelo acesso ao conhecimento da sua empresa sem fine-tuning e sem banco vetorial dedicado. Chunking, embeddings, índice HNSW, busca híbrida e o custo real de indexar mil documentos.
▸Neste artigo
O pedido chega sempre parecido: “queremos um assistente que responda sobre nossos produtos”. E a primeira ideia de quem nunca fez é sempre a mesma: treinar um modelo com os dados da empresa.
Quase nunca é o caminho. Fine-tuning é caro, demora, exige refazer tudo quando a informação muda, e — o detalhe que mais custa — ensina estilo melhor do que ensina fato. O modelo aprende a soar como sua documentação sem necessariamente acertar o número da tabela de preços.
RAG (Retrieval-Augmented Generation) inverte o problema. Em vez de colocar o conhecimento dentro do modelo, você busca o trecho relevante na hora da pergunta e entrega junto com ela. O modelo não precisa saber nada da sua empresa: precisa saber ler.
Este artigo é a implementação que uso, com PostgreSQL e pgvector — sem serviço vetorial dedicado, sem mensalidade nova, no banco que seu projeto Laravel já tem.
Como o RAG funciona
O fluxo tem duas metades que rodam em momentos diferentes.
Indexação (uma vez, ou quando o conteúdo muda):
documento → fatiar em trechos → gerar embedding de cada trecho → gravar no banco
Consulta (a cada pergunta):
pergunta → embedding da pergunta → buscar trechos parecidos → montar prompt → LLM
A peça central é o embedding: um vetor de números que representa o
significado de um texto. Textos com sentido parecido geram vetores próximos no
espaço, mesmo sem compartilhar palavra nenhuma. É por isso que “como cancelo
minha conta” encontra um documento intitulado “encerramento de cadastro” — algo
que LIKE '%cancelar%' jamais faria.
Por que pgvector
Existe uma indústria inteira de bancos vetoriais. Antes de contratar um, faça a conta: até alguns milhões de vetores, o PostgreSQL com pgvector resolve — e você ganha transação, backup, JOIN com suas tabelas de negócio e uma operação que o time já sabe tocar.
Poder filtrar por metadado no mesmo WHERE da busca vetorial é a vantagem que
mais pesa no dia a dia:
where empresa_id = 42
and publicado_em > now() - interval '1 year'
order by embedding <=> $1
Fazer isso com um banco vetorial separado significa manter dois sistemas sincronizados e resolver o filtro na aplicação.
Instalação
create extension if not exists vector;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(<<<'SQL'
create table trechos (
id bigserial primary key,
documento_id bigint not null references documentos(id) on delete cascade,
conteudo text not null,
-- 1536 é a dimensão do text-embedding-3-small.
-- Precisa bater exatamente com o modelo escolhido.
embedding vector(1536) not null,
metadados jsonb not null default '{}',
criado_em timestamptz not null default now()
)
SQL);
DB::statement('create index on trechos using gin (metadados)');
DB::statement('create index on trechos (documento_id)');
}
public function down(): void
{
DB::statement('drop table if exists trechos');
}
};O índice vetorial fica para depois — explico o motivo mais abaixo.
Chunking: onde a qualidade se decide
Esta é a etapa que mais afeta o resultado final e a que recebe menos atenção.
O modelo só vê o trecho que você recuperou. Se o chunking cortou a frase que respondia à pergunta ao meio, nenhum modelo salva a resposta.
Trecho grande demais dilui o significado: um vetor que representa três assuntos diferentes não fica próximo de nenhum deles. Também gasta contexto e dinheiro à toa.
Trecho pequeno demais perde o contexto: “o prazo é de 30 dias” não serve para nada sem saber o prazo de quê.
O ponto de equilíbrio que funciona para documentação e base de conhecimento fica entre 500 e 1.000 caracteres, com 10% a 20% de sobreposição. A sobreposição existe justamente para que uma ideia cortada na fronteira apareça inteira em pelo menos um dos trechos.
<?php
declare(strict_types=1);
namespace App\Services\Rag;
final class Fatiador
{
public function __construct(
private int $tamanho = 800,
private int $sobreposicao = 120,
) {}
/** @return list<string> */
public function fatiar(string $texto): array
{
// Quebra em parágrafos primeiro: respeitar a estrutura do texto
// rende muito mais que cortar a cada N caracteres.
$paragrafos = preg_split('/\n\s*\n/', trim($texto)) ?: [];
$trechos = [];
$atual = '';
foreach ($paragrafos as $paragrafo) {
$paragrafo = trim($paragrafo);
if ($paragrafo === '') {
continue;
}
// Parágrafo sozinho já estoura o limite: quebra por frase.
if (mb_strlen($paragrafo) > $this->tamanho) {
if ($atual !== '') {
$trechos[] = $atual;
$atual = '';
}
foreach ($this->quebrarPorFrase($paragrafo) as $pedaco) {
$trechos[] = $pedaco;
}
continue;
}
if (mb_strlen($atual) + mb_strlen($paragrafo) + 2 > $this->tamanho) {
$trechos[] = $atual;
$atual = $this->cauda($atual);
}
$atual = $atual === '' ? $paragrafo : "{$atual}\n\n{$paragrafo}";
}
if (trim($atual) !== '') {
$trechos[] = $atual;
}
return $trechos;
}
/** Últimos N caracteres, para servir de sobreposição no próximo trecho. */
private function cauda(string $texto): string
{
return mb_strlen($texto) <= $this->sobreposicao
? $texto
: mb_substr($texto, -$this->sobreposicao);
}
/** @return list<string> */
private function quebrarPorFrase(string $paragrafo): array
{
$frases = preg_split('/(?<=[.!?])\s+/', $paragrafo) ?: [];
$saida = [];
$buffer = '';
foreach ($frases as $frase) {
if (mb_strlen($buffer) + mb_strlen($frase) > $this->tamanho && $buffer !== '') {
$saida[] = $buffer;
$buffer = $this->cauda($buffer);
}
$buffer = trim("{$buffer} {$frase}");
}
if ($buffer !== '') {
$saida[] = $buffer;
}
return $saida;
}
}Uma técnica que rende muito e custa quase nada: prefixar cada trecho com o título do documento e da seção. O embedding passa a carregar o contexto hierárquico, e a recuperação melhora de forma perceptível.
$conteudo = "{$documento->titulo} › {$secao}\n\n{$trecho}";
Gerar os embeddings
Chamar a API uma vez por trecho funciona e é lento. Os provedores aceitam lote — use.
<?php
declare(strict_types=1);
namespace App\Services\Rag;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class GeradorDeEmbeddings
{
private const MODELO = 'text-embedding-3-small';
private const LOTE = 100;
/**
* @param list<string> $textos
* @return list<list<float>>
*/
public function gerar(array $textos): array
{
$saida = [];
foreach (array_chunk($textos, self::LOTE) as $lote) {
$pendentes = [];
// Cache por hash: reindexar um documento em que só um parágrafo
// mudou não deve pagar pelos outros de novo.
foreach ($lote as $i => $texto) {
$chave = 'emb:' . self::MODELO . ':' . hash('xxh128', $texto);
$cacheado = Cache::get($chave);
if ($cacheado !== null) {
$saida[] = $cacheado;
continue;
}
$pendentes[$i] = $texto;
}
if ($pendentes === []) {
continue;
}
$resposta = Http::withToken(config('services.openai.key'))
->timeout(60)
->retry(3, 1000, throw: false)
->post('https://api.openai.com/v1/embeddings', [
'model' => self::MODELO,
'input' => array_values($pendentes),
])
->throw()
->json('data');
foreach ($resposta as $posicao => $item) {
$texto = array_values($pendentes)[$posicao];
$chave = 'emb:' . self::MODELO . ':' . hash('xxh128', $texto);
Cache::forever($chave, $item['embedding']);
$saida[] = $item['embedding'];
}
}
return $saida;
}
}Detalhes que economizam dinheiro e dor de cabeça:
- Cache por hash do conteúdo. Reindexação parcial vira barata.
retrycom espera. Erro 429 é rotina em indexação grande.- Guarde o nome do modelo. Trocar de modelo obriga a reindexar tudo — vetores de modelos diferentes não são comparáveis. Ter o modelo registrado evita misturar gerações sem perceber.
Gravar e consultar
O driver do PHP não tem tipo nativo para vector, então o valor vai como string
no formato que o pgvector espera: [0.1,0.2,0.3].
use Illuminate\Support\Facades\DB;
public function indexar(Documento $documento): void
{
$trechos = $this->fatiador->fatiar($documento->conteudo);
$vetores = $this->embeddings->gerar($trechos);
DB::transaction(function () use ($documento, $trechos, $vetores) {
DB::table('trechos')->where('documento_id', $documento->id)->delete();
$linhas = [];
foreach ($trechos as $i => $trecho) {
$linhas[] = [
'documento_id' => $documento->id,
'conteudo' => $trecho,
'embedding' => '[' . implode(',', $vetores[$i]) . ']',
'metadados' => json_encode([
'titulo' => $documento->titulo,
'posicao' => $i,
'modelo' => 'text-embedding-3-small',
]),
];
}
foreach (array_chunk($linhas, 500) as $bloco) {
DB::table('trechos')->insert($bloco);
}
});
}A busca usa o operador <=>, que calcula distância de cosseno:
public function buscar(string $pergunta, int $quantidade = 5): array
{
[$vetor] = $this->embeddings->gerar([$pergunta]);
$consulta = '[' . implode(',', $vetor) . ']';
return DB::select(<<<'SQL'
select
t.id,
t.conteudo,
t.metadados,
1 - (t.embedding <=> ?::vector) as similaridade
from trechos t
join documentos d on d.id = t.documento_id
where d.empresa_id = ?
and d.publicado = true
order by t.embedding <=> ?::vector
limit ?
SQL, [$consulta, $empresaId, $consulta, $quantidade]);
}Os três operadores disponíveis:
| Operador | Métrica | Quando usar |
|---|---|---|
<=> |
Distância de cosseno | Padrão para texto |
<-> |
Distância euclidiana (L2) | Vetores não normalizados |
<#> |
Produto interno negativo | Mais rápido, se já normalizado |
Para embeddings de texto da OpenAI, que já vêm normalizados, <=> é a escolha
correta e a mais previsível.
Índices: HNSW ou IVFFlat
Sem índice, cada busca compara a pergunta com todos os vetores da tabela. Com 5.000 trechos isso responde em 30 ms e ninguém reclama. Com 500.000, são vários segundos.
-- HNSW: melhor recall, busca mais rápida, construção mais lenta
create index on trechos
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
-- IVFFlat: construção rápida, memória menor, recall um pouco pior
-- lists ≈ raiz quadrada do número de linhas
create index on trechos
using ivfflat (embedding vector_cosine_ops)
with (lists = 1000);A comparação, na prática:
| HNSW | IVFFlat | |
|---|---|---|
| Tempo de construção | Lento | Rápido |
| Memória | Maior | Menor |
| Recall | ~99% | ~95% |
| Latência | Menor | Maior |
| Precisa de dados antes? | Não | Sim |
Use HNSW a menos que a construção do índice esteja inviável. O IVFFlat tem uma pegadinha séria: ele agrupa os vetores em listas calculadas no momento da criação. Criar o índice numa tabela vazia produz agrupamento inútil, e a busca passa a errar sem dar nenhum sinal. Se for de IVFFlat, popule a tabela primeiro e recrie o índice periodicamente.
Por isso a migration acima não cria o índice vetorial: crie depois da carga inicial. Indexar 100 mil vetores já existentes é muito mais rápido que manter o índice atualizado durante a inserção.
O ajuste fino da busca:
-- HNSW: quanto maior, melhor o recall e mais lenta a busca
set hnsw.ef_search = 100;
-- IVFFlat: quantas listas visitar
set ivfflat.probes = 10;
Busca híbrida: o que mais melhora resultado
Busca vetorial entende significado, mas erra em nome próprio, código de produto
e sigla. Se alguém procura pelo SKU AB-1234, o embedding não tem o que
capturar — é uma sequência arbitrária.
A solução é combinar busca vetorial com busca textual, e fundir os dois rankings
com Reciprocal Rank Fusion: cada resultado ganha pontos por 1 / (k + posição)
em cada lista.
with vetorial as (
select id, row_number() over (order by embedding <=> $1::vector) as posicao
from trechos
where documento_id in (select id from documentos where empresa_id = $2)
order by embedding <=> $1::vector
limit 50
),
textual as (
select id, row_number() over (
order by ts_rank_cd(to_tsvector('portuguese', conteudo),
plainto_tsquery('portuguese', $3)) desc
) as posicao
from trechos
where to_tsvector('portuguese', conteudo) @@ plainto_tsquery('portuguese', $3)
and documento_id in (select id from documentos where empresa_id = $2)
limit 50
)
select
t.id,
t.conteudo,
coalesce(1.0 / (60 + v.posicao), 0) + coalesce(1.0 / (60 + x.posicao), 0) as pontos
from trechos t
left join vetorial v on v.id = t.id
left join textual x on x.id = t.id
where v.id is not null or x.id is not null
order by pontos desc
limit 8;A constante 60 é o valor consagrado na literatura de RRF, e funciona bem sem ajuste. A vantagem do método é não precisar normalizar pontuações de escalas diferentes — ele só usa a posição em cada ranking.
Não esqueça do índice de texto:
create index on trechos using gin (to_tsvector('portuguese', conteudo));
Montar o prompt e citar as fontes
Com os trechos recuperados, o prompt final. Duas instruções fazem quase toda a diferença entre um assistente confiável e um gerador de invenções:
public function responder(string $pergunta): array
{
$trechos = $this->buscar($pergunta, quantidade: 8);
if ($trechos === []) {
return [
'resposta' => 'Não encontrei nada sobre isso na base de conhecimento.',
'fontes' => [],
];
}
$contexto = collect($trechos)
->map(fn ($t, $i) => "[{$i}] {$t->conteudo}")
->implode("\n\n---\n\n");
$sistema = <<<TXT
Você responde perguntas usando EXCLUSIVAMENTE os trechos fornecidos.
Regras:
- Se a resposta não estiver nos trechos, diga que não encontrou. Não deduza.
- Cite a fonte com o número entre colchetes, ex.: [2].
- Responda em português do Brasil, de forma direta.
TXT;
$resposta = $this->llm->completar(
sistema: $sistema,
usuario: "Trechos:\n\n{$contexto}\n\nPergunta: {$pergunta}",
temperatura: 0.1,
);
return [
'resposta' => $resposta,
'fontes' => collect($trechos)->map(fn ($t) => [
'titulo' => json_decode($t->metadados)->titulo,
'similaridade' => round($t->similaridade, 3),
])->all(),
];
}“Se não estiver nos trechos, diga que não encontrou” é a instrução que mais reduz alucinação. Sem ela, o modelo preenche a lacuna com o que ele acha que sabe — e soa igualmente confiante estando errado.
Temperatura baixa (0 a 0.2). Para responder com base em fonte, criatividade é defeito.
Devolva as fontes sempre. Além de permitir ao usuário conferir, é o que transforma a resposta em algo auditável.
Como saber se melhorou
Sem medição, todo ajuste em RAG vira opinião. O mínimo viável é um conjunto de 30 a 50 perguntas reais, cada uma com o trecho que deveria ser recuperado.
Com isso você mede:
- Recall@k — em quantas perguntas o trecho correto apareceu entre os k primeiros? É a métrica que importa mais: se a recuperação falhou, o modelo não tem como acertar.
- MRR — em que posição, em média, o trecho certo aparece?
- Taxa de abstenção — com que frequência o assistente diz que não sabe? Se for perto de zero, desconfie: ele está inventando.
Rode isso a cada mudança de chunking, modelo ou ef_search. É o único jeito de
saber se o ajuste ajudou ou só mudou.
Quanto custa
Números de referência para 1.000 documentos de ~5 KB cada, com
text-embedding-3-small (US$ 0,02 por milhão de tokens):
| Item | Volume | Custo |
|---|---|---|
| Trechos gerados | ~7.000 | — |
| Tokens de indexação | ~1,4 milhão | US$ 0,03 |
| Armazenamento (1536 dims) | ~43 MB | irrelevante |
| Embedding por consulta | ~20 tokens | ~US$ 0,0000004 |
A indexação custa centavos. O custo de verdade está na geração da resposta: enviar 8 trechos de 800 caracteres consome cerca de 2.000 tokens de entrada por pergunta.
Foi por isso que insisti no chunking: trecho enxuto e recuperação precisa significam menos trechos enviados, resposta melhor e conta menor — os três ao mesmo tempo.
Checklist
- Chunking respeitando parágrafo, 500–1.000 caracteres, 10–20% de sobreposição
- Prefixe o trecho com título do documento e da seção
- Cache de embedding por hash do conteúdo
- Grave o nome do modelo junto com o vetor
- Crie o índice HNSW depois da carga inicial
- Busca híbrida com RRF — é o maior ganho isolado de qualidade
- Filtre por metadado no mesmo
WHEREda busca vetorial - Instrua o modelo a admitir quando não sabe, e use temperatura baixa
- Sempre devolva as fontes
- Mantenha um conjunto de avaliação e rode a cada mudança
RAG não é difícil de fazer funcionar — é difícil de fazer funcionar bem. A diferença entre uma demo bonita e algo que aguenta produção está quase toda na qualidade da recuperação, não no modelo que gera a resposta.