Configurações

Idioma

Por que seu Semantic Cache está retornando respostas erradas

L
LemonData
·5 de março de 2026·189 visualizações
#cache semântico#embeddings#infraestrutura de LLM#depuração em produção
Por que seu Semantic Cache está retornando respostas erradas

Um usuário relatou que nosso plugin de tradução estava retornando o mesmo resultado em cache para cada solicitação, independentemente da entrada. Investigamos e descobrimos algo pior: 95% de todos os semantic cache hits em nossa plataforma eram falsos positivos. 199 solicitações de tradução diferentes, 198 corpos de solicitação únicos, uma resposta em cache servida para todos eles.

O Relato do Bug

O relato foi simples: "Desativei o cache semântico, mas cada tradução retorna o mesmo resultado."

Três IDs de solicitação, três segmentos de tradução diferentes, respostas em cache idênticas. Os corpos das solicitações variavam de 1.564 a 8.676 bytes. O ID da resposta em cache era o mesmo em todos eles: chatcmpl-DG6J03nhdvcF7Ek0C8rJkjh7lN9pF.

Primeira suspeita: as configurações de cache do usuário não estavam sendo aplicadas. Isso acabou sendo um bug separado de sincronização de fonte de dados (o painel de administração escrevia em uma tabela, o API gateway lia de outra). Mas corrigir isso resolveu apenas metade do problema. Mesmo com o cache ativado e funcionando corretamente, o cache semântico estava correspondendo a solicitações que nunca deveriam coincidir.

Os Dados de Produção

Extraímos 24 horas de dados de cache hit do ClickHouse. Os números eram ruins.

Model Total Requests Cache Hits Unique Requests Unique Responses Hit Rate
gpt-4.1-nano 200 199 198 1 99.5%
glm-4.6-thinking 100 38 13 1 38%
gpt-5-nano 31 29 28 2 93.5%
gpt-oss-120b 18 17 17 1 94.4%
qwen3-vl-flash 17 16 16 1 94.1%

198 solicitações de tradução únicas, todas retornando a mesma resposta única em cache. Isso não é um cache. Isso é uma função quebrada que retorna uma constante.

Cada modelo afetado compartilhava dois traços: todas as solicitações vinham de um único usuário e todas usavam um template de system prompt fixo com conteúdo de usuário variável.

Por que Embeddings falham em entradas estruturadas

O plugin de tradução envia solicitações como esta:

System: "Act as a translation API. Output a single raw JSON object only.
         Input: {"targetLanguage":"<lang>","title":"...","segments":[...]}"

User:   {"targetLanguage":"zh","title":"Product Page",
         "description":"Translate product descriptions",
         "tone":"formal",
         "segments":[{"text":"actual varying content here"}]}

O system prompt é idêntico em todas as solicitações. A mensagem do usuário é um objeto JSON onde targetLanguage, title, description e tone são fixos. Apenas segments[].text muda.

Quando nosso cache semântico extrai texto para embedding, ele concatena o system prompt e a mensagem do usuário. O template fixo representa cerca de 80% do texto. O modelo de embedding (all-mpnet-base-v2, 768 dimensões) comprime isso em um vetor onde a estrutura do template domina. O conteúdo real da tradução mal altera o resultado.

Resultado: a similaridade de cosseno entre "traduzir 'Hello world'" e "traduzir 'O relatório financeiro trimestral mostra um aumento de 15% na receita'" excede 0.95. Nosso threshold é 0.95. Cada solicitação de tradução corresponde à primeira entrada em cache.

Analisando os logs, encontramos três maneiras pelas quais isso falha:

O plugin de tradução é o pior caso. Chaves e valores JSON fixos ofuscam os segmentos de tradução reais. Tanto o gpt-4.1-nano quanto o gpt-5-nano foram afetados por isso.

