Parte 1 : Laravel RAG com AISDK - Laravel AI SDK e pgvector: a base de um RAG em PHP

Parte 1 : Laravel RAG com AISDK - Laravel AI SDK e pgvector: a base de um RAG em PHP

Quem trabalha com licitação conhece a rotina. Sai um edital novo, você baixa um PDF de quarenta, sessenta, às vezes duzentas páginas, e precisa responder uma pergunta só: quais documentos essa licitação exige da minha empresa. A resposta está lá dentro, mas nunca em um lugar só. Está espalhada entre o corpo do edital, o termo de referência e os anexos, e quase sempre com redação diferente em cada um.

Perder uma certidão significa inabilitação. Então não dá para chutar, e ler tudo toma tempo que você não tem quando o prazo é curto e são cinco editais na mesma semana.

Nesta série de três artigos a gente vai construir um sistema que faz essa triagem. Você sobe o PDF do edital, o sistema quebra o documento em pedaços, indexa cada pedaço e depois devolve uma lista organizada dos documentos exigidos, com o trecho e a página de onde cada exigência saiu. A ideia não é substituir a leitura do edital. É acelerar a triagem e mostrar exatamente onde conferir.

A técnica por trás disso se chama RAG, sigla em inglês para geração aumentada por recuperação. A explicação sem jargão é simples: um modelo de linguagem não conhece o seu documento, então antes de perguntar qualquer coisa a ele você busca os trechos relevantes e entrega junto com a pergunta. É uma prova com consulta. O modelo não precisa ter decorado o edital, precisa saber ler a parte certa dele.

O que muda aqui em relação aos tutoriais de RAG que circulam por aí é que vamos fazer tudo dentro do Laravel. Banco vetorial, busca, agente e interface. Um framework só, do PostgreSQL até a tela em React. E o documento de teste é um edital real, não um texto de exemplo bem comportado.

O que vamos usar

O Laravel AI SDK, pacote oficial que unifica o acesso aos provedores de IA. O PostgreSQL com a extensão pgvector, que o Laravel já suporta nativamente com uma coluna vector e métodos de busca por similaridade no query builder. A OpenAI como provedor. E Docker para tudo isso rodar igual na sua máquina e na minha.

O edital de teste é o Pregão Eletrônico 006/2026 do município de Vargem Bonita, em Santa Catarina, para contratação de serviços de arbitragem esportiva. São 48 páginas, sob a Lei 14.133/2021, com termo de referência, estudo técnico preliminar e quatro anexos. É um edital pequeno para os padrões do setor e serve bem como cobaia. O PDF está público no site da prefeitura.

O Laravel AI SDK é recente. Confira a versão do pacote e do framework antes de copiar os comandos, porque a API pode ter mudado desde a publicação deste texto. Os exemplos aqui foram escritos sobre o Laravel 13.

Uma nota sobre o escopo

Este é um projeto de estudo, e isso muda as decisões. O objetivo é demonstrar como o SDK funciona em um fluxo de RAG completo, não entregar uma ferramenta pronta para produção. Sempre que houver escolha entre a solução simples e a solução robusta, o texto vai pela simples e anota a alternativa. No último artigo eu volto nessa lista de anotações e discuto o que um RAG mínimo deixa na mesa.

Subindo o ambiente com Docker

Três coisas precisam existir: PHP com as extensões do PostgreSQL, o banco com pgvector já instalado, e o pdftotext para extrair o conteúdo do PDF. O Docker resolve as três de uma vez e evita a conversa chata de instalar pgvector na mão.

Crie a pasta do projeto e dentro dela um 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 que importa aqui é o poppler-utils. É ele que traz o pdftotext, a ferramenta de linha de comando que vai extrair o texto do edital. O Node entra porque o Vite vai precisar dele quando chegarmos na interface.

Agora o docker-compose.yml na raiz:

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, usamos pgvector/pgvector:pg17, que é o Postgres com a extensão já compilada dentro. Economiza uma dor de cabeça inteira.

Se você já tem outro Postgres rodando na máquina, a porta 5432 vai estar ocupada e o container não sobe. A correção é trocar 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 essa não muda.

Criando o projeto Laravel

Se você tem PHP e Composer na máquina:

composer create-project laravel/laravel edital-rag

Se preferir não depender do PHP local, um container descartável resolve:

docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp \

-v "$PWD":/app -w /app composer:2 \

composer create-project laravel/laravel .

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 nome do host é db e não localhost porque, de dentro do container da aplicação, o banco é outro container. Esse é o erro número um de quem está começando com Docker, e vale a pena dizer em voz alta.

Instalando o AI SDK

Todos os comandos daqui para frente 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 traz as migrations do pacote, que montam as tabelas de conversa que o SDK usa para guardar histórico de agentes. Não vamos precisar delas neste projeto, mas deixe rodar, custa nada.

No config/ai.php vale conferir o modelo padrão de embeddings. Vamos usar o text-embedding-3-small da OpenAI, que gera vetores de 1536 dimensões e é barato o bastante para você reprocessar o mesmo edital dez vezes enquanto ajusta o tamanho dos pedaços sem sentir no cartão.

Modelando o banco

