# Como manter embeddings sincronizados no Laravel com pgvector: reindexação incremental

> Como atualizar só os trechos que mudaram quando um registro é editado, sem regerar todos os embeddings: hash por chunk, job único na fila, backfill e troca de modelo.

Por Lucas Roberto · IA · publicado em 2026-10-21 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/como-manter-embeddings-sincronizados-no-laravel-com-pgvector-reindexacao-incremental

Para manter embeddings sincronizados no Laravel sem reindexar tudo, divida cada registro em trechos de forma determinística, guarde um hash SHA-256 de cada trecho junto do vetor no pgvector e, a cada alteração, chame a API de embeddings só para os hashes que ainda não existem. Um observer dispara um job único por registro depois do commit, e um comando Artisan cuida do backfill e da troca de modelo. Assim, editar uma vírgula num artigo de 30 seções custa uma chamada de um trecho, não de trinta.

Esse é o problema que aparece logo depois que o RAG vai para produção. Montar a base e responder com contexto é a parte que todo mundo documenta (já mostrei em [como fazer RAG com pgvector no Laravel](/blog/como-fazer-rag-com-pgvector-no-laravel-embeddings-busca-vetorial-e-respostas-com-claude)). O que ninguém conta é que a base apodrece: alguém edita a política de taxas, despublica um artigo, apaga uma FAQ, e o agente de IA continua citando o texto antigo porque o vetor nunca foi atualizado.

## O que você vai ter funcionando no final

1. Uma tabela `embedding_chunks` polimórfica, que serve para qualquer model (artigos de ajuda, FAQs, descrições de serviço).
2. Um chunker determinístico: o mesmo texto sempre gera os mesmos trechos.
3. Um job que compara hashes, reaproveita vetores e só chama a OpenAI para o que mudou.
4. Observers que disparam a sincronização ao criar, editar, despublicar, apagar e restaurar.
5. Um comando `embeddings:reindex` para backfill e migração de modelo de embedding.
6. Um teste com `Http::fake` que prova que só o trecho alterado foi enviado.

Pré-requisitos:

- PHP 8.2+ e Laravel 11 ou 12.
- PostgreSQL 15+ com a extensão pgvector instalada (pgvector é a extensão que adiciona o tipo `vector` e busca por similaridade ao PostgreSQL).
- Redis como driver de fila, de preferência com Horizon.
- Uma chave da API da OpenAI. Uso o modelo `text-embedding-3-small`, que gera vetores de 1536 dimensões.

Não uso SDK nenhum: a chamada é feita com `Http::` do Laravel, o que deixa o código fácil de testar e de trocar de provedor.

## 1. Como criar a tabela de chunks com pgvector no Laravel?

Um chunk é um trecho do documento original, pequeno o bastante para virar um embedding útil. Embedding é o vetor numérico que representa o significado do trecho e permite busca por similaridade.

```bash
php artisan make:migration create_embedding_chunks_table
```

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

        Schema::create('embedding_chunks', function (Blueprint $table) {
            $table->id();
            $table->morphs('embeddable');
            $table->unsignedInteger('chunk_index');
            $table->text('content');
            $table->char('content_hash', 64);
            $table->string('embedding_model', 100);
            $table->timestamps();

            $table->unique(['embeddable_type', 'embeddable_id', 'chunk_index']);
            $table->index('embedding_model');
        });

        DB::statement('ALTER TABLE embedding_chunks ADD COLUMN embedding vector(1536) NOT NULL');
        DB::statement('CREATE INDEX embedding_chunks_embedding_hnsw ON embedding_chunks USING hnsw (embedding vector_cosine_ops)');
    }

    public function down(): void
    {
        Schema::dropIfExists('embedding_chunks');
    }
};
```

Crio a coluna `vector` com SQL puro para não depender de suporte do Blueprint, que varia entre versões. HNSW é o índice de vizinhos aproximados do pgvector, o que mantém a busca rápida mesmo com muitos trechos.

Duas colunas fazem todo o trabalho deste tutorial: `content_hash` (SHA-256 do texto do trecho) diz se o conteúdo mudou, e `embedding_model` diz com qual modelo aquele vetor foi gerado. Vetores de modelos diferentes não são comparáveis, então essa coluna não é opcional.

O model Eloquent é mínimo:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class EmbeddingChunk extends Model
{
    protected $guarded = [];

    protected $hidden = ['embedding'];
}
```

