~ / tutoriais /guia-rag-backend-pgvector-laravel $ _

Guia de RAG para devs backend: do zero ao pgvector em Laravel

Lucas Souza Lucas Souza 10 min de leitura Tutoriais
Guia de RAG para devs backend: do zero ao pgvector em Laravel

Quem nunca viu um demo de RAG funcionar lindo no notebook e quebrar no primeiro cliente real? O motivo quase nunca é o modelo. É a engenharia de dados em volta dele.

RAG, Retrieval-Augmented Generation, é o padrão para dar memória, contexto e fontes a um LLM sem precisar fine-tunar nada. Você guarda seus documentos num índice, recupera os pedaços relevantes na hora da pergunta e injeta tudo no prompt. Conceito simples. Implementação em produção, nem tanto.

Neste tutorial vamos construir um pipeline RAG completo em Laravel 12 + PostgreSQL 16 + pgvector, com ingestion assíncrono em fila, busca híbrida (BM25 + embeddings) combinada por Reciprocal Rank Fusion, geração com tool use no Claude API e três métricas que separam protótipo de produto: recall@5, faithfulness e latência p95.

TL;DR

  • O que é: tutorial backend de RAG ponta a ponta em Laravel, sem framework de IA.
  • Stack: Laravel 12, PostgreSQL 16, pgvector 0.8, pgvector-php, Voyage AI (embeddings), Claude API (geração).
  • Custo/Acesso: PostgreSQL é open-source. Voyage cobra ~$0,06 por 1M de tokens em voyage-3.5; Claude tem free tier de avaliação.
  • Link útil: docs oficiais do pgvector e do Voyage embeddings na Anthropic.

O contexto: por que RAG ainda importa em 2026

Modelos com 1M de tokens de contexto existem. Caching de prompts ficou barato. Mesmo assim, RAG não morreu, só mudou de papel.

Jogar 500 mil tokens de documentação a cada query continua sendo:

  1. Caro. Mesmo com cache, o custo escala com o número de documentos, não com a pergunta.
  2. Lento. Time-to-first-token cresce com o contexto, e p95 vira problema.
  3. Inseguro. Misturar dados de clientes diferentes no mesmo prompt é receita de vazamento.
  4. Frágil. Estudos mostram que LLMs perdem precisão em prompts longos, o famoso "lost in the middle".

RAG resolve os quatro problemas: você só manda os 4-8 chunks relevantes, isola por tenant via filtro no banco e mantém a latência sob controle.

E a pergunta que todo mundo faz: "mas vetor é tudo que importa?". Não. A literatura de 2026 é clara: busca híbrida (BM25 + embeddings) é a baseline mínima viável. Embedding sozinho perde quando o usuário pesquisa por sigla, código de produto ou termo técnico exato. BM25 sozinho perde em sinônimos e paráfrase. Os dois combinados por Reciprocal Rank Fusion ganham em quase todo benchmark público.

Pré-requisitos

Antes de começar, garante:

  • PHP 8.3+ e Laravel 12 instalados.
  • PostgreSQL 16 com a extensão pgvector 0.8+ (CREATE EXTENSION vector; precisa funcionar).
  • Conta na Voyage AI (embeddings) e na Anthropic (geração).
  • Horizon ou outro worker de fila configurado: vamos rodar ingestion assíncrono.
  • Familiaridade com Eloquent e jobs.

A escolha do embedding não é dogmática. A Anthropic recomenda oficialmente Voyage porque não tem modelo próprio, e o voyage-3.5 lidera o MTEB no momento com 1024 dimensões padrão. Mas o pipeline funciona igual com text-embedding-3-small da OpenAI. Trocar é mudar o cliente HTTP e o tamanho da coluna.

Passo 1: schema do banco

A primeira decisão é cara: chunk é uma entidade de primeira classe. Não é coluna de documents. É tabela própria.

Por quê? Porque um documento vira N chunks, cada chunk tem seu embedding, e o índice HNSW que vai responder a busca vive nessa tabela.

