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

  1. Uma classe SemanticCache com um método remember() que recebe tenant, escopo, pergunta e uma closure que chama o LLM.
  2. Cache exato no Redis para perguntas idênticas (custa zero chamadas de API).
  3. Busca vetorial no pgvector para perguntas parecidas, com limiar de similaridade configurável.
  4. Isolamento por tenant (cliente da plataforma) e por versão do prompt.
  5. Comandos Artisan para calibrar o limiar e limpar entradas vencidas.
  6. Um teste com Http::fake provando 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():

  1. Normaliza a pergunta (minúsculas, espaços, pontuação final).
  2. Procura no Redis pelo hash exato. Achou, devolve.
  3. Gera o embedding e busca o vizinho mais próximo no pgvector, filtrando por tenant, escopo, versão e validade.
  4. Se a similaridade passa do limiar, devolve e conta o hit.
  5. 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:

  1. 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).
  2. Rode o comando em todos e anote as similaridades.
  3. Escolha o limiar acima do maior valor entre os pares que não deveriam bater, com folga.
  4. 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

  1. Resposta de um cliente servida para outro. Acontece quando o tenant_id fica 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.
  2. 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.
  3. 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.
  4. 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+, rode SET hnsw.iterative_scan = relaxed_order na sessão. Em versões antigas, aumente hnsw.ef_search ou, com poucos tenants grandes, crie índices parciais por escopo.
  5. Erro "expected 1536 dimensions". Você trocou o modelo de embeddings ou passou o parâmetro dimensions e a coluna ficou com outro tamanho. Modelo novo exige coluna nova e cache zerado: vetores de modelos diferentes não são comparáveis.
  6. Resposta truncada ou de erro no cache. Sem checar stop_reason, uma resposta cortada por max_tokens vira a resposta oficial por dias. Grave só end_turn.
  7. 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.