## 2. Como configurar a chave da OpenAI?

```bash
# .env
OPENAI_API_KEY=sk-...
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
```

```php
// config/services.php
'openai' => [
    'key' => env('OPENAI_API_KEY'),
    'embedding_model' => env('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
],
```

O nome do modelo fica em config porque é ele que dispara a reindexação quando você trocar, no passo 7.

## 3. Cliente de embeddings com Http:: e retry

```php
<?php

namespace App\Ai;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;

class EmbeddingClient
{
    public function model(): string
    {
        return config('services.openai.embedding_model');
    }

    /**
     * @param  list<string>  $texts
     * @return list<list<float>>
     */
    public function embed(array $texts): array
    {
        $response = Http::withToken(config('services.openai.key'))
            ->acceptJson()
            ->timeout(30)
            ->retry(3, 1000, function (Throwable $e) {
                return $e instanceof ConnectionException
                    || ($e instanceof RequestException
                        && in_array($e->response->status(), [429, 500, 502, 503], true));
            })
            ->post('https://api.openai.com/v1/embeddings', [
                'model' => $this->model(),
                'input' => array_values($texts),
            ]);

        return collect($response->json('data'))
            ->sortBy('index')
            ->pluck('embedding')
            ->values()
            ->all();
    }
}
```

O corpo da requisição é só isto:

```json
{
  "model": "text-embedding-3-small",
  "input": ["primeiro trecho", "segundo trecho"]
}
```

A resposta traz um array `data` em que cada item tem `index` e `embedding`. Ordeno por `index` em vez de confiar na ordem do array, porque é o `index` que amarra o vetor ao texto enviado. Retries e timeouts em chamadas de IA têm mais nuances do que cabem aqui; detalhei em [como chamar LLMs no Laravel com filas, retries e timeouts](/blog/como-chamar-llms-no-laravel-com-filas-retries-e-timeouts-sem-duplicar-custo).

## 4. Por que o chunker precisa ser determinístico?

Todo o esquema de reaproveitamento depende de uma propriedade: o mesmo texto de entrada gera exatamente os mesmos trechos. Se um `\r\n` vindo do editor de um lado e um `\n` do outro mudarem o hash, você regera tudo sem perceber e paga por isso.

O segundo ponto é o raio de impacto. Um chunker que empacota parágrafos de forma gulosa do início ao fim propaga qualquer inserção: adicione um parágrafo no começo e as fronteiras de todos os trechos seguintes mudam. Por isso divido primeiro por título (`##`) e só empacoto dentro de cada seção. Uma edição afeta a própria seção, não o documento inteiro.

```php
<?php

namespace App\Ai;

class TextChunker
{
    public function __construct(private int $maxChars = 1500) {}

    /** @return list<string> */
    public function chunk(string $text, string $prefix = ''): array
    {
        $text = $this->normalize($text);

        if ($text === '') {
            return [];
        }

        $chunks = [];

        foreach (preg_split('/\n(?=#{1,6} )/', $text) as $section) {
            foreach ($this->pack(trim($section)) as $piece) {
                $chunks[] = $prefix !== '' ? $prefix."\n\n".$piece : $piece;
            }
        }

        return $chunks;
    }

    /** @return list<string> */
    private function pack(string $section): array
    {
        $out = [];
        $current = '';

        foreach (preg_split('/\n{2,}/', $section) as $paragraph) {
            if ($current !== '' && mb_strlen($current) + mb_strlen($paragraph) + 2 > $this->maxChars) {
                $out[] = $current;
                $current = $paragraph;
                continue;
            }

            $current = $current === '' ? $paragraph : $current."\n\n".$paragraph;
        }

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

        return $out;
    }

    private function normalize(string $text): string
    {
        $text = str_replace(["\r\n", "\r"], "\n", $text);
        $text = preg_replace('/[ \t]+\n/', "\n", $text);
        $text = preg_replace('/\n{3,}/', "\n\n", $text);

        return trim($text);
    }
}
```

