Parte 1: Laravel AI SDK e pgvector, a base de um RAG em PHP

Parte 1: Laravel AI SDK e pgvector, a base de um RAG em PHP

Eu queria descobrir o que um edital de licitação pede sem ler as 48 páginas dele. Esta série nasceu disso. Ao longo de três artigos, vou montar em Laravel um sistema que lê o PDF de um edital e devolve a lista de documentos de habilitação, com o trecho e a página de onde cada exigência saiu.

A técnica por trás é RAG (Retrieval-Augmented Generation, ou geração aumentada por recuperação). Em vez de mandar o documento inteiro para o modelo de linguagem, o sistema busca os trechos relevantes e entrega só eles como contexto. A maioria dos tutoriais mostra isso com um texto curto e bem comportado. Aqui o documento é real, com anexos, numeração repetida e até uma contradição, e são esses detalhes que mostram onde um RAG acerta e onde erra.

O documento de teste é o edital do Pregão Eletrônico nº 006/2026 de Vargem Bonita/SC, para contratação de arbitragem esportiva. Baixe o PDF agora, porque ele acompanha a série inteira. O código completo está no repositório do projeto.

Nesta primeira parte, preparo o ambiente, o banco e a extração do PDF. Na segunda, corto o texto, gero os embeddings e testo a busca. Na terceira, entrego a busca a um agente, valido a resposta com um schema e coloco uma tela em cima.

O que vamos usar

O Laravel AI SDK é o pacote oficial do Laravel para conversar com provedores de IA. Com ele eu gero embeddings (vetores numéricos que representam o significado de um texto), faço busca vetorial e rodo um agente, tudo sem escrever cliente HTTP.

O banco é o PostgreSQL com a extensão pgvector, que guarda esses vetores e faz busca por similaridade. O Laravel já suporta esse tipo de coluna de forma nativa. O provedor de IA é a OpenAI, e tudo roda em Docker.

O edital tem 48 páginas e segue a Lei 14.133/2021. Além do corpo, traz termo de referência, estudo técnico preliminar e quatro anexos.

Um aviso: o AI SDK é recente e a API dele ainda muda. Escrevi os exemplos sobre o Laravel 13. Antes de copiar um comando, confira a versão que você tem instalada.

Uma nota sobre o escopo

Este é um projeto de estudo, não um produto. Sempre que precisei escolher entre a solução simples e a robusta, fiquei com a simples e anotei a alternativa. No último artigo eu volto a essas anotações e mostro o que ficou faltando.

Subindo o ambiente com Docker

O projeto precisa de três coisas: PHP com as extensões do PostgreSQL, um banco com pgvector instalado e o pdftotext para tirar o texto do PDF. Com Docker, as três sobem juntas e ninguém precisa compilar o pgvector na mão.

Crie a pasta do projeto e, dentro dela, o arquivo docker/php/Dockerfile:

FROM php:8.4-cli-bookworm

RUN apt-get update && apt-get install -y \
        git \
        unzip \
        curl \
        ca-certificates \
        libzip-dev \
        libpq-dev \
        libicu-dev \
        poppler-utils \
    && docker-php-ext-install pdo_pgsql pgsql zip intl \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
    && apt-get install -y nodejs \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www

EXPOSE 8000 5173

O pacote mais importante aí é o poppler-utils. Ele traz o pdftotext, a ferramenta de linha de comando que extrai o texto do edital. O Node está no Dockerfile porque o Vite vai precisar dele quando chegarmos à interface.

Na raiz do projeto, crie o docker-compose.yml:

services:
  app:
    build: ./docker/php
    working_dir: /var/www
    volumes:
      - .:/var/www
    ports:
      - "8000:8000"
      - "5173:5173"
    command: php artisan serve --host=0.0.0.0 --port=8000
    depends_on:
      - db
      - redis

  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_DB: edital
      POSTGRES_USER: edital
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

Repare na imagem do banco. Em vez do Postgres oficial, usei a pgvector/pgvector:pg17, que já vem com a extensão compilada.

Se você já tem outro Postgres rodando na máquina, a porta 5432 vai estar ocupada e o container não sobe. Para resolver, troque só o número da esquerda, por exemplo "5434:5432". O da direita é a porta dentro da rede do Compose, que é a que a aplicação usa, e esse fica como está.

Criando o projeto Laravel

Com PHP e Composer instalados na máquina, basta um comando:

composer create-project laravel/laravel edital-rag

Se não quiser depender do PHP local, dá para usar um container descartável:

docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp \
  -v "$PWD":/app -w /app composer:2 \
  composer create-project laravel/laravel .

