Parte 2: embeddings e busca vetorial no Laravel com pgvector
Paulo Roberto Bolsanello·18 Set 2026·9 min de leitura
Na primeira parte, o ambiente subiu e o texto do PDF saiu limpo no terminal. Agora esse texto vai para o banco dividido em pedaços, cada um com seu vetor, e a primeira busca semântica começa a funcionar. Ainda não há modelo generativo envolvido.
O objetivo continua o mesmo: responder quais documentos o edital exige. Para isso, o modelo de linguagem vai precisar ler os trechos certos. Esta etapa prepara o terreno: recorta o documento, transforma cada recorte em vetor e guarda tudo de um jeito que permita encontrar o recorte certo depois. O SDK resolve os vetores em uma linha. O resto é PHP comum.
Esta etapa usa dois models: Document, que representa o PDF e expõe a relação com os pedaços, e DocumentChunk, que representa cada pedaço.
Arquivo: app/Models/Document.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Document extends Model
{
protected $fillable = [
'original_name',
'storage_path',
'status',
'page_count',
'extracted_text',
];
public function chunks(): HasMany
{
return $this->hasMany(DocumentChunk::class);
}
}
No DocumentChunk ficam o cast do vetor e a regra que tira o embedding da serialização.
Arquivo: app/Models/DocumentChunk.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\AsVector;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DocumentChunk extends Model
{
protected $fillable = [
'document_id',
'page',
'content',
'embedding',
];
protected $hidden = [
'embedding',
];
protected function casts(): array
{
return [
'embedding' => AsVector::class,
];
}
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
}
O cast AsVector converte o array de floats do PHP para o tipo vector do Postgres e vice-versa. Sem ele, eu teria que montar a string no formato que o pgvector espera. Com ele, basta atribuir um array.
O $hidden vai ser usado na próxima parte, quando o agente receber esses models serializados. Cada embedding tem 1536 números, e mandar isso junto com o texto só ocuparia contexto, sem nada que o modelo consiga aproveitar.
A página de cada pedaço
Quando o sistema disser que o edital exige certidão negativa de falência, você vai querer conferir no PDF. Por isso cada pedaço precisa guardar a página de onde saiu.
Na primeira parte, vimos que ler a página no rodapé não funciona, porque o Word espaçou as letras e o rodapé sai como P á g i n a 12 | 48. Existe um caminho que não depende de como o documento foi escrito. O pdftotext coloca um caractere de form feed (\f) entre uma página e outra, em qualquer PDF, com ou sem rodapé.
Então basta quebrar o texto nesse caractere. As páginas saem em ordem, e o índice do array vira o número da página. Só tem um cuidado: algumas versões do pdftotext também colocam um form feed depois da última página. Nesse caso, o explode cria um elemento vazio no fim, e um edital de 48 páginas passaria a ter 49. Por isso o código remove os elementos vazios do final antes de contar.
Como o corte acontece dentro de cada página, um trecho que atravessa a virada de página fica partido em dois. Perco um pouco de continuidade, mas cada pedaço ganha uma página exata para citar. Num sistema em que o usuário precisa conferir a resposta, prefiro assim.
Cortando o texto
Daria para cortar o edital por cláusula, aproveitando que a numeração sempre vem no começo da linha, no formato 7.1.1.. O resultado seria melhor, mas o código ficaria maior. Como o foco aqui é entender o SDK e o funcionamento de um RAG, escolhi o corte mais simples: o código junta parágrafos até atingir um tamanho fixo e repete o final do pedaço anterior no começo do seguinte.
Essa repetição se chama sobreposição (overlap). Sem ela, uma exigência que caísse bem na fronteira entre dois pedaços viraria duas metades sem sentido, e nenhuma responderia à pergunta. Repetindo os últimos 200 caracteres, esse problema quase desaparece. O custo é um pouco de texto duplicado no banco.
Você passa uma lista de textos e recebe uma lista de vetores na mesma ordem. Não precisei de cliente HTTP, nem de montar payload ou tratar resposta. O modelo e as dimensões vêm do config/ai.php, então trocar de provedor é questão de configuração.
Para processar um documento inteiro, acrescentei três ajustes.
O primeiro é o timeout de 120 segundos. Cada requisição envia um lote de 32 trechos, não uma frase só, e o tempo padrão pode não bastar.
O segundo é o cache. O SDK guarda os embeddings usando provedor, modelo, dimensões e conteúdo como chave. Enquanto eu ajustava o tamanho dos pedaços e reprocessava o mesmo edital, isso evitou pagar de novo pelos trechos que não mudaram.
O terceiro é o retry, que vem do Laravel, não do SDK. A ingestão faz várias chamadas de rede seguidas, e uma hora alguma falha por um problema passageiro. Com o retry, o lote que falhou é tentado de novo, até três vezes, em vez de derrubar a ingestão inteira.
Esse código fica em um serviço, App\Services\EditalIngestor, e não dentro do comando Artisan, porque na próxima parte ele também vai rodar a partir de um upload pela web. O laço fica dentro de um try: se um lote falhar mesmo depois das tentativas, o documento passa de processing para failed. Assim, uma ingestão que parou no meio não fica parecendo ativa.
Rodando a ingestão
O serviço completo está em app/Services/EditalIngestor.php, e o comando que recebe o caminho do PDF e chama o serviço está em app/Console/Commands/IngestEdital.php.
O edital de Vargem Bonita, com 48 páginas, virou 117 pedaços. A ingestão levou poucos segundos e custou alguns centavos.
Testando a busca
Antes de colocar um modelo de linguagem na história, quero ver a busca funcionando sozinha. É ela que define o limite de qualidade do sistema: se o trecho certo não for recuperado, nenhum modelo vai adivinhar o que ele dizia.
A consulta primeiro limita a busca ao documento escolhido. Sem esse filtro, a partir da segunda ingestão os resultados misturariam editais diferentes. Em seguida, recebe a pergunta em texto puro e deixa o Laravel gerar o embedding. Por fim, descarta o que estiver abaixo da similaridade mínima e já devolve tudo ordenado, sem orderBy.
O minSimilarity vai de 0 a 1 e é o parâmetro que você mais vai ajustar. Com um valor alto, a busca não devolve nada. Com um valor baixo, vem ruído junto. Num edital, deixar passar uma exigência pode custar a licitação, então prefiro receber ruído. Por isso o valor está baixo.
O primeiro resultado, o mais similar de todos, foi o item 7 do edital, cujo título é justamente HABILITAÇÃO. Para um modelo de embeddings, isso faz sentido: a pergunta era sobre documentos de habilitação, e esse trecho é o que mais fala do assunto. O problema é que o item 7 não lista documento nenhum. O texto dele é este:
Os documentos previstos no ANEXO II deste edital, necessários e suficientes para demonstrar a capacidade do licitante de realizar o objeto da licitação, serão exigidos para fins de habilitação.
A busca trouxe o trecho mais parecido com a pergunta, e esse trecho não responde nada. A lista de verdade, com CNPJ, certidões, FGTS e CNDT, está no Anexo II, na página 38, que apareceu só em quarto lugar.
Esse é o limite da busca por similaridade pura. O vetor mede o quanto um trecho fala do mesmo assunto que a pergunta, e não se ele contém a resposta. Essa observação vai definir as instruções do agente na próxima parte.
Também apareceu um pedaço quase vazio: um fragmento de frase cortado no meio de uma palavra, seguido do rodapé da página. É efeito da sobreposição, que pega os últimos 200 caracteres sem olhar onde a palavra termina. Como deixei a limpeza de cabeçalho e rodapé de fora, esse pedaço entra no ranking sem trazer nada útil.
O que ficou de fora
Algumas melhorias ficaram de fora de propósito:
corte por cláusula em vez de tamanho fixo;
busca por palavra-chave combinada com a vetorial, usando a coluna content_tsv criada na primeira parte;
limpeza de cabeçalho e rodapé repetidos;
remoção de pedaços quase idênticos;
ingestão em fila;
OCR para editais digitalizados.
Todas melhoram o resultado, mas nenhuma é necessária para entender como o mecanismo funciona.
Na próxima parte
O edital já está no banco com seus vetores, e a busca funciona, com uma ressalva: o trecho mais parecido nem sempre é o que responde. Na terceira parte, entra o agente do SDK com saída estruturada. Ele usa a busca, lê os trechos e devolve a lista de documentos em formato de checklist. Também entra uma tela para usar tudo isso sem terminal.
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.
A função list() do PHP é uma forma prática de atribuir múltiplos valores de um array a variáveis individuais. Neste artigo, vamos explorar como essa função funciona, seus usos mais comuns e alternativas modernas…
O WSL 2 (Windows Subsystem for Linux) representa um grande avanço na integração entre o Windows e o Linux, permitindo que desenvolvedores tenham acesso a um ambiente Linux completo dentro do Windows. Com melhorias de…