O `$prefix` recebe o título do documento e é repetido em cada trecho. Isso melhora a recuperação (um trecho solto sobre "prazo de repasse" fica ambíguo sem saber de qual artigo veio), com um trade-off claro: mudar o título invalida todos os trechos daquele registro. Para mim vale a pena, porque título muda pouco.

Uma limitação consciente: um parágrafo maior que `maxChars` vira um trecho sozinho. Com texto de central de ajuda isso quase não acontece; se o seu conteúdo tem blocos enormes, quebre por frase dentro do `pack`.

## 5. Como ligar os models à sincronização com uma trait?

O contrato diz o que vira texto e quais campos importam:

```php
<?php

namespace App\Ai;

interface Embeddable
{
    public function embeddingTitle(): string;

    public function embeddingText(): string;

    /** Campos que, se mudarem, exigem nova sincronização. */
    public function embeddingFields(): array;

    public function shouldBeEmbedded(): bool;
}
```

A trait registra os eventos do Eloquent:

```php
<?php

namespace App\Ai;

use App\Jobs\SyncEmbeddings;
use App\Models\EmbeddingChunk;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

trait HasEmbeddings
{
    public static function bootHasEmbeddings(): void
    {
        static::saved(function (Model $model) {
            if ($model->wasRecentlyCreated || $model->wasChanged($model->embeddingFields())) {
                SyncEmbeddings::dispatch($model)->afterCommit();
            }
        });

        static::deleted(function (Model $model) {
            $model->embeddingChunks()->delete();
        });

        if (method_exists(static::class, 'restored')) {
            static::restored(function (Model $model) {
                SyncEmbeddings::dispatch($model)->afterCommit();
            });
        }
    }

    public function embeddingChunks(): MorphMany
    {
        return $this->morphMany(EmbeddingChunk::class, 'embeddable');
    }
}
```

O `afterCommit()` é obrigatório aqui. Sem ele, o job pode rodar antes de a transação que salvou o artigo terminar, ler a versão antiga e gravar vetores desatualizados, que é exatamente o bug que este tutorial quer eliminar. Expliquei o mecanismo em [jobs disparados dentro de transação e o afterCommit no Laravel](/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel).

No model, um artigo da central de ajuda:

```php
<?php

namespace App\Models;

use App\Ai\Embeddable;
use App\Ai\HasEmbeddings;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Article extends Model implements Embeddable
{
    use HasEmbeddings, SoftDeletes;

    protected $fillable = ['title', 'body', 'status'];

    public function embeddingTitle(): string
    {
        return (string) $this->title;
    }

    public function embeddingText(): string
    {
        return (string) $this->body;
    }

    public function embeddingFields(): array
    {
        return ['title', 'body', 'status'];
    }

    public function shouldBeEmbedded(): bool
    {
        return $this->status === 'published' && ! $this->trashed();
    }
}
```

`status` entra em `embeddingFields` de propósito: despublicar um artigo precisa tirar os trechos da base, senão o agente segue respondendo com conteúdo que o time já retirou do ar.

## 6. Como gerar embedding só dos trechos que mudaram?

Este é o coração. O job:

1. Recarrega o registro do banco (nunca confia no estado serializado).
2. Se o registro não deve ser indexado, apaga os trechos e sai.
3. Gera os trechos e os hashes.
4. Se a sequência de hashes é idêntica à salva e o modelo é o atual, sai sem fazer nada.
5. Reaproveita os vetores cujos hashes já existem e chama a API só para os novos.
6. Substitui os trechos numa transação curta, sem nenhuma chamada HTTP dentro dela.

