Cache semântico é guardar a resposta de um LLM junto com o embedding da pergunta e reaproveitá-la quando chega outra pergunta com o mesmo significado, mesmo escrita de outro jeito. No Laravel dá para montar isso com duas camadas: um cache exato no Redis, pelo hash da pergunta normalizada, e uma busca por similaridade no PostgreSQL com pgvector, com um limiar conservador. A parte difícil é decidir o que não pode ir para o cache: respostas personalizadas, dados de outro cliente e respostas truncadas.
No atendimento por WhatsApp e nas centrais de ajuda, muita gente pergunta a mesma coisa com palavras diferentes: "como mudo o horário?", "dá pra remarcar?", "quero trocar o dia do meu agendamento". Pagar o Claude para gerar a mesma resposta toda vez é desperdício de dinheiro e de latência. Mas um cache semântico mal feito é pior que nenhum: ele devolve, com toda a confiança, a resposta certa para a pergunta errada.
O que você vai ter funcionando no final
- Uma classe
SemanticCachecom um métodoremember()que recebe tenant, escopo, pergunta e uma closure que chama o LLM. - Cache exato no Redis para perguntas idênticas (custa zero chamadas de API).
- Busca vetorial no pgvector para perguntas parecidas, com limiar de similaridade configurável.
- Isolamento por tenant (cliente da plataforma) e por versão do prompt.
- Comandos Artisan para calibrar o limiar e limpar entradas vencidas.
- Um teste com
Http::fakeprovando que a segunda pergunta parecida não chama o Claude.
Pré-requisitos:
- Laravel 11 ou 12, PHP 8.2+.
- PostgreSQL 15+ com a extensão pgvector instalada (0.8+ se quiser iterative scans, explico abaixo).
- Redis configurado como store de cache.
- Chave da API da Anthropic (para o Claude) e da OpenAI (só para embeddings, porque a Anthropic não oferece modelo de embeddings).
Embedding, para alinhar: é um vetor de números que representa o significado de um texto; textos com significado parecido geram vetores próximos. Se você nunca montou embeddings com pgvector, o post sobre RAG com pgvector no Laravel cobre a base. Aqui o foco é outro: reaproveitar respostas, não buscar documentos.
Cache semântico é a mesma coisa que prompt caching?
Não. Prompt caching é um recurso da API do Claude que reaproveita o processamento de um prefixo repetido do prompt (system prompt, documentos fixos) e cobra menos por esses tokens, mas o modelo ainda gera a resposta. Cache semântico é seu, na sua infraestrutura: se a pergunta já foi respondida, o Claude nem é chamado. Os dois se combinam bem; escrevi sobre o primeiro em prompt caching e Batch API do Claude no Laravel.
| Prompt caching | Cache semântico | |
|---|---|---|
| Onde vive | API da Anthropic | Seu Redis e PostgreSQL |
| O que economiza | Tokens de entrada repetidos | A chamada inteira |
| Risco de resposta errada | Nenhum | Existe, depende do limiar |
| Serve para | Qualquer chamada com prefixo fixo | Perguntas genéricas e repetitivas |
1. Configurar as chaves e o comportamento do cache
No .env:
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-haiku-4-5 OPENAI_API_KEY=sk-... SEMANTIC_CACHE_THRESHOLD=0.95 SEMANTIC_CACHE_TTL_DAYS=7 CACHE_STORE=redis
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5'), ], 'openai' => [ 'key' => env('OPENAI_API_KEY'), ],
E um arquivo próprio, config/semantic_cache.php:
<?php return [ 'threshold' => (float) env('SEMANTIC_CACHE_THRESHOLD', 0.95), 'ttl_days' => (int) env('SEMANTIC_CACHE_TTL_DAYS', 7), 'embedding_model' => 'text-embedding-3-small', 'dimensions' => 1536, // Versão do prompt por escopo. Mudou o prompt ou a base de conhecimento? Suba a versão. 'versions' => [ 'faq' => 'v1', ], ];
A versão por escopo é o que torna a invalidação trivial: entradas de v1 simplesmente param de ser encontradas quando você passa para v2. Se você já versiona prompts no repositório como descrevi em como versionar prompts no Laravel, use o mesmo identificador aqui.
2. Criar a tabela com a coluna vector no PostgreSQL
php artisan make:migration create_llm_semantic_cache_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('llm_semantic_cache', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('tenant_id'); $table->string('scope', 50); $table->string('prompt_version', 20); $table->text('question'); $table->text('answer'); $table->string('model', 60); $table->unsignedInteger('hits')->default(0); $table->timestamp('expires_at'); $table->timestamps(); $table->index(['tenant_id', 'scope', 'prompt_version']); }); DB::statement('ALTER TABLE llm_semantic_cache ADD COLUMN embedding vector(1536) NOT NULL'); DB::statement('CREATE INDEX llm_semantic_cache_embedding_idx ON llm_semantic_cache USING hnsw (embedding vector_cosine_ops)'); } public function down(): void { Schema::dropIfExists('llm_semantic_cache'); } };
HNSW é um tipo de índice aproximado para busca de vizinhos mais próximos: rápido, mas não garante achar o vizinho exato. Uso vector_cosine_ops porque a comparação é por distância de cosseno (operador <=> no pgvector). Uso SQL cru para a coluna para não depender de versão específica do Schema Builder.
php artisan migrate
3. Gerar embeddings com a API da OpenAI
<?php namespace App\Ai; use Illuminate\Support\Facades\Http; class Embeddings { /** @return array<int, float> */ public function embed(string $text): array { $response = Http::withToken(config('services.openai.key')) ->timeout(10) ->retry(2, 200) ->post('https://api.openai.com/v1/embeddings', [ 'model' => config('semantic_cache.embedding_model'), 'input' => $text, ]); return $response->json('data.0.embedding'); } }
O retry() do client HTTP do Laravel já lança exceção depois da última tentativa falhar. O timeout curto é proposital: o embedding está no caminho crítico de toda pergunta, e se ele demorar, o cache deixa de ser uma otimização.
4. Escrever a classe SemanticCache
O fluxo do remember():
- Normaliza a pergunta (minúsculas, espaços, pontuação final).
- Procura no Redis pelo hash exato. Achou, devolve.
- Gera o embedding e busca o vizinho mais próximo no pgvector, filtrando por tenant, escopo, versão e validade.
- Se a similaridade passa do limiar, devolve e conta o hit.
- Senão, chama o LLM pela closure. Se o resultado for cacheável, grava nas duas camadas.
<?php namespace App\Ai; use Closure; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; class SemanticCache { public function __construct(private Embeddings $embeddings) {} /** * @param Closure(string): LlmAnswer $generate */ public function remember(int $tenantId, string $scope, string $question, Closure $generate): string { $version = config("semantic_cache.versions.{$scope}", 'v1'); $normalized = $this->normalize($question); $exactKey = sprintf('semcache:%d:%s:%s:%s', $tenantId, $scope, $version, hash('sha256', $normalized)); if ($cached = Cache::store('redis')->get($exactKey)) { return $cached; } $vector = $this->toPgVector($this->embeddings->embed($normalized)); $row = DB::selectOne( 'SELECT id, answer, 1 - (embedding <=> ?::vector) AS similarity FROM llm_semantic_cache WHERE tenant_id = ? AND scope = ? AND prompt_version = ? AND expires_at > now() ORDER BY embedding <=> ?::vector LIMIT 1', [$vector, $tenantId, $scope, $version, $vector] ); if ($row && $row->similarity >= config('semantic_cache.threshold')) { DB::update('UPDATE llm_semantic_cache SET hits = hits + 1 WHERE id = ?', [$row->id]); Cache::store('redis')->put($exactKey, $row->answer, now()->addHours(6)); return $row->answer; } $answer = $generate($question); if ($answer->cacheable) { $ttl = now()->addDays(config('semantic_cache.ttl_days')); DB::insert( 'INSERT INTO llm_semantic_cache (tenant_id, scope, prompt_version, question, answer, model, embedding, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?::vector, ?, now(), now())', [$tenantId, $scope, $version, $normalized, $answer->text, $answer->model, $vector, $ttl] ); Cache::store('redis')->put($exactKey, $answer->text, $ttl); } return $answer->text; } private function normalize(string $text): string { $text = mb_strtolower(trim($text)); $text = preg_replace('/\s+/u', ' ', $text); return rtrim($text, " ?!."); } private function toPgVector(array $embedding): string { return '[' . implode(',', $embedding) . ']'; } }
E o objeto de retorno da closure:
<?php namespace App\Ai; final class LlmAnswer { public function __construct( public readonly string $text, public readonly string $model, public readonly bool $cacheable, ) {} }
Repare que a decisão de cachear é de quem gera a resposta, não do cache. O cache não tem como saber se a resposta menciona o saldo de alguém.
5. Usar o cache só onde a resposta é genérica
Esta é a decisão de arquitetura mais importante. Eu só passo pelo cache semântico o que eu chamo de escopo faq: perguntas cuja resposta depende apenas da base de conhecimento do tenant (políticas, horários, como usar o produto), nunca de dados do usuário que está perguntando. "Qual o horário de funcionamento?" entra. "Meu pagamento caiu?" não entra, nem perto.
Num agente com tool calling, isso fica natural: a ferramenta responder_duvida_geral usa o cache, e as ferramentas que consultam pedido, agenda ou pagamento não usam.
<?php namespace App\Ai; use Illuminate\Support\Facades\Http; class FaqAnswerer { public function __construct(private SemanticCache $cache) {} public function answer(int $tenantId, string $knowledgeBase, string $question): string { return $this->cache->remember($tenantId, 'faq', $question, function (string $q) use ($knowledgeBase) { $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ])->timeout(30)->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), 'max_tokens' => 600, 'system' => "Responda dúvidas gerais usando apenas a base abaixo. Não mencione dados de clientes.\n\n" . $knowledgeBase, 'messages' => [ ['role' => 'user', 'content' => $q], ], ])->throw(); return new LlmAnswer( text: $response->json('content.0.text'), model: $response->json('model'), cacheable: $response->json('stop_reason') === 'end_turn', ); }); } }
O cacheable só é verdadeiro quando stop_reason é end_turn. Se a resposta parou por max_tokens, ela está cortada e não merece ser servida mil vezes.
Para ver na prática quanto isso economiza no seu volume, jogue o número de chamadas evitadas na calculadora de custo de IA e compare com o custo dos embeddings, que é bem menor por chamada mas não é zero.
6. Calibrar o limiar de similaridade com perguntas reais
O limiar é o número que decide se duas perguntas são "a mesma". Não existe valor universal: depende do modelo de embeddings, do idioma e do domínio. Comece alto (0.95 é um ponto de partida conservador) e ajuste com dados. Este comando mostra a similaridade entre duas frases:
<?php namespace App\Console\Commands; use App\Ai\Embeddings; use Illuminate\Console\Command; class SemanticCacheCompare extends Command { protected $signature = 'semcache:compare {a} {b}'; protected $description = 'Mostra a similaridade de cosseno entre duas perguntas'; public function handle(Embeddings $embeddings): int { $a = $embeddings->embed(mb_strtolower($this->argument('a'))); $b = $embeddings->embed(mb_strtolower($this->argument('b'))); $dot = array_sum(array_map(fn ($x, $y) => $x * $y, $a, $b)); $norm = sqrt(array_sum(array_map(fn ($x) => $x * $x, $a))) * sqrt(array_sum(array_map(fn ($y) => $y * $y, $b))); $this->info(number_format($dot / $norm, 4)); return self::SUCCESS; } }
php artisan semcache:compare "como remarco meu horário" "dá pra trocar o dia do agendamento" php artisan semcache:compare "como cancelo a assinatura" "como evito que a assinatura seja cancelada"
O processo que funciona:
- Junte umas dezenas de pares reais do seu log: pares que deveriam bater e pares parecidos que não deveriam (negação, produto diferente, prazo diferente).
- Rode o comando em todos e anote as similaridades.
- Escolha o limiar acima do maior valor entre os pares que não deveriam bater, com folga.
- Em produção, amostre hits do cache periodicamente e confira se a resposta servida fazia sentido para a pergunta nova.
Prefiro perder hits a servir resposta errada. Um cache que acerta pouco ainda economiza; um que erra destrói a confiança no atendimento.
7. Limpar entradas vencidas e invalidar quando a base muda
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; class SemanticCachePurge extends Command { protected $signature = 'semcache:purge {--tenant=}'; protected $description = 'Remove entradas vencidas ou de um tenant específico'; public function handle(): int { $query = DB::table('llm_semantic_cache'); $this->option('tenant') ? $query->where('tenant_id', $this->option('tenant')) : $query->where('expires_at', '<=', now()); $this->info($query->delete() . ' entradas removidas'); return self::SUCCESS; } }
Em routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('semcache:purge')->daily();
Quando o tenant edita a base de conhecimento (mudou horário, política de cancelamento, taxa), rode semcache:purge --tenant=ID num listener do evento de atualização. Lembre que o Redis também guarda a resposta: como as chaves têm prefixo semcache:{tenant}, o mais simples é incluir no hash um updated_at da base do tenant, ou usar uma versão por tenant em vez de global. Eu prefiro a versão no hash: invalidação sem varrer chaves.
8. Testar que a segunda pergunta não chama o Claude
<?php use App\Ai\FaqAnswerer; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); it('reaproveita a resposta para pergunta equivalente', function () { Http::fake([ 'api.openai.com/*' => Http::response([ 'data' => [['embedding' => array_fill(0, 1536, 0.0255)]], ]), 'api.anthropic.com/*' => Http::response([ 'model' => 'claude-haiku-4-5', 'stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'Atendemos das 9h às 18h.']], ]), ]); $faq = app(FaqAnswerer::class); $faq->answer(1, 'Horário: 9h às 18h', 'Qual o horário de vocês?'); $second = $faq->answer(1, 'Horário: 9h às 18h', 'que horas vocês abrem'); expect($second)->toBe('Atendemos das 9h às 18h.'); $claudeCalls = Http::recorded(fn ($request) => str_contains($request->url(), 'anthropic.com')); expect($claudeCalls)->toHaveCount(1); });
O embedding falso é idêntico nas duas chamadas, então a similaridade é 1 e o teste valida o encanamento, não a qualidade do limiar. A qualidade se valida com o passo 6. Esse teste precisa rodar em PostgreSQL com pgvector, não em SQLite.
Erros comuns
- Resposta de um cliente servida para outro. Acontece quando o
tenant_idfica fora do filtro ou da chave do Redis. Ele tem que estar nas duas camadas, sempre. Se você usa Row-Level Security, ótimo, mas não dependa só dela num SQL cru. - Cachear resposta personalizada. O sintoma é um usuário recebendo "seu pagamento de R$ X foi aprovado" que não é dele. A regra: o contexto enviado ao modelo no escopo cacheável não pode conter nenhum dado do usuário. Se contém,
cacheableé falso. - Negação e quase-sinônimos. "Como cancelar" e "como não ser cancelado" costumam ter embeddings muito próximos. Limiar baixo transforma isso em resposta errada. Calibre com pares negativos, não só com positivos.
- Busca retorna vazio mesmo com dados. Com índice HNSW, o filtro
WHEREé aplicado depois da busca aproximada; se os vizinhos encontrados forem de outros tenants, sobra nada. No pgvector 0.8+, rodeSET hnsw.iterative_scan = relaxed_orderna sessão. Em versões antigas, aumentehnsw.ef_searchou, com poucos tenants grandes, crie índices parciais por escopo. - Erro "expected 1536 dimensions". Você trocou o modelo de embeddings ou passou o parâmetro
dimensionse a coluna ficou com outro tamanho. Modelo novo exige coluna nova e cache zerado: vetores de modelos diferentes não são comparáveis. - Resposta truncada ou de erro no cache. Sem checar
stop_reason, uma resposta cortada pormax_tokensvira a resposta oficial por dias. Grave sóend_turn. - Cache que nunca invalida. A base de conhecimento mudou e o cache continua respondendo o horário antigo. Versão no escopo e purge por tenant resolvem.
Conclusão prática
Comece pequeno: um único escopo genérico, limiar alto, TTL curto e uma amostragem semanal dos hits. Meça a taxa de acerto e o custo evitado antes de ampliar para outros escopos. O ganho real do cache semântico não está no código de busca vetorial, que é curto; está na disciplina de decidir o que pode ser reaproveitado e de manter o limiar honesto com dados reais. Se uma resposta depende de quem pergunta, ela não vai para o cache, ponto.
Perguntas frequentes
Dá para fazer cache semântico só com Redis?
Dá, usando Redis Stack ou Redis com módulo de busca vetorial. Se você já tem PostgreSQL com pgvector, usar o banco para a parte vetorial e o Redis só para o cache exato evita mais uma peça para operar.
Qual limiar de similaridade usar?
Não existe número certo para todo caso. Comece alto, perto de 0.95, e ajuste com pares reais de perguntas que deveriam e que não deveriam bater, sempre ficando acima dos pares negativos.
Funciona com streaming de resposta?
Funciona. No hit, você envia a resposta guardada de uma vez ou simula o stream em pedaços. No miss, faz o stream normal do Claude e grava o texto completo só no final, depois de confirmar o end_turn.
Guardar as perguntas dos usuários fere a LGPD?
No escopo genérico, as perguntas não deveriam ter dados pessoais, mas usuários escrevem o que querem. Aplique o mesmo mascaramento de CPF, e-mail e telefone que você usa antes de enviar ao LLM, defina TTL e inclua a tabela na sua política de retenção.
Vale a pena para volume baixo?
Com pouco tráfego a taxa de acerto tende a ser baixa e o embedding extra vira custo sem retorno. O cache semântico compensa quando há muitas perguntas repetitivas no mesmo tenant ou escopo.