Um assistente de sumarização de contexto tinha uma variação do mesmo problema. Seu system prompt era tão longo que o conteúdo do usuário (variando de 5KB a 47KB) mal era registrado no embedding. Foi assim que o glm-4.6-thinking acabou retornando o mesmo resumo para cada conversa.

O terceiro padrão era mais sutil. Para o gpt-oss-120b e qwen3-vl-flash, os primeiros 500 caracteres de cada solicitação eram idênticos byte a byte. O conteúdo variável vinha depois, mas o embedding já estava dominado pelo prefixo compartilhado.

O que diz a pesquisa

Este não é um problema novo. Artigos recentes o quantificaram.

O projeto vCache da UC Berkeley descobriu que cache hits corretos e incorretos têm "distribuições de similaridade altamente sobrepostas". O threshold ideal varia de 0.71 a 1.0 entre diferentes entradas em cache. Nenhum número único funciona. A solução deles: aprender um threshold separado por entrada de cache, o que reduziu as taxas de erro em 6x enquanto dobrou as taxas de acerto. (vCache, 2025)

Fica pior quando você mistura tipos de consulta. Um estudo de cache consciente de categoria mostrou que um threshold de 0.80 produz 15% de correspondências falsas em consultas de código (sort_ascending vs sort_descending), enquanto o mesmo threshold perde paráfrases válidas em consultas conversacionais. Um threshold, dois modos de falha. (Category-Aware Semantic Caching, 2025)

Os bancos também enfrentam isso. Um estudo de caso do InfoQ documentou um sistema RAG onde "Posso pular o pagamento do meu empréstimo este mês" correspondia a "O que acontece se eu perder um pagamento de empréstimo" com 88,7% de similaridade. Intenções diferentes, mesma resposta em cache. Eles começaram com uma taxa de falsos positivos de 99% e precisaram de quatro rodadas de otimização para baixar para 3,8%. (InfoQ Banking Case Study, 2025)

A questão mais profunda: embeddings medem se dois prompts são semanticamente semelhantes, não se a mesma resposta pode responder a ambos. Essa lacuna é onde vivem os falsos cache hits. (Efficient Prompt Caching via Embedding Similarity, 2024)

Todos os artigos que encontramos concordam em uma coisa: a similaridade de embedding por si só não é suficiente. Você precisa de uma camada de verificação.

A correção em duas camadas

Construímos duas defesas. A primeira remove o ruído do template antes do embedding. A segunda verifica os acertos após a correspondência.

Camada 2: Extração de conteúdo para Embeddings

Antes de gerar um embedding, agora detectamos entradas estruturadas (JSON) e extraímos apenas o conteúdo significativo e variável.