```php
<?php

namespace App\Jobs;

use App\Ai\EmbeddingClient;
use App\Ai\TextChunker;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\DB;

class SyncEmbeddings implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
    use Queueable;

    public bool $deleteWhenMissingModels = true;
    public int $tries = 5;
    public array $backoff = [10, 30, 120];
    public int $uniqueFor = 600;

    public function __construct(public Model $model)
    {
        $this->onQueue('embeddings');
    }

    public function uniqueId(): string
    {
        return $this->model->getMorphClass().':'.$this->model->getKey();
    }

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->uniqueId()))->releaseAfter(15)->expireAfter(300)];
    }

    public function handle(TextChunker $chunker, EmbeddingClient $client): void
    {
        $type = $this->model->getMorphClass();
        $id = $this->model->getKey();
        $scope = fn () => DB::table('embedding_chunks')
            ->where('embeddable_type', $type)
            ->where('embeddable_id', $id);

        $model = $this->model->fresh();

        if (! $model || ! $model->shouldBeEmbedded()) {
            $scope()->delete();
            return;
        }

        $modelName = $client->model();
        $chunks = $chunker->chunk($model->embeddingText(), $model->embeddingTitle());
        $hashes = array_map(fn (string $c) => hash('sha256', $c), $chunks);

        $rows = $scope()
            ->orderBy('chunk_index')
            ->selectRaw('content_hash, embedding_model, embedding::text as embedding')
            ->get();

        $current = $rows->where('embedding_model', $modelName);

        if ($current->count() === $rows->count() && $rows->pluck('content_hash')->all() === $hashes) {
            return; // nada mudou
        }

        $vectors = $current->pluck('embedding', 'content_hash')->all();
        $textByHash = array_combine($hashes, $chunks) ?: [];
        $missing = array_values(array_diff(array_unique($hashes), array_keys($vectors)));

        foreach (array_chunk($missing, 100) as $batch) {
            $embeddings = $client->embed(array_map(fn ($h) => $textByHash[$h], $batch));

            foreach ($batch as $i => $hash) {
                $vectors[$hash] = '['.implode(',', $embeddings[$i]).']';
            }
        }

        $now = now();
        $insert = [];

        foreach ($chunks as $i => $content) {
            $insert[] = [
                'embeddable_type' => $type,
                'embeddable_id' => $id,
                'chunk_index' => $i,
                'content' => $content,
                'content_hash' => $hashes[$i],
                'embedding_model' => $modelName,
                'embedding' => $vectors[$hashes[$i]],
                'created_at' => $now,
                'updated_at' => $now,
            ];
        }

        DB::transaction(function () use ($scope, $insert) {
            $scope()->delete();

            if ($insert !== []) {
                DB::table('embedding_chunks')->insert($insert);
            }
        });
    }
}
```

Algumas decisões que valem explicar:

- **Reaproveitamento por hash, não por posição.** Se uma seção nova entra no meio, os trechos seguintes mudam de `chunk_index` mas não de conteúdo. Comparar por hash reaproveita todos eles.
- **`embedding::text` volta no formato `[0.1,0.2,...]`**, que é o mesmo formato aceito no insert. O vetor sai do banco e volta sem passar pela API.
- **`ShouldBeUniqueUntilProcessing`** (variação do job único que libera a trava quando o processamento começa, e não quando termina). Com o `ShouldBeUnique` comum, uma edição feita enquanto o job roda seria descartada e a base ficaria com a penúltima versão. Aqui, a edição enfileira um novo job, que vai ler o estado mais recente com `fresh()`.
- **`WithoutOverlapping`** garante que dois jobs do mesmo registro nunca rodem ao mesmo tempo, o que evitaria um apagar os trechos que o outro acabou de inserir.
- **Delete e insert na mesma transação curta.** A busca nunca vê o registro com zero trechos no meio da troca.

No Horizon, coloque a fila `embeddings` num supervisor separado com poucos processos. É ela que controla a pressão sobre o limite de requisições da OpenAI, sem disputar worker com cobrança e webhook.

