Voltar ao Blog
Inteligência Artificial13 min de leitura

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

Habilitando a extensão
create extension if not exists vector;
database/migrations/create_trechos_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(<<<'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.

app/Services/Rag/Fatiador.php
<?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.

app/Services/Rag/GeradorDeEmbeddings.php
<?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.
  • retry com 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].

Inserindo trechos
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:

Busca por similaridade
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.

Criando o índice
-- 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.

Busca híbrida com RRF
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:

app/Services/Rag/Assistente.php
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

  1. Chunking respeitando parágrafo, 500–1.000 caracteres, 10–20% de sobreposição
  2. Prefixe o trecho com título do documento e da seção
  3. Cache de embedding por hash do conteúdo
  4. Grave o nome do modelo junto com o vetor
  5. Crie o índice HNSW depois da carga inicial
  6. Busca híbrida com RRF — é o maior ganho isolado de qualidade
  7. Filtre por metadado no mesmo WHERE da busca vetorial
  8. Instrua o modelo a admitir quando não sabe, e use temperatura baixa
  9. Sempre devolva as fontes
  10. 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.

Inteligência ArtificialRAGPostgreSQLpgvectorEmbeddingsPHP

Leia também