Duas tabelas. Uma para o documento, outra para os pedaços dele.

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:

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();

});

O campo extracted_text guarda o texto que saiu do PDF. Guardar isso no banco parece desperdício, mas salva muito tempo: quando a busca devolver resultado estranho, você vai querer olhar exatamente o texto que foi indexado, sem reprocessar o arquivo.

A segunda tabela é onde mora a parte interessante:

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)

');

Quatro coisas para comentar aqui.

O Schema::ensureVectorExtensionExists() roda o CREATE EXTENSION IF NOT EXISTS vector para você. Precisa vir antes de qualquer coluna vetorial.

O método vector() com index() cria a coluna e um índice HNSW com distância de cosseno. Em um documento só o índice não faz diferença de performance, porque o volume é pequeno demais. Ele está aí porque o dia em que você indexar cem editais vai fazer.

O nullable() não é detalhe. O vector() cria a coluna como not null por padrão, e isso impede gravar o pedaço antes de ter o vetor dele. Eu descobri isso do jeito ruim, com uma migration extra depois de o schema já estar aplicado.

A coluna content_tsv não tem equivalente no Blueprint do Laravel, por isso o SQL cru. Ela é uma coluna gerada: o Postgres calcula o tsvector automaticamente a partir do content, toda vez que a linha muda. Você nunca escreve nela. Ela não vai ser usada nesta série, e está aí de propósito, como gancho para a conversa sobre busca híbrida no último artigo.

Rode as migrations:

docker compose exec app php artisan migrate

Conferindo o que foi criado

Vale olhar o schema antes de seguir, porque um erro aqui só apareceria muito mais tarde:

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

Ou direto no psql do container do banco. Três coisas devem aparecer: a coluna embedding com tipo vector(1536), a coluna content_tsv do tipo tsvector marcada como generated always as ... stored, e dois índices, um hnsw (embedding vector_cosine_ops) e um gin (content_tsv).

O primeiro teste de verdade: extrair o texto

Antes de pensar em embedding, vetor ou modelo de linguagem, tem uma pergunta que precisa ser respondida: esse PDF é legível por máquina. Muito edital de município pequeno é digitalizado, ou seja, é uma foto de papel dentro de um arquivo PDF. Nesse caso a extração devolve string vazia e o projeto inteiro para antes de começar.

Vamos descobrir isso já:

docker compose exec app php artisan make:command ExtractPdfText

<?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 preserve o alinhamento das colunas. Sem ela, qualquer tabela vira uma sopa de números embaralhados. Com ela, a tabela pelo menos continua parecendo uma tabela.

O pdfinfo no começo do comando não é enfeite. Ele mostra a contagem de páginas, que vamos usar depois, e dá uma pista sobre a origem do arquivo.

O que a saída real mostrou

No edital de Vargem Bonita o texto saiu limpo, com a numeração das cláusulas preservada no início da linha, no formato 2.5.1., e os títulos de seção em caixa alta. Se o texto aparece, o projeto está de pé, e é só isso que esta primeira parte precisa provar.

Vale ler o cabeçalho do pdfinfo com atenção, porque ele antecipa a qualidade do que vem. Neste edital o campo Creator diz Acrobat PDFMaker 25 para Word, ou seja, o documento nasceu digital, foi escrito no Word e exportado. Por isso a extração sai limpa. Quando esse campo aponta um scanner, ou vem vazio num arquivo grande, é sinal de que o PDF é imagem e vai precisar de OCR.

E aqui vai o primeiro aprendizado que só a saída real ensina. Eu ia ler o número da página do rodapé do documento, que aparece em todas as 48 páginas. Olhando a saída, o rodapé sai assim:

P á g i n a 3 | 48

O Word aplicou espaçamento entre as letras da palavra, e o pdftotext devolve esse espaçamento como espaço de verdade. Uma expressão regular escrita para Página não casaria com nada, e o pior é que falharia em silêncio: você acabaria com a página nula em todos os trechos e descobriria o motivo muito depois.

Existe um caminho melhor, e ele aparece no próximo artigo. A lição geral, por enquanto, é essa: olhe a saída crua antes de escrever qualquer regra sobre ela.

Na próxima parte

A fundação está pronta: ambiente rodando, banco preparado para vetor, e a certeza de que o PDF é legível. Na segunda parte o texto vai para o banco em pedaços, com os vetores que o Laravel AI SDK gera em uma linha, e a primeira busca semântica começa a funcionar, ainda sem nenhum modelo generativo no meio.

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

Testando o envio de mensagens de email usando Laravel

Testando o envio de mensagens de email usando Laravel

Enviar mensagens de email é uma parte essencial de muitas aplicações web, e o Laravel, um popular framework PHP, torna esse processo bastante fácil. No entanto, durante o desenvolvimento, é crucial testar o envio de…

Artigo, Laravel, php
Usando o git cherry-pick no Git: Quando e Como Usar

Usando o git cherry-pick no Git: Quando e Como Usar

O git cherry-pick é um dos comandos mais poderosos e versáteis no Git, mas muitas vezes é mal compreendido ou subutilizado. Enquanto o comando git merge mescla todas as mudanças de uma branch para outra, o cherry-pick…

Artigo, GIT, Intermediários