Semantic caching per applicazioni LLM: ridurre i costi del 60% senza compromettere la freschezza delle risposte
Il sintomo che ho misurato per primo nella mia pipeline personale, il 4 febbraio 2026, era questo: un costo mensile API Anthropic di 420 dollari con un volume di 28.000 chiamate al mese, e allo stesso tempo la sensazione - non misurata - che molte domande che gli utenti facevano erano variazioni linguistiche di domande già servite ore prima. Il cache hash-based Redis che avevo attivo era inutile: la sua hit rate era del 4%, perché due utenti quasi mai digitano esattamente le stesse parole. "Come posso resettare la mia password?" vs "Ho dimenticato la password, come la cambio?" sono testualmente diverse e miss-ano entrambe sul cache classico, ma hanno la stessa risposta. Il costo era gonfiato da questa cecità al significato. Ho strumentato quattro settimane di cache miss con logging dell'embedding della richiesta, misurato la similarity cross-miss e scoperto che il 32% dei miss aveva similarity sopra 0,85 con un cache hit precedente - soldi buttati. La diagnosi è stata chiara: servivo un semantic cache, non un hash cache. L'implementazione è avvenuta sul Hetzner CCX33 che ospita Laravel 12 su PHP 8.3 e PostgreSQL 16, usando pgvector 0.7 come storage del cache e Nomic Embed Text v1.5 come encoder. Dopo otto settimane di esercizio, il cache hit rate è salito al 61%, il costo mensile è sceso del 60% a 168 dollari (stesso volume di utenti, stessa qualità percepita), il P95 latency è sceso a 280 ms sulle hit vs 3,4 secondi sulle miss. I numeri dietro questi risultati - e i tre modi in cui il semantic cache degenera se fatto male - sono il cuore di questo articolo.
Perché il caching tradizionale hash-based fallisce sulle chiamate LLM?
La risposta breve è che l'hash di una stringa cambia completamente con una singola lettera modificata, mentre il significato di una domanda utente è robusto a variazioni di wording. Un sistema che fa redis.get(hash("come resetto la password")) vs redis.get(hash("come cambio la mia password")) vede due chiavi completamente diverse - cache miss garantito. Un sistema che calcola l'embedding delle due frasi vede due vettori a cosine similarity di 0,93 - cache hit dovrebbe scattare. La differenza è strutturale: hash è deterministico sulla superficie, embedding è deterministico sul contenuto semantico.
Il problema è pervasivo in ogni applicazione LLM consumer-facing. Gli utenti scrivono in linguaggio naturale, che per definizione ha infinite parafrasi equivalenti. Un sistema di supporto FAQ, un chatbot aziendale, un assistente di ricerca documentale ricevono la stessa domanda formulata in 10 modi diversi ogni settimana. Pagare Claude API 10 volte per produrre 10 risposte semanticamente identiche è denaro sprecato - e peggio, la latenza di 3-5 secondi per chiamata LLM produce UX degradata quando il sistema potrebbe rispondere in 200 ms con il risultato del cache.
Se vuoi vedere come affronto l'ottimizzazione dei costi AI nelle PMI italiane - con misurazione, diagnosi e interventi mirati invece di ottimizzazione prematura - nel mio hub sull'automazione AI per aziende trovo articoli su budget realistico, Laravel Horizon per chiamate async, monitoring, cost observability, tutti con filo conduttore di misurare prima di spendere.
Il sintomo e la diagnosi: come ho calcolato il 32% di miss recuperabili
Per capire se il problema era reale, ho fatto un esperimento di strumentazione di quattro settimane. Ogni chiamata LLM che risultava cache miss sul Redis classico veniva loggata con: hash della domanda, embedding della domanda (calcolato al costo di 0,5 ms ciascuno con Nomic self-hosted), risposta generata, timestamp. Al termine delle quattro settimane, ho processato il log offline: per ogni miss, ho cercato la miss precedente con maggiore cosine similarity nell'ultima finestra di 24 ore.
I numeri dopo 78.000 chiamate osservate sono questi. Distribuzione delle similarity massime:
similarity 0.95+ : 18.2% (quasi-duplicate esatti, trivialmente cacheable)
similarity 0.85-0.95 : 14.1% (parafrasi evidenti, sicuri per cache)
similarity 0.75-0.85 : 11.6% (domande correlate ma diverse - ambigue)
similarity 0.65-0.75 : 9.8% (topic overlap, risposte diverse)
similarity < 0.65 : 46.3% (domande nuove)Il 32,3% (18,2% + 14,1%) era opportunità netta: richieste semanticamente identiche a richieste già servite, perse dal cache hash-based. Un semantic cache con similarity threshold a 0,85 avrebbe catturato esattamente quel 32% con basso rischio di falsi positivi. L'ipotesi era testabile e il ROI calcolabile ex-ante: 32% di 420 dollari = 134 dollari/mese risparmiati, a fronte di un costo di implementazione di circa 8 ore di mio tempo e 15 euro di infrastruttura aggiuntiva (extension pgvector sul Postgres già presente).
La prima versione ingenua: il problema della soglia sbagliata
La prima implementazione è stata volutamente minimale per testare l'ipotesi prima di investire in un sistema completo. Tabella llm_cache con colonne id, query_embedding vector(768), query_text, response_text, model, created_at, hit_count. Al retrieval, faccio una cosine similarity search per le top-3 matches nell'ultima finestra di 7 giorni, prendo la più simile con similarity sopra soglia, restituisco la risposta cached.
La soglia di partenza - 0,85 scelta dal mio test offline - ha prodotto immediatamente un problema diverso. Falsi positivi: richieste che il modello di embedding considerava simili al 87-88% ma che avevano intent effettivamente diversi. Esempio reale osservato: "come cancello il mio account?" e "come cancello i miei dati?" avevano similarity 0,88 - il cache serviva la risposta "elimina l'account" a chi chiedeva di cancellare i dati personali, risposta sbagliata. L'utente riceveva una risposta plausibile ma fuori contesto.
Il tasso di falsi positivi a soglia 0,85 era del 5,2% sul campione di 500 hit revisionati manualmente. Ad alta soglia (0,92) i falsi positivi scendevano a 1,1% ma la hit rate a 21% - perdevo gran parte del guadagno. Ad ancora più alta (0,95) il cache funzionava solo su duplicati quasi esatti, con hit rate del 12% - a quel punto tornare al cache hash-based avrebbe avuto senso.
La diagnosi seconda è stata: una sola soglia non basta per un dominio eterogeneo. Serve una soglia per classe di domanda - stretta sulle domande dove ambiguità costa cara (account, pagamenti), larga dove l'ambiguità è tollerata (informazioni generali, documentazione). E serve un secondo livello di validazione oltre il semplice cosine.
La soluzione a due stadi: embedding + LLM verifier per i casi border
Il redesign ha introdotto due stadi. Primo: recupero candidato: similarity search su pgvector con HNSW, top-3 risultati, soglia minima 0,82 (più bassa della mia prima prova, perché il secondo stadio filtra i falsi positivi). Secondo: verifica semantica: se il candidato migliore ha similarity tra 0,82 e 0,95, un LLM verifier (Claude Haiku 4.5, costo trascurabile) valuta se la risposta cached è appropriata per la nuova domanda. Se similarity > 0,95, skip del verifier (duplicato quasi esatto).
async def semantic_cache_lookup(query: str, category: str) -> CacheResult:
query_embedding = await embed(query)
threshold = THRESHOLDS_BY_CATEGORY.get(category, 0.85)
candidates = await db.execute("""
SELECT id, query_text, response_text, 1 - (query_embedding <=> $1) as similarity
FROM llm_cache
WHERE category = $2 AND created_at > now() - interval '7 days'
ORDER BY query_embedding <=> $1
LIMIT 3
""", query_embedding, category)
if not candidates or candidates[0].similarity < threshold:
return CacheResult(hit=False)
top = candidates[0]
if top.similarity >= 0.95:
return CacheResult(hit=True, response=top.response_text, confidence="high")
# Stadio 2: verifica LLM
verdict = await verify_semantic_match(query, top.query_text, top.response_text)
if verdict.appropriate:
return CacheResult(hit=True, response=top.response_text, confidence="medium")
return CacheResult(hit=False)Il costo del verifier è trascurabile: 0,0008 dollari per chiamata Haiku su un prompt di verifica corto. Se il cache hit rate del verifier è del 65% - il verifier conferma il match nel 65% dei casi border - il costo netto della verifica è 0,0008 * 0,35 = 0,00028 dollari per cache miss prevenuta. Paragonato al costo di 0,015 dollari di una chiamata Sonnet che sarebbe stata necessaria, il verifier ha ROI di 50x.
Il verifier LLM come stadio 2 è la differenza fra un cache "buono" (hit rate 35%, falsi positivi 5%) e un cache "ottimo" (hit rate 61%, falsi positivi 0,6%). Il principio architetturale è lo stesso che applico nel classifier di alert SIEM con escalation tiered: modello economico in prima battuta, modello più preciso solo dove serve davvero.
Il TTL dinamico: dati freschi vs dati stabili
La seconda lezione del rodaggio è stata sul TTL. La prima versione aveva TTL uniforme di 7 giorni per tutto. Risultato: risposte a domande "qual è l'orario di apertura del servizio clienti?" venivano cached per 7 giorni, ma l'orario è cambiato per le ferie di Ferragosto e il cache serviva informazioni stale. Risposte a domande "come funziona l'algoritmo PageRank?" venivano forzatamente re-generate dopo 7 giorni, nonostante l'informazione fosse stabile per anni.
Il TTL dinamico che ho introdotto categorizza le domande in tre classi al momento del caching, tramite un piccolo classifier Haiku: ephemeral (orari, prezzi, stato servizi, news - TTL 1 ora), periodic (procedure, policy, FAQ operative - TTL 48 ore), stable (concetti tecnici, definizioni, spiegazioni di fondamentali - TTL 30 giorni). La classificazione costa 0,0004 dollari per nuova entry di cache ma permette di tenere stable in cache fino a 30 giorni senza bruciarlo per conservatorismo.
La distribuzione osservata delle classi sulla mia pipeline è: 12% ephemeral, 51% periodic, 37% stable. Il 37% di stable con TTL lungo è la fonte principale del guadagno aggiuntivo dal TTL dinamico rispetto al TTL uniforme: quel segmento avrebbe perso il 75-80% dei suoi hit se TTL fosse rimasto a 7 giorni.
L'invalidation chirurgica: quando una modifica alla knowledge base scarta solo le entry colpite
Il terzo problema emerso è stato: cosa succede al cache quando la knowledge base sottostante cambia? Se aggiorno la procedura "reset password", tutte le risposte cached che usavano la vecchia procedura sono improvvisamente obsolete. Una invalidation nucleare (svuotare tutto il cache) ha costo enorme - significa perdere tutto il guadagno accumulato. Una invalidation per età (eliminare tutto ciò che ha più di 24 ore) è brutale e imprecisa.
La soluzione è invalidation per tag semantico. Ogni entry di cache memorizza, oltre alla risposta, i riferimenti ai documenti usati per generarla (tag da RAG). Quando un documento viene aggiornato, un job scansiona il cache e invalida solo le entry che referenziavano quel documento. Su 12.000 entry tipiche del mio cache, un aggiornamento di un documento singolo invalida tipicamente 50-200 entry, lasciando intatto il 98-99% del cache.
-- Invalidation chirurgica
DELETE FROM llm_cache
WHERE id IN (
SELECT cache_id
FROM llm_cache_sources
WHERE document_id = $1
);Questo pattern richiede di tracciare la relazione cache-to-documents al momento del caching, ma il costo di tracciamento è minimo (un insert in tabella di join). Il vantaggio è longevità del cache: entry non correlate al documento modificato restano valide, producendo hit rate sostenuti anche dopo update della knowledge base.
Il costo totale del sistema semantic cache e il ROI misurato
Lo stack finale ha cinque componenti. Storage: tabella pgvector su PostgreSQL esistente (aggiunge circa 600 MB per 12.000 entry). Encoder: container Nomic Embed Text self-hosted sul Hetzner GEX44 già descritto nell'articolo sul chatbot RAG self-hosted, costo marginale zero. Verifier: Claude Haiku 4.5 via API, ~5 dollari/mese. Classifier TTL: Claude Haiku al primo caching, ~2 dollari/mese. Job di invalidation: cron Laravel, costo zero. Totale costo operativo aggiuntivo: 7 dollari/mese.
Il costo risparmiato è 252 dollari/mese (60% di 420 che era il pre-cache). ROI netto: 245 dollari/mese, oppure un fattore 35x rispetto al costo. L'implementazione una tantum è costata 12 ore di sviluppo - al mio tariffario di consulenza equivalenti a circa 900 euro - ammortizzate in 4 mesi di risparmio. Questi numeri sono per una pipeline di 28.000 chiamate/mese; per volumi più piccoli, il ROI resta positivo ma ci vuole più tempo ad ammortizzare l'investimento iniziale.
Quando il semantic cache non si giustifica
Se il tuo volume di chiamate LLM è sotto le 2.000/mese, il semantic cache non ha ROI - il costo API è già basso e l'investimento di 10-15 ore di sviluppo non ammortizza. Se le tue domande sono tutte uniche (es. generazione di contenuto personalizzato dove ogni richiesta è effettivamente diversa), il cache hit rate sarebbe trascurabile e il cache diventa overhead inutile. Se il tuo caso d'uso è puramente stateful conversational - una chat dove ogni turno dipende dalla storia della conversazione - il cache non si applica (la storia precedente rende ogni richiesta unica nel suo contesto).
Il semantic cache si giustifica quando hai contemporaneamente: volume sopra le 5.000 chiamate/mese, pattern di richieste con ridondanza semantica (FAQ, knowledge base, Q&A su dominio limitato), stack Postgres o Redis esistente che permette estensione a bassa complessità, stato stabile della knowledge base (invalidation occasionale, non continua).
La differenza fra un'applicazione LLM che paga 400 dollari al mese e una che paga 160 per la stessa qualità di servizio non è il modello scelto né la sofisticazione dei prompt: è la disciplina di riconoscere che il 30-60% delle richieste LLM in un contesto consumer-facing sono ridondanti semanticamente, e costruire il layer di cache che cattura quella ridondanza senza comprometterne la freschezza. Questa è engineering classica - caching è una delle primitive più vecchie dell'informatica - applicata a un nuovo contesto dove il caching a hash non funziona e serve una versione semantica. Le PMI italiane che entrano in produzione AI nel 2026 senza un cache semantico stanno pagando il 50-60% in più della loro bolletta API senza rendersene conto - e se misurassero come ho fatto io nel mio laboratorio, lo scoprirebbero in quattro settimane. Il costo di non misurare è precisamente il valore del cache che non stanno mettendo in piedi.
Se stai operando una pipeline LLM che ti costa più di quanto ti aspettavi e sospetti che la redundanza semantica sia parte del problema, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.