// database/migrations/2026_05_23_create_rag_tables.php
public function up(): void
{
    DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

    Schema::create('documents', function (Blueprint $table) {
        $table->id();
        $table->foreignId('tenant_id')->constrained();
        $table->string('source_url')->nullable();
        $table->string('title');
        $table->text('raw_content');
        $table->string('content_hash', 64)->unique();
        $table->timestamp('ingested_at')->nullable();
        $table->timestamps();
    });

    Schema::create('document_chunks', function (Blueprint $table) {
        $table->id();
        $table->foreignId('document_id')->constrained()->cascadeOnDelete();
        $table->foreignId('tenant_id')->constrained();
        $table->unsignedInteger('chunk_index');
        $table->text('content');
        $table->unsignedInteger('token_count');
        $table->timestamps();
    });

    // Coluna vetorial separada porque vai ter índice especial
    DB::statement('ALTER TABLE document_chunks ADD COLUMN embedding vector(1024)');

    // Índice full-text para o lado BM25 da busca híbrida
    DB::statement("
        ALTER TABLE document_chunks
        ADD COLUMN content_tsv tsvector
        GENERATED ALWAYS AS (to_tsvector('portuguese', content)) STORED
    ");
    DB::statement('CREATE INDEX document_chunks_tsv_idx ON document_chunks USING GIN (content_tsv)');

    // Índice HNSW para o lado vetorial. m=16, ef_construction=64 = baseline 2026.
    DB::statement('
        CREATE INDEX document_chunks_embedding_idx
        ON document_chunks
        USING hnsw (embedding vector_cosine_ops)
        WITH (m = 16, ef_construction = 64)
    ');
}

Três detalhes que vão te economizar horas de debug:

  • content_hash único em documents. Reingestion idempotente. Sem isso, cada deploy duplica o corpus.
  • tenant_id na tabela de chunks. Multi-tenant precisa filtrar no WHERE antes do ORDER BY embedding, senão você pesquisa entre tenants e o índice deixa de ajudar.
  • m=16, ef_construction=64. Padrão do pgvector que a Crunchy Data recomenda para a maioria dos workloads. Aumentar dobra build time e memória, só mexa depois de medir recall.

Passo 2: ingestion assíncrono

Ingestion não pode rodar dentro do request. Embedding é I/O caro, chunking é CPU, e qualquer documento de 50 páginas trava sua thread se rodar inline.

Pattern: controller só persiste o Document cru e dispara um job. O job faz chunking, embedding em batch e persistência.

// app/Jobs/IngestDocument.php
class IngestDocument implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $documentId) {}

    public function handle(Chunker $chunker, VoyageClient $voyage): void
    {
        $document = Document::findOrFail($this->documentId);

        // 1. Chunking semântico por janela deslizante com overlap
        $chunks = $chunker->chunk(
            $document->raw_content,
            maxTokens: 400,
            overlapTokens: 60,
        );

        // 2. Embedding em batch. Voyage aceita até 128 textos por chamada
        foreach (array_chunk($chunks, 64) as $batch) {
            $embeddings = $voyage->embed(
                texts: array_column($batch, 'content'),
                model: 'voyage-3.5',
                inputType: 'document',
            );

            $rows = collect($batch)->map(fn ($c, $i) => [
                'document_id' => $document->id,
                'tenant_id'   => $document->tenant_id,
                'chunk_index' => $c['index'],
                'content'     => $c['content'],
                'token_count' => $c['tokens'],
                'embedding'   => new Vector($embeddings[$i]),
                'created_at'  => now(),
                'updated_at'  => now(),
            ])->all();

            DocumentChunk::insert($rows);
        }

        $document->update(['ingested_at' => now()]);
    }
}

Por que maxTokens: 400, overlapTokens: 60? Porque um chunk grande demais (>1000) dilui o sinal do embedding: a similaridade fica baixa mesmo quando o trecho relevante está lá. E sem overlap, perguntas que caem na fronteira entre dois chunks ficam órfãs. 60 tokens de sobreposição é o que a documentação do Voyage sugere como ponto de partida.

A diferença entre input_type: 'document' na ingestion e input_type: 'query' na busca não é decoração. Voyage usa embeddings assimétricos: o mesmo texto vira vetores diferentes dependendo se ele é o documento sendo indexado ou a pergunta sendo feita. Se você esquece de alternar, o recall cai ~10pp.

Passo 3: busca híbrida com RRF

Aqui mora a engenharia.

A query final tem três partes, executadas em uma única ida ao banco para minimizar latência:

  1. Top-50 por BM25 (ts_rank_cd sobre content_tsv).
  2. Top-50 por similaridade de cosseno sobre embedding.
  3. Fusão por Reciprocal Rank Fusion com k=60, devolvendo top-N.
