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). 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
- Uma tabela
embedding_chunkspolimórfica, que serve para qualquer model (artigos de ajuda, FAQs, descrições de serviço). - Um chunker determinístico: o mesmo texto sempre gera os mesmos trechos.
- Um job que compara hashes, reaproveita vetores e só chama a OpenAI para o que mudou.
- Observers que disparam a sincronização ao criar, editar, despublicar, apagar e restaurar.
- Um comando
embeddings:reindexpara backfill e migração de modelo de embedding. - Um teste com
Http::fakeque 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
vectore 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.
php artisan make:migration create_embedding_chunks_table
<?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 namespace App\Models; use Illuminate\Database\Eloquent\Model; class EmbeddingChunk extends Model { protected $guarded = []; protected $hidden = ['embedding']; }
2. Como configurar a chave da OpenAI?
# .env OPENAI_API_KEY=sk-... OPENAI_EMBEDDING_MODEL=text-embedding-3-small
// 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 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:
{ "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.
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 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 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 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.
No model, um artigo da central de ajuda:
<?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:
- Recarrega o registro do banco (nunca confia no estado serializado).
- Se o registro não deve ser indexado, apaga os trechos e sai.
- Gera os trechos e os hashes.
- Se a sequência de hashes é idêntica à salva e o modelo é o atual, sai sem fazer nada.
- Reaproveita os vetores cujos hashes já existem e chama a API só para os novos.
- Substitui os trechos numa transação curta, sem nenhuma chamada HTTP dentro dela.
<?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_indexmas não de conteúdo. Comparar por hash reaproveita todos eles. embedding::textvolta 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 oShouldBeUniquecomum, 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 comfresh().WithoutOverlappinggarante 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.
// 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 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; } }
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 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
- 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, rodeembeddings:reindex --stale, ou melhor, um reindex completo, porque o--stalenão detecta conteúdo alterado, só modelo diferente ou ausência de trechos. - 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. - Busca misturando vetores de modelos diferentes. Os resultados ficam sem sentido e nenhum erro aparece. Filtre
embedding_modelem toda consulta de similaridade. - Artigo apagado com soft delete continua indexado. O
fresh()ignora escopos globais e devolve o registro comdeleted_atpreenchido. Por issoshouldBeEmbedded()checatrashed(). ShouldBeUniqueem vez deShouldBeUniqueUntilProcessing. 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.- Erro de dimensão no insert (
expected 1536 dimensions, not 3072). Você trocou de modelo sem ajustar a coluna ou o parâmetrodimensions. Veja o passo 7. - 429 em massa durante o backfill. O retry ajuda, mas quem resolve é o número de processos da fila
embeddingsno Horizon. Comece baixo e suba olhando a taxa de erro. - Trecho grande demais. Cada entrada dos modelos
text-embedding-3aceita até 8192 tokens. CommaxCharsem 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, 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.