Overview
A semantic cache returns a stored LLM answer when a new query’s embedding is close enough to a cached query’s. It fits high-volume, repetitive intent (support bots, FAQs) where a near-match answer is acceptable. Savings equal the measured hit rate times the per-call cost, minus the embedding and vector lookup on every request. For the concept, see semantic-cache. For re-embedding costs, see embeddings-cost-control.
Provider prompt caching is a different tool: it makes re-reading a long prefix cheaper but still generates a fresh answer. See prompt-caching-strategies. Use both when the prefix is long and the questions repeat.
Tune the threshold on labeled pairs, starting strict
The threshold is a cosine similarity cutoff, and its useful value depends on the embedding model, so a number copied from another system is not evidence.
- Collect 100 or more real query pairs and label each pair “same answer” or “different answer”.
- Score every pair with your model and pick the lowest threshold with no false positives you cannot tolerate.
- Start strict, loosen as the labeled false-positive rate allows, and re-run when you change the embedding model.
hit = nearest and nearest.score >= THRESHOLD # cosine similarity; tuned on labeled pairsRaise the threshold for legal, medical, and financial answers, or skip the cache where freshness matters more than cost.
Scope every entry
A cache entry is valid only for the context that produced it. Store, and filter on, these fields:
- the query embedding (normalized; see embeddings-dimensionality),
- the response text,
tenant_idor user scope whenever the answer uses account data,prompt_version, retrieval-corpus version, and the generating model ID (for exampleclaude-sonnet-5-5),created_atand an expiry.
Never serve an entry across tenants when the answer reflects account state.
Use pgvector when Postgres is already in the stack
CREATE TABLE semantic_cache (
id BIGSERIAL PRIMARY KEY,
embedding vector(1024) NOT NULL,
response TEXT NOT NULL,
tenant_id TEXT NOT NULL,
prompt_version TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_hit TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON semantic_cache USING hnsw (embedding vector_cosine_ops);
SET hnsw.iterative_scan = strict_order; -- pgvector 0.8+: keep scanning when the filter drops rows
SELECT response, 1 - (embedding <=> $1) AS score
FROM semantic_cache
WHERE tenant_id = $2 AND prompt_version = $3
ORDER BY embedding <=> $1
LIMIT 1;An approximate index applies the WHERE filter after the scan, so without iterative scans a selective filter can return no row even when a match exists. A vector HNSW index supports up to 2,000 dimensions (see embeddings-dimensionality). chromadb supports the same filtered query for non-Postgres stacks.
Invalidate on every change that alters the answer
TTL alone is not invalidation. Delete or ignore entries when the prompt template, retrieval corpus, or model changes.
- Tag entries with
prompt_versionand filter on it; delete old tags on deploy. - Or namespace by a hash of prompt plus model and let old entries expire.
- Bound the table: evict by
last_hitorcreated_atonce it passes a row cap.
DELETE FROM semantic_cache WHERE id IN (
SELECT id FROM semantic_cache ORDER BY last_hit ASC LIMIT 10000);Track hit rate and false-positive rate
Report hit rate, false-positive rate (cache hits whose answer was wrong, from user feedback or a judge), and net cost saved. A high hit rate with false positives is a correctness bug. See rag-eval for the eval setup.