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.