Parte 1: Laravel AI SDK e pgvector, a base de um RAG em PHP
Paulo Roberto Bolsanello·18 Set 2026·10 min de leitura
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.
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 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:
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.
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:
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:
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.
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.
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.
<?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:
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.
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
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…