```php
// config/horizon.php, dentro de 'environments' => ['production' => [...]]
'supervisor-embeddings' => [
    'connection' => 'redis',
    'queue' => ['embeddings'],
    'maxProcesses' => 2,
    'tries' => 5,
    'timeout' => 120,
],
```

## 7. Como fazer backfill e trocar de modelo de embedding?

O comando enfileira tudo ou só o que está desatualizado:

```php
<?php

namespace App\Console\Commands;

use App\Jobs\SyncEmbeddings;
use Illuminate\Console\Command;

class ReindexEmbeddings extends Command
{
    protected $signature = 'embeddings:reindex {model} {--stale}';

    protected $description = 'Enfileira a sincronização de embeddings de um model';

    public function handle(): int
    {
        $class = $this->argument('model');
        $current = config('services.openai.embedding_model');
        $query = $class::query();

        if ($this->option('stale')) {
            $query->where(function ($q) use ($current) {
                $q->whereDoesntHave('embeddingChunks')
                    ->orWhereHas('embeddingChunks', fn ($c) => $c->where('embedding_model', '!=', $current));
            });
        }

        $count = 0;

        $query->chunkById(200, function ($models) use (&$count) {
            foreach ($models as $model) {
                SyncEmbeddings::dispatch($model);
                $count++;
            }
        });

        $this->info("{$count} registros enfileirados.");

        return self::SUCCESS;
    }
}
```

```bash
php artisan embeddings:reindex "App\Models\Article"
php artisan embeddings:reindex "App\Models\Article" --stale
```

Para migrar de modelo, troque `OPENAI_EMBEDDING_MODEL` e rode com `--stale`. Durante a migração convivem vetores dos dois modelos, por isso a busca precisa sempre filtrar `where embedding_model = ?` com o modelo atual. O custo disso é uma janela em que parte da base ainda não foi reprocessada e não aparece nos resultados. Se essa janela não for aceitável, gere os vetores novos numa tabela paralela e troque o nome da tabela no fim.

Se o modelo novo tiver outra dimensão, a coluna `vector(1536)` não aceita. Ou você usa o parâmetro `dimensions` dos modelos `text-embedding-3` para manter 1536, ou cria uma coluna nova. Lembre que o índice HNSW para o tipo `vector` tem limite de 2000 dimensões no pgvector.

## 8. Como testar que só o trecho alterado foi enviado?

O teste precisa de PostgreSQL com pgvector (SQLite não tem o tipo `vector`). Uso `Queue::fake()` para os eventos não dispararem o job sozinhos e chamo o `handle` direto:

```php
<?php

use App\Jobs\SyncEmbeddings;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

beforeEach(function () {
    Queue::fake();

    Http::fake([
        'api.openai.com/*' => fn ($request) => Http::response([
            'data' => collect($request['input'])
                ->map(fn ($text, $i) => ['index' => $i, 'embedding' => array_fill(0, 1536, 0.01)])
                ->all(),
        ]),
    ]);
});

function syncEmbeddings(Article $article): void
{
    app()->call([new SyncEmbeddings($article), 'handle']);
}

it('gera embedding só do trecho que mudou', function () {
    $article = Article::create([
        'title' => 'Repasse',
        'status' => 'published',
        'body' => "## Taxas\n\nA taxa do débito é fixa.\n\n## Prazos\n\nO repasse cai em D+1.",
    ]);

    Queue::assertPushed(SyncEmbeddings::class);

    syncEmbeddings($article);
    Http::assertSent(fn ($r) => count($r['input']) === 2);

    $article->update([
        'body' => "## Taxas\n\nA taxa do débito mudou.\n\n## Prazos\n\nO repasse cai em D+1.",
    ]);
    syncEmbeddings($article);

    Http::assertSentCount(2);
    Http::assertSent(fn ($r) => count($r['input']) === 1
        && str_contains($r['input'][0], 'mudou'));

    expect($article->embeddingChunks()->count())->toBe(2);
});
```

Vale um segundo teste para despublicação: mude `status` para `draft`, rode o job e verifique que `embeddingChunks()->count()` é zero. Para outras formas de isolar a API em testes, veja os posts de testes de LLM do blog.