A lógica:

  1. Verificar se o conteúdo da mensagem começa com { ou [
  2. Se for analisado como JSON, coletar recursivamente todos os valores de string das folhas
  3. Filtrar valores curtos (20 caracteres ou menos), pois normalmente são campos de configuração como "zh", "formal" ou "Product Page"
  4. Se o texto extraído for muito curto ou vazio, usar o texto original como fallback
function extractContentForEmbedding(text: string): string {
  const extracted = tryExtractJsonContent(text);
  return extracted && extracted.length > 20 ? extracted : text;
}

Isso se aplica tanto ao system prompt quanto à mensagem do usuário. Para o plugin de tradução, o embedding agora representa "Hello world" em vez de um blob JSON de 2KB. Para o assistente de sumarização, ele extrai a conversa real de dentro do wrapper do template.

O threshold de 20 caracteres foi escolhido empiricamente:

  • "zh" (2 caracteres): filtrado. Valor de configuração.
  • "formal" (6 caracteres): filtrado. Valor de configuração.
  • "Product Page" (12 caracteres): filtrado. Campo de template.
  • "Translate product descriptions" (31 caracteres): mantido. Conteúdo significativo.
  • "The quarterly financial report..." (40+ caracteres): mantido. Conteúdo real da tradução.

Camada 3: Verificação de Fingerprint

Após um hit no cache semântico, comparamos um hash do texto extraído da solicitação atual com o hash armazenado na entrada em cache. Se eles não coincidirem, o hit é rejeitado.

// Na escrita do cache
entry.metadata.textHash = fnv1aHash(extractedText);

// Na leitura do cache, após encontrar uma correspondência de similaridade
if (entry.metadata.textHash !== undefined) {
  if (entry.metadata.textHash !== fnv1aHash(currentExtractedText)) {
    // Falso positivo: semanticamente similar, mas conteúdo diferente
    metrics.recordFingerprintRejection();
    return null;
  }
}

O hash usa o texto extraído (pós-Camada 2), não a entrada bruta. Duas solicitações com wrappers de template diferentes, mas conteúdo real idêntico, ainda coincidem. Conteúdo diferente, hash diferente, rejeitado.

Entradas de cache antigas sem textHash ignoram a verificação (compatibilidade reversa). Elas expiram naturalmente via TTL.

Usamos FNV-1a (32 bits) para o hash. Rápido, determinístico e uma taxa de colisão de ~1 em 4 bilhões é aceitável para verificar um único cache hit.

Por que não apenas aumentar o Threshold?

Nosso threshold já é 0.95. Aumentá-lo não ajuda. O problema é que entradas estruturalmente semelhantes produzem pontuações de similaridade acima de 0.95, não importa o que o conteúdo real diga.

Os dados do vCache confirmam isso: as distribuições de similaridade de acertos corretos e incorretos se sobrepõem tanto que nenhum ponto de corte único os separa. Aumente o threshold para 0.99 e você eliminará cache hits legítimos para paráfrases sem eliminar os falsos positivos de solicitações pesadas em templates.

Corrija a entrada, verifique a saída. Não mexa no threshold.

Resultados

Com ambas as camadas implementadas:

Metric Before After
gpt-4.1-nano false positives 198/199 0
False positive share of all cache hits ~95% <5%
Legitimate cache hit rate Unchanged Unchanged
Added latency per request 0 <1ms (JSON parse + FNV hash)

A Camada 2 sozinha teria corrigido o plugin de tradução. A Camada 3 é a rede de segurança para casos em que a extração JSON não separa totalmente o conteúdo, ou para entradas estruturadas que não são JSON.

Conclusões

Se você estiver executando um cache semântico em produção:

  1. Monitore a diversidade de respostas. Se um modelo tem 100% de taxa de cache hit e 1 resposta única, você tem um problema de falso positivo. Consulta: SELECT model, uniqExact(substring(response_body, 1, 200)) as unique_responses, count() as total FROM request_logs WHERE cache_hit = true GROUP BY model.

  2. Entrada estruturada mata o embedding ingênuo. Qualquer solicitação com um template fixo (APIs JSON, wrappers de system prompt, tarefas de preenchimento de formulários) produzirá pontuações de similaridade artificialmente altas. Pré-processe antes do embedding.

  3. Uma camada de verificação não é opcional. Todo cache semântico de produção na literatura de pesquisa possui uma. A questão é se você usa uma verificação de hash leve, um cross-encoder reranker ou uma chamada de verificação completa por LLM. Escolha com base no seu orçamento de latência.

  4. Thresholds globais são um compromisso, não uma solução. Diferentes tipos de consulta precisam de diferentes thresholds. Se você não puder usar thresholds por categoria ou por entrada, pelo menos adicione o pré-processamento de entrada para normalizar a qualidade do embedding entre as categorias.

O cache semântico pode reduzir de 30% a 70% os custos de API de LLM. Mas sem o pré-processamento de entrada e a verificação de acertos, você está servindo respostas obsoletas e chamando isso de ganho de desempenho.


A LemonData oferece acesso unificado a mais de 300 modelos de AI com cache integrado, roteamento e otimização de custos. Experimente grátis com $1 de crédito.

Share: