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.
Ao trabalhar com migrations no Laravel, uma tarefa comum é definir valores padrão para colunas em tabelas de banco de dados. Uma situação específica é definir o ano atual como valor padrão para uma coluna. Este tutorial…
Avançados, Banco de dados, Iniciantes, Intermediários, Laravel, php, Postgres
Para instalar o docker no ubuntu basta seguir os passos abaixo: Abra o terminal do Linux copie e cole os códigos que seguem: sudo apt-get install curl sudo apt install docker.io sudo curl -L…