## Erros comuns

1. **Atualização em massa não dispara o observer.** `Article::where(...)->update([...])` vai direto ao banco e pula os eventos do Eloquent. Depois de um update em massa ou de uma importação via SQL, rode `embeddings:reindex --stale`, ou melhor, um reindex completo, porque o `--stale` não detecta conteúdo alterado, só modelo diferente ou ausência de trechos.
2. **Tudo é regerado a cada edição.** Quase sempre é normalização: o editor salva `\r\n`, espaços no fim da linha ou HTML com atributos que mudam. Logue os hashes de duas execuções seguidas sem edição; se forem diferentes, o problema está antes do chunker.
3. **Busca misturando vetores de modelos diferentes.** Os resultados ficam sem sentido e nenhum erro aparece. Filtre `embedding_model` em toda consulta de similaridade.
4. **Artigo apagado com soft delete continua indexado.** O `fresh()` ignora escopos globais e devolve o registro com `deleted_at` preenchido. Por isso `shouldBeEmbedded()` checa `trashed()`.
5. **`ShouldBeUnique` em vez de `ShouldBeUniqueUntilProcessing`.** Edições feitas durante o processamento são descartadas silenciosamente. É o tipo de bug que só aparece quando o suporte pergunta por que o agente cita a regra antiga.
6. **Erro de dimensão no insert** (`expected 1536 dimensions, not 3072`). Você trocou de modelo sem ajustar a coluna ou o parâmetro `dimensions`. Veja o passo 7.
7. **429 em massa durante o backfill.** O retry ajuda, mas quem resolve é o número de processos da fila `embeddings` no Horizon. Comece baixo e suba olhando a taxa de erro.
8. **Trecho grande demais.** Cada entrada dos modelos `text-embedding-3` aceita até 8192 tokens. Com `maxChars` em 1500 você fica muito longe disso, a menos que um parágrafo gigante escape do empacotamento.

## Por onde começar no seu projeto

Se você já tem RAG rodando, o caminho de menor risco é: crie as colunas `content_hash` e `embedding_model` na tabela que já existe, preencha com um reindex completo e só então ligue a trait nos models. A partir daí, monitore uma métrica só: quantos trechos são enviados à API por edição. Se esse número for próximo do total de trechos do documento, seu chunker não é determinístico ou o seu raio de impacto está grande demais. Com a base sincronizada, vale combinar com [busca híbrida com pgvector e full-text search do PostgreSQL](/blog/como-fazer-busca-hibrida-no-laravel-com-pgvector-e-full-text-search-do-postgresql), que se beneficia do mesmo `content` já normalizado.

## Perguntas frequentes

### Preciso guardar o texto do trecho além do vetor?

Sim. O texto é o que vai para o prompt do LLM na hora de responder, e ele também permite recalcular o hash e auditar o que estava indexado. O vetor sozinho não serve para nada além da busca.

### Dá para usar o Claude para gerar embeddings?

A API da Anthropic não oferece um endpoint de embeddings. O comum é gerar os vetores com outro provedor, como a OpenAI, e usar o Claude para responder com os trechos recuperados. O padrão deste tutorial funciona igual com qualquer provedor que receba um array de textos.

### Por que não atualizar só o trecho alterado em vez de apagar e inserir todos?

Porque a posição dos trechos muda quando uma seção entra ou sai. Apagar e inserir numa transação curta é mais simples e o custo real (a chamada à API) já foi evitado pelo reaproveitamento por hash.

### Com que frequência rodar o reindex completo?

Se tudo passa pelo Eloquent, só em migração de modelo ou depois de importações e updates em massa. Um agendamento semanal com `--stale` é uma rede de segurança barata para registros que ficaram sem trechos por algum job que esgotou as tentativas.

### Isso funciona para PDFs e anexos?

Funciona, desde que você extraia o texto antes e trate esse texto extraído como o `embeddingText()`. Guarde o texto extraído em uma coluna, para que o hash dependa dele e não do arquivo binário.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