Depois, mova os arquivos do Docker para dentro da pasta do projeto e suba o ambiente:

docker compose build
docker compose up -d

Configurando a conexão

No .env, aponte o banco para o serviço do Compose:

DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=edital
DB_USERNAME=edital
DB_PASSWORD=secret

CACHE_STORE=redis
REDIS_HOST=redis

OPENAI_API_KEY=sua-chave-aqui

O host é db, e não localhost, porque de dentro do container da aplicação o banco é outro container. Esse é o erro mais comum de quem está começando com Docker.

Instalando o AI SDK

A partir daqui, todos os comandos rodam dentro do container:

docker compose exec app composer require laravel/ai

docker compose exec app php artisan vendor:publish \
    --provider="Laravel\Ai\AiServiceProvider"

O vendor:publish cria o config/ai.php e copia as migrations do pacote. Elas criam as tabelas onde o SDK guarda o histórico de conversa dos agentes. Este projeto não usa essas tabelas, mas pode deixar que sejam criadas.

No config/ai.php, confira o modelo padrão de embeddings. Eu uso o text-embedding-3-small da OpenAI, que gera vetores de 1536 dimensões. Ele é barato: dá para reprocessar o mesmo edital várias vezes, enquanto você ajusta o tamanho dos pedaços, sem que isso pese na fatura.

Modelando o banco

São duas tabelas: uma para o documento e outra para os pedaços (chunks) em que ele vai ser dividido.

docker compose exec app php artisan make:migration create_documents_table
docker compose exec app php artisan make:migration create_document_chunks_table

A primeira é simples.

Arquivo: database/migrations/2026_09_18_100000_create_documents_table.php

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('original_name');
    $table->string('storage_path');
    $table->string('status')->default('pending');
    $table->unsignedInteger('page_count')->nullable();
    $table->longText('extracted_text')->nullable();
    $table->timestamps();
});

A coluna extracted_text guarda o texto que saiu do PDF. Parece redundante, mas ajuda muito na hora de depurar. Quando a busca devolver um resultado estranho, você consegue ver exatamente o texto que foi indexado, sem processar o arquivo de novo.

A segunda tabela concentra as decisões mais importantes.

Arquivo: database/migrations/2026_09_18_100100_create_document_chunks_table.php

use Illuminate\Support\Facades\DB;

Schema::ensureVectorExtensionExists();

Schema::create('document_chunks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('document_id')->constrained()->cascadeOnDelete();
    $table->unsignedInteger('page')->nullable();
    $table->text('content');
    $table->vector('embedding', dimensions: 1536)->nullable()->index();
    $table->timestamps();
});

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_content_tsv_idx
    ON document_chunks USING GIN (content_tsv)
');

O Schema::ensureVectorExtensionExists() executa o CREATE EXTENSION IF NOT EXISTS vector. Ele precisa vir antes de qualquer coluna vetorial.

O vector() com index() cria a coluna e um índice HNSW com distância de cosseno. Com um edital só, o índice não muda nada na performance, porque o volume é pequeno. Ele passa a fazer diferença quando a base tiver dezenas de documentos.

O nullable() é necessário porque o vector() cria a coluna como not null por padrão. Sem ele, o banco recusaria gravar um pedaço antes de o vetor desse pedaço existir.

A coluna content_tsv não tem equivalente no Blueprint do Laravel, por isso usei SQL direto. Ela é uma coluna gerada: o Postgres calcula o tsvector (a versão do texto preparada para busca por palavra-chave) a partir de content sempre que a linha muda. Ninguém escreve nela. A série não chega a usar essa coluna. Deixei ela criada porque é a base da busca híbrida, assunto do fechamento da série.

Rode as migrations:

docker compose exec app php artisan migrate

Conferindo o schema

Antes de seguir, confira o que foi criado. Um erro aqui só apareceria bem mais tarde.

docker compose exec app php artisan db --sql "\\d document_chunks"

Também dá para ver direto no psql do container do banco. O resultado precisa mostrar:

  • a coluna embedding com tipo vector(1536);
  • a coluna content_tsv do tipo tsvector, marcada como generated always as ... stored;
  • um índice hnsw em embedding vector_cosine_ops e um índice gin em content_tsv.

Extraindo o texto do PDF

Antes de pensar em embedding ou modelo de linguagem, preciso saber se o PDF tem texto. Muito edital de prefeitura pequena é digitalizado, ou seja, é a foto de um papel dentro de um PDF. Nesse caso, a extração devolve uma string vazia e o projeto para antes de começar.

Para testar isso, criei um comando Artisan:

docker compose exec app php artisan make:command ExtractPdfText