// app/Services/Retrieval/HybridRetriever.php
class HybridRetriever
{
    public function __construct(private VoyageClient $voyage) {}

    public function search(string $query, int $tenantId, int $topK = 8): Collection
    {
        $queryEmbedding = $this->voyage->embed(
            texts: [$query],
            model: 'voyage-3.5',
            inputType: 'query',
        )[0];

        $vector = '[' . implode(',', $queryEmbedding) . ']';

        $sql = <<<SQL
            WITH bm25 AS (
                SELECT id, ROW_NUMBER() OVER (
                    ORDER BY ts_rank_cd(content_tsv, websearch_to_tsquery('portuguese', ?)) DESC
                ) AS rank
                FROM document_chunks
                WHERE tenant_id = ?
                  AND content_tsv @@ websearch_to_tsquery('portuguese', ?)
                LIMIT 50
            ),
            dense AS (
                SELECT id, ROW_NUMBER() OVER (
                    ORDER BY embedding <=> ?::vector
                ) AS rank
                FROM document_chunks
                WHERE tenant_id = ?
                ORDER BY embedding <=> ?::vector
                LIMIT 50
            ),
            fused AS (
                SELECT
                    COALESCE(bm25.id, dense.id) AS id,
                    COALESCE(1.0 / (60 + bm25.rank), 0) +
                    COALESCE(1.0 / (60 + dense.rank), 0) AS rrf_score
                FROM bm25
                FULL OUTER JOIN dense USING (id)
            )
            SELECT c.*, fused.rrf_score
            FROM fused
            JOIN document_chunks c ON c.id = fused.id
            ORDER BY fused.rrf_score DESC
            LIMIT ?
        SQL;

        return collect(DB::select($sql, [
            $query, $tenantId, $query,
            $vector, $tenantId, $vector,
            $topK,
        ]));
    }
}

A constante 60 no denominador do RRF é o k do paper original. Não é mágica, é dampening: impede que o top-1 de uma lista domine a fusão. Funciona porque scores brutos de BM25 e cosseno vivem em escalas diferentes, e rank-based fusion ignora isso elegantemente.

Antes de aceitar essa query em prod, ligue EXPLAIN ANALYZE. Se o planner não estiver usando o índice HNSW, provavelmente o WHERE tenant_id = ? está filtrando demais e o índice perdeu seletividade. Solução: criar índice parcial por tenant grande, ou pré-filtrar por outra dimensão antes.

Passo 4: geração com tool use no Claude API

A última peça é o LLM. E aqui tem uma escolha de arquitetura que muita gente erra: não passe os chunks no system prompt como contexto fixo. Em vez disso, exponha a busca como uma tool que o Claude chama quando precisa.

Por que? Três razões:

  • O modelo decide se precisa buscar: perguntas triviais ("oi") não viram query no banco.
  • Ele pode chamar a tool múltiplas vezes com queries diferentes, refinando a busca.
  • Você ganha citation tracking quase de graça: o tool_use_id amarra cada chunk citado.
// app/Services/Generation/RagAgent.php
class RagAgent
{
    public function __construct(
        private AnthropicClient $claude,
        private HybridRetriever $retriever,
    ) {}

    public function answer(string $question, int $tenantId): array
    {
        $messages = [['role' => 'user', 'content' => $question]];
        $citations = [];

        while (true) {
            $response = $this->claude->messages()->create([
                'model' => 'claude-sonnet-4-6',
                'max_tokens' => 1024,
                'system' => view('prompts.rag-system')->render(),
                'tools' => [[
                    'name' => 'search_knowledge_base',
                    'description' => 'Busca trechos relevantes na base de conhecimento do cliente.',
                    'input_schema' => [
                        'type' => 'object',
                        'properties' => [
                            'query' => ['type' => 'string', 'description' => 'Termos de busca em linguagem natural.'],
                        ],
                        'required' => ['query'],
                    ],
                ]],
                'messages' => $messages,
            ]);

            if ($response->stop_reason === 'end_turn') {
                return [
                    'answer' => collect($response->content)
                        ->where('type', 'text')->pluck('text')->join("\n\n"),
                    'citations' => $citations,
                ];
            }

            // Executa a(s) tool call(s) do turno
            $toolUses = collect($response->content)->where('type', 'tool_use');
            $toolResults = $toolUses->map(function ($call) use ($tenantId, &$citations) {
                $chunks = $this->retriever->search($call->input['query'], $tenantId, 5);
                $citations = array_merge($citations, $chunks->pluck('id')->all());

                return [
                    'type' => 'tool_result',
                    'tool_use_id' => $call->id,
                    'content' => $chunks->map(fn ($c) =>
                        "[chunk:{$c->id}] {$c->content}"
                    )->join("\n---\n"),
                ];
            });

            $messages[] = ['role' => 'assistant', 'content' => $response->content];
            $messages[] = ['role' => 'user', 'content' => $toolResults->all()];
        }
    }
}

