Parte 2: embeddings e busca vetorial no Laravel com pgvector

Parte 2: embeddings e busca vetorial no Laravel com pgvector

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.

Os models

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.

Arquivo: app/Services/EditalIngestor.php

private function extractPages(string $text): array
{
    $pages = explode("\f", $text);

    while ($pages !== [] && trim($pages[array_key_last($pages)]) === '') {
        array_pop($pages);
    }

    return $pages;
}

private function splitIntoChunks(array $pages): array
{
    $chunks = [];

    foreach ($pages as $index => $pageText) {
        foreach ($this->splitPage($pageText) as $content) {
            $chunks[] = [
                'content' => $content,
                'page' => $index + 1,
            ];
        }
    }

    return $chunks;
}

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.

Arquivo: app/Services/EditalIngestor.php

private const CHUNK_SIZE = 1200;

private const CHUNK_OVERLAP = 200;

private function splitPage(string $pageText): array
{
    $paragraphs = preg_split('/\n\s*\n/', $pageText, flags: PREG_SPLIT_NO_EMPTY) ?: [];

    $chunks = [];
    $current = '';

    foreach ($paragraphs as $paragraph) {
        $paragraph = trim($paragraph);

        if ($paragraph === '') {
            continue;
        }

        if (mb_strlen($current) + mb_strlen($paragraph) > self::CHUNK_SIZE && $current !== '') {
            $chunks[] = trim($current);
            $current = mb_substr($current, -self::CHUNK_OVERLAP)."\n\n";
        }

        $current .= $paragraph."\n\n";
    }

    if (trim($current) !== '') {
        $chunks[] = trim($current);
    }

    return $chunks;
}

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.

Gerando os embeddings

Com o SDK, gerar os embeddings é isto:

Arquivo: app/Services/EditalIngestor.php

use Laravel\Ai\Embeddings;

$response = Embeddings::for($texts)->generate();

$response->embeddings; // [[0.12, -0.04, ...], [0.98, 0.31, ...], ...]

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.

O laço de vetorização fica assim:

Arquivo: app/Services/EditalIngestor.php

foreach (array_chunk($chunks, 32) as $batch) {
    $response = retry(
        3,
        fn () => Embeddings::for(array_column($batch, 'content'))
            ->timeout(120)
            ->cache()
            ->generate(),
        sleepMilliseconds: 2000,
    );

    foreach ($batch as $index => $chunk) {
        $document->chunks()->create([
            'content' => $chunk['content'],
            'page' => $chunk['page'],
            'embedding' => $response->embeddings[$index],
        ]);
    }
}

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.

docker compose exec app php artisan edital:ingest storage/app/edital.pdf

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.

Arquivo: app/Console/Commands/SearchEdital.php

$chunks = DocumentChunk::query()
    ->where('document_id', $document->id)
    ->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.2)
    ->limit(5)
    ->get();

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.

docker compose exec app php artisan edital:search "documentos exigidos para habilitação"

O que a busca mostrou

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.

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo

Entendendo a Função list() no PHP

Entendendo a Função list() no PHP

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…

Iniciantes, Intermediários, php
Um resumo sobre o WSL2

Um resumo sobre o WSL2

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…

Artigo, Iniciantes, Linux, Ubuntu, Windows, WSL