Arquivo: app/Console/Commands/ExtractPdfText.php

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;

class ExtractPdfText extends Command
{
    protected $signature = 'edital:extract {path} {--pages=1}';

    protected $description = 'Extract raw text from a PDF file for inspection';

    public function handle(): int
    {
        $path = $this->argument('path');

        if (! is_file($path)) {
            $this->error("File not found: {$path}");

            return self::FAILURE;
        }

        $info = Process::run(['pdfinfo', $path]);
        $this->line($info->output());

        $result = Process::run([
            'pdftotext',
            '-layout',
            '-f', '1',
            '-l', (string) $this->option('pages'),
            $path,
            '-',
        ]);

        if (! $result->successful()) {
            $this->error($result->errorOutput());

            return self::FAILURE;
        }

        $text = trim($result->output());

        if ($text === '') {
            $this->error(
                'No text extracted. This PDF is probably scanned and would need OCR.'
            );

            return self::FAILURE;
        }

        $this->newLine();
        $this->line($text);

        return self::SUCCESS;
    }
}

Baixe o edital, salve como storage/app/edital.pdf e rode:

docker compose exec app php artisan edital:extract storage/app/edital.pdf --pages=3

A flag -layout pede ao pdftotext que mantenha o alinhamento das colunas. Sem ela, as tabelas saem com os números embaralhados.

O pdfinfo no início do comando mostra a quantidade de páginas, que vou usar na próxima parte, e dá uma pista de como o arquivo foi gerado.

O que a saída mostrou

No edital de Vargem Bonita, o texto saiu limpo. A numeração das cláusulas aparece no início da linha, no formato 2.5.1., e os títulos de seção vêm em caixa alta. Para esta primeira parte, isso basta: o PDF é legível e o projeto pode seguir.

O cabeçalho do pdfinfo explica por que a extração funcionou. O campo Creator mostra Acrobat PDFMaker 25 para Word, sinal de que o documento foi escrito no Word e exportado para PDF. Quando esse campo aponta um scanner, ou vem vazio em um arquivo grande, o PDF provavelmente é imagem e vai precisar de OCR (reconhecimento óptico de caracteres).

A saída também trouxe uma surpresa. Meu plano era ler o número da página no rodapé, que aparece nas 48 páginas. Só que o rodapé sai assim:

P á g i n a 3 | 48

O Word aplicou espaçamento entre as letras, e o pdftotext transforma esse espaçamento em espaços de verdade. Uma expressão regular escrita para encontrar "Página" não casaria com nada, e sem dar erro nenhum. Todos os pedaços ficariam sem página, e eu só descobriria o motivo muito depois.

Na próxima parte eu mostro outro jeito de saber a página de cada trecho. Por enquanto, fica a lição: olhe a saída crua antes de escrever qualquer regra em cima dela.

Na próxima parte

Com o ambiente rodando, o banco pronto para vetores e o PDF legível, a base está montada. Na segunda parte, o texto vai para o banco em pedaços, cada um com o vetor que o Laravel AI SDK gera, e a primeira busca semântica começa a funcionar, ainda sem nenhum modelo generativo envolvido.

Referências

  • LARAVEL. Laravel AI SDK. Documentação oficial, versão 13.x. Disponível em: https://laravel.com/framework/docs/ai-sdk. Acesso em: 18 set. 2026.
  • MUNICÍPIO DE VARGEM BONITA. Pregão Eletrônico nº 006/2026: contratação de serviços de arbitragem esportiva. Processo Administrativo nº 012/2026. Vargem Bonita, SC, 2 fev. 2026. Disponível em: https://vargembonita.sc.gov.br/uploads/sites/93/2026/02/PL012.2026-PE006.2026-ARBITRAGEM.pdf. Acesso em: 18 set. 2026.

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo

Solucionando Problemas de Conexão no PostgreSQL: Um Guia Prático

Solucionando Problemas de Conexão no PostgreSQL: Um Guia Prático

Quando se trata de gerenciar bancos de dados, o PostgreSQL se destaca como uma escolha robusta e flexível para desenvolvedores e administradores de banco de dados. No entanto, como qualquer sistema complexo, pode…

Artigo, Banco de dados, Iniciantes, Linux, Postgres, Tutorial, Ubuntu
Como instalar o Go Language no Ubuntu ?

Como instalar o Go Language no Ubuntu ?

Então resolvi tirar um tempo para conhecer outras linguagens de programação e no momento resolvi iniciar com a GO pois foge a uma série de padrões de programação na qual já estou acostumado e as etapas desse processo de…

Artigo, GO, Tutorial