O system prompt em prompts/rag-system.blade.php deve cravar duas regras:

  • "Responda com base nos trechos devolvidos pela tool. Se não encontrar, diga que não sabe."
  • "Cite cada afirmação relevante com [chunk:ID], replicando o ID que veio no tool_result."

Sem essas duas linhas, o modelo enche o silêncio com conhecimento paramétrico e a faithfulness despenca.

Limitações e armadilhas reais

Onde esse pipeline quebra, em ordem de frequência observada em produção:

  • Tabelas e PDFs com layout. Chunker por janela ignora estrutura. Para documentos pesados em tabela, considere um parser dedicado (Unstructured, LlamaParse) antes do chunking.
  • PT-BR no to_tsvector. O dicionário portuguese do Postgres é bom, não excelente. Sinônimos e flexão verbal pegam, gíria e jargão técnico, não. Se seu domínio é cheio de termos próprios, vale curar um dicionário customizado.
  • HNSW e DELETE. Marcação de tombstone no índice HNSW degrada com deletes frequentes. Em corpus muito mutável, agendar REINDEX CONCURRENTLY mensal evita surpresa.
  • Custo de embedding em re-ingestion. Toda mudança no chunker força re-embed do corpus inteiro. Versionar a estratégia de chunking e só re-processar documentos novos quando possível é o caminho.
  • Prompt injection via chunk. Documento malicioso pode conter instruções dentro do texto. Trate todo tool_result como dado não confiável: instrução fica no system, dado fica no user. OWASP LLM01 tem o detalhe.

As três métricas que importam

Você não escapa de medir. RAG sem evals é fé.

1. Recall@5 no conjunto de retrieval. Monte 50–100 perguntas reais com a resposta certa marcada manualmente (qual chunk deveria aparecer no top-5). Recall@5 é a métrica mais correlacionada com qualidade de resposta em sistemas que rodam híbrido. Meta inicial: ≥0,85.

2. Faithfulness na geração. Para cada resposta gerada, decompor em afirmações e checar se cada uma está suportada por algum chunk recuperado. O RAGAS automatiza isso usando LLM-as-a-judge. Score abaixo de 0,8 significa que o modelo está inventando: provavelmente seu prompt está fraco ou o retrieval não está trazendo o suficiente.

3. Latência p95. Não p50, p95. É o cliente do fim da fila que abandona a conversa. Em Laravel com pgvector na mesma VPC, p95 abaixo de 1,5s é alcançável sem reranker. Com reranker (Voyage rerank-2.5), some 200-400ms: vale o trade-off se recall ainda estiver curto.

Salva esses três números num dashboard. Compara semana contra semana. Toda mudança no chunker, no prompt ou no top-K precisa passar nessa régua.

Conclusão

RAG bom não é mágica. É chunker decente + embedding adequado + híbrido com RRF + tool use no LLM + três métricas medidas semanalmente. Postgres com pgvector dá conta da maioria dos casos até a casa das dezenas de milhões de vetores antes de você precisar pensar em vector DB dedicado.

O próximo passo natural é colocar um reranker entre o retrieval e o LLM, e tracejar tool_use_id → chunk → citação na resposta final pra fechar o ciclo de auditoria. Se você estiver construindo isso em PHP e quiser comparar notas com outros devs que estão na mesma trincheira, a gente troca esse tipo de figurinha todo dia na Beer and Code, a melhor comunidade de AI engineering em português, com grupo no WhatsApp aberto pra quem está construindo IA em produção.

Lucas Souza
Lucas Souza

{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.

Você também pode gostar

VirguIA

beer & code assistant

conectando…

Não foi possível iniciar o chat agora.

tocando