A semantic cache for LLM applications. It returns a stored answer when a new prompt means the same thing as an earlier one, and calls the model only when it does not. A rule-based guard and an optional verifier reduce the chance of returning a wrong answer for prompts that look alike but are not equivalent.
- Runs in-process with SQLite and NumPy. No server or vector database is required.
- Works with any embedding model: local, hosted, or the built-in offline hashing embedder.
- Sync and async APIs, streaming support, and adapters for OpenAI, LangChain and LlamaIndex.
- Optional FAISS index for large caches.
Contents: Installation | Quick start | Usage | Architecture | Performance and accuracy | Limitations | Development | Contributing | Security | License
Embedding similarity alone is not a safe test for "same question". These pairs score above typical thresholds but need different answers:
| Prompt A | Prompt B |
|---|---|
| Convert 10 km to miles | Convert 10 miles to km |
| What is C++? | What is C#? |
| Is x > 5? | Is x < 5? |
| Who is the CEO of Tesla? | Who is the CEO of Apple? |
semantic-cache checks each candidate match against explicit rules (numbers, units,
negation, symbols, entities, question type) before returning it. Matches the rules
cannot settle are treated as misses unless a verifier approves them.
Requires Python 3.10 or newer. Install from source:
git clone https://github.com/0x-bala/semanticcache.git
cd semanticcache
pip install -e .The core package depends only on NumPy. Optional extras:
| Extra | Installs | Use |
|---|---|---|
ann |
faiss-cpu | Approximate search for large caches |
openai |
openai | OpenAI client wrapper and embedder |
cohere |
cohere | Cohere embedder |
langchain |
langchain-core | LangChain cache adapter |
llama-index |
llama-index-core | LlamaIndex adapter |
transformers |
sentence-transformers | Local embeddings and cross-encoder verifier |
web |
fastapi, uvicorn | Demo FastAPI app |
ollama |
ollama | Ollama client for the local demo |
dev |
pytest, ruff, build, anyio | Development |
all |
all of the above except dev |
pip install -e ".[ann,transformers]"from semantic_cache import SemanticCache
cache = SemanticCache(threshold=0.85, ttl=3600)
@cache.cached(namespace="my-app")
def ask(prompt: str) -> str:
return call_your_llm(prompt) # replace with a real call
ask("What is the capital of France?") # miss: calls the function, stores the answer
ask("what's the capital of france") # hit: answered from the cacheThe default embedder is an offline character n-gram hasher. It needs no model download and works for repeated or lightly reworded prompts. For real paraphrase matching, use a model-based embedder (see Embedders).
Without the decorator:
result = cache.get("What is the capital of France?")
if result is not None:
print(result.response, result.score)
else:
answer = call_your_llm("What is the capital of France?")
cache.set("What is the capital of France?", answer)The optional FastAPI demo exposes /ask, /health and /explain. It uses a local Ollama
model for both embeddings and text generation, uses FAISS HNSW for similarity search starting
with the first cached entry, and persists entries in manual_ollama_cache.db.
ollama pull llama3.2:3b
ollama pull nomic-embed-text
pip install -e ".[web,ollama,transformers]"
python -m uvicorn manual_api_ollama:app --host 127.0.0.1 --port 8765Open http://127.0.0.1:8765/docs to try the endpoints interactively. Send a prompt to
POST /ask; send the same prompt again to see cache_hit: true. /health reports cache
statistics and whether a verifier is configured. POST /explain is read-only: it never calls
the LLM, stores nothing, and returns the top candidate, the effective cosine threshold, the
decision, the guard verdict and reason, and the cross-encoder score. This demo has no
authentication, so keep it bound to localhost and do not expose it publicly.
SemanticCache(
embedder=None, # object with embed(text) and embed_batch(texts); default HashingEmbedder
store=None, # default SQLiteStore() in memory
threshold=0.85, # minimum cosine similarity for a candidate
ttl=None, # seconds before entries expire; None means never
namespace="default", # separate caches per model, prompt version or tenant
guard=True, # apply the hit guard
verifier=None, # callable(new_query, cached_query) -> bool for ambiguous matches
verify_always=True, # True: every ambiguous match needs the verifier
shadow_rate=0.0, # fraction of hits audited in the background
exact_match=True, # serve normalized exact repeats before embedding
)Namespaces keep answers apart. Use a different namespace for each model, system prompt, or other input that changes the answer.
from semantic_cache import SemanticCache, SQLiteStore
store = SQLiteStore("cache.db", max_entries=50_000, eviction_policy="lru") # lru | lfu | fifo
cache = SemanticCache(store=store)
...
cache.close() # flushes pending writesWrites are batched and flushed in the background and on normal exit. Pass
batch_writes=False to SQLiteStore if every write must be durable immediately.
| Embedder | Notes |
|---|---|
HashingEmbedder |
Offline, no dependencies. Good for repeats and small rewordings. |
SentenceTransformerEmbedder |
Local model, real paraphrase matching. Needs the transformers extra. |
OpenAIEmbedder, CohereEmbedder, OllamaEmbedder |
Hosted or self-hosted providers. |
GeminiEmbedder |
Reads GEMINI_API_KEY. Output size is configurable (128 to 3072). |
from semantic_cache import SemanticCache, SentenceTransformerEmbedder
cache = SemanticCache(embedder=SentenceTransformerEmbedder(), threshold=0.85)API keys are read from environment variables or passed explicitly. They are never logged.
For each candidate above the threshold the guard returns one of three verdicts:
| Verdict | Meaning | Result |
|---|---|---|
ACCEPT |
No rule found a difference | Hit |
REJECT |
A rule found a conflict (numbers, units, negation, symbols, direction, question type, quoted text, time-sensitive query) | Miss |
VERIFY |
Words or roles differ in a way rules cannot settle (different entity in the same slot, swapped roles, different question frame) | Miss, unless a verifier approves |
Without a verifier, VERIFY matches are treated as misses. This is the safe default and
it lowers the hit rate on loosely worded paraphrases. To recover them, supply a verifier:
from semantic_cache import SemanticCache, GeminiVerifier
cache = SemanticCache(verifier=GeminiVerifier()) # uses GEMINI_API_KEYAny callable verifier(new_query, cached_query) -> bool works, including a local model
such as CrossEncoderVerifier (transformers extra). CrossEncoderVerifier defaults to
threshold 0.9 on a 0-1 scale (earlier versions used an unreachable 4.25); calibrate it on a
labeled sample of your own pairs. allow_question_intent_mismatch (default False) lets the
"question intent differs" rule send candidates to the verifier instead of rejecting them
when a verifier is configured. A verifier that errors or times out returns "no", so a
failure produces a miss, not a wrong answer.
Conservative verification policy: A VERIFY verdict is always adjudicated — it
can never be bypassed by a high cosine similarity score. verify_always=False is retained
for backward compatibility but does not affect VERIFY handling. REJECT verdicts cannot
be overridden by a permissive verifier. If multiple candidates exist, the cache continues
searching after a rejection or failed verification.
The hit guard is a finite set of lexical rules, not a semantic equivalence proof. It catches
known confusion patterns (numbers, units, negation, operators, entity slots, question type)
but cannot guarantee that two queries produce the same correct answer. For production use
cases where correctness is critical, always configure a verifier. Namespace and
CacheContext partitioning separates data but is not an authorization boundary — do not
rely on it alone for tenant isolation without your own access controls.
answer = await cache.aask("Explain event loops", call_llm) # sync or async callable
for chunk in cache.get_or_compute_stream("Say hello", stream_llm):
print(chunk, end="")
async for chunk in cache.async_get_or_compute_stream("Say hello", async_stream_llm):
print(chunk, end="")A cached stream is replayed as a single chunk containing the full response.
from semantic_cache import cached
@cached(ttl=600)
def summarize(prompt: str, model: str = "small") -> str: ...Arguments other than the prompt (here, model) are part of the cache key, so calls that
differ only in those arguments never share an answer. Pass bypass=True to skip the cache
for one call.
# OpenAI
from openai import OpenAI
from semantic_cache import SemanticCache, wrap_openai
client = wrap_openai(OpenAI(), cache=SemanticCache(threshold=0.92))
# LangChain
from langchain_core.globals import set_llm_cache
from semantic_cache import LangChainSemanticCache
set_llm_cache(LangChainSemanticCache())Shadow mode re-computes a sample of cache hits with the real model and compares the results, giving a measured false-hit rate from your own traffic.
cache = SemanticCache(
shadow_rate=0.05,
shadow_comparator=lambda fresh, cached: are_equivalent(fresh, cached),
)
...
cache.flush_shadow()
print(cache.stats.estimated_false_hit_rate)The estimate is only as good as the comparator. Comparing exact strings overstates false hits, because two correct answers rarely match character for character. About 300 audited hits with no mismatches support a false-hit rate below 1%.
store = SQLiteStore("cache.db", ann_backend="faiss", ann_threshold=10_000)Below ann_threshold entries, search is exact brute force. Above it, FAISS HNSW is used
and evicted entries are filtered from results until about a quarter of the index is stale,
at which point the index is rebuilt.
python tools/tune_threshold.py --csv pairs.csvThe CSV has the columns query_a,query_b,should_hit. The tool reports precision and
recall for several thresholds with the guard on and off.
SemanticCache
|-- Exact tier normalized-text hash -> stored answer
|-- Embedder text -> unit vector (with an LRU cache of recent embeddings)
|-- SQLiteStore
| |-- in-memory index NumPy matrix, optional FAISS HNSW
| |-- background writer batched SQLite writes (WAL mode)
| '-- eviction LRU, LFU or FIFO under max_entries
|-- Hit guard ACCEPT / VERIFY / REJECT per candidate
|-- Verifier optional, decides VERIFY candidates
'-- Shadow audit optional, samples hits against fresh model output
A lookup proceeds in this order:
- Normalize the query and check the exact tier.
- Embed the query (cached embeddings are reused).
- Find the nearest stored entries in the namespace at or above the threshold.
- Run the guard on the best candidate.
REJECTends the lookup as a miss. - For
VERIFY, call the verifier if one is configured; otherwise miss. - On a hit, record usage for eviction and optionally schedule a shadow audit.
Everything the cache stores lives in one SQLite file. Cross-process safety is limited to safe concurrent opening of a shared file; see Limitations.
Numbers come from benchmarks/results_real.json. Embeddings are real
(all-MiniLM-L6-v2, 384 dimensions) and the questions are from the Quora Question Pairs
dataset. The machine was a 12-thread Windows PC.
| Brute force | FAISS HNSW | |
|---|---|---|
| Exact repeat (p50) | 0.005 ms | 0.005 ms |
| Semantic lookup (p50 / p99) | 3.58 ms / 4.10 ms | 0.20 ms / 0.34 ms |
| Miss (p50) | 3.53 ms | 0.34 ms |
| Index build | 10.7 s | 29.6 s |
| Memory | 1.13 GB | 1.53 GB |
| Recall@1 against brute force | exact | 98.1% |
| Insert past capacity (mean / worst) | 0.22 ms / 159 ms | 0.46 ms / 174 ms |
Below roughly 10,000 entries brute force is fast enough and needs no extra dependency.
1,000 duplicate and 1,000 non-duplicate pairs at threshold 0.85, decided by
SemanticCache.get():
| Setting | Duplicates served | Wrong answers served |
|---|---|---|
Similarity only (guard=False) |
64.0% | 14.7% |
| Guard on, no verifier (default) | 7.8% | 0.8% |
Guard on, verify_always=False |
21.1% | 2.8% |
| Guard on + CrossEncoderVerifier (threshold 0.9) | 25.5% | 1.9% |
The numbers come from Quora question pairs with MiniLM embeddings, so results on other data and embedders will differ. At cosine threshold 0.80 the verifier row is 26.6% / 2.0%.
The guard serves about 18 times fewer wrong answers than similarity alone. Without a verifier it also serves far fewer correct paraphrases, because ambiguous matches are treated as misses. The Quora dataset is loosely labeled (some "non-duplicates" are reasonable paraphrases), so absolute values on your traffic will differ.
Shadow-mode estimates against ground truth with a semantic comparator, FAISS on hnswlib at scale, and a comparison with other caches are not yet published.
To reproduce:
python tests/benchmark.py # offline speed, no keys
python benchmarks/real_embeddings.py --part 12 # real-embedding speed and accuracy
python benchmarks/guard_rules.py # which guard rules block which pairs- Single process. Each process keeps its own in-memory index and does not see entries written by another process until it reloads.
- Writes are batched (about 50 ms) and flushed on normal exit. A hard kill can lose the last few writes.
- Brute-force search is exact but linear in the number of entries.
- The guard is rule-based and English-oriented. It does not understand meaning, which is why ambiguous cases are sent to a verifier or treated as misses.
- The database file is not encrypted. Do not cache secrets or personal data without your own retention and access controls. See SECURITY.md.
pip install -e ".[dev]"
python -m ruff check .
python -m pytest -q -m "not live"Tests that call live APIs are marked live and need a key and quota; the rest run offline.
CI runs lint, the tests and a package build on Python 3.10 to 3.13.
Repository layout:
semantic_cache/ library code (cache, store, embedders, guard, verifier, adapters)
manual_api_ollama.py local Ollama demo FastAPI app (/ask, /health, /explain)
tests/ unit and regression tests
benchmarks/ reproducible speed and accuracy benchmarks and recorded results
benchmarks/calibrate_verifier.py measures verifier thresholds
tools/ threshold tuning
Contributions are welcome. See CONTRIBUTING.md for the full guide.
- Open an issue to discuss larger changes before writing code.
- Create a branch, make focused commits, and add tests for any behavior change.
- Run
ruff check .andpytestlocally. Tests must not call the network. - Open a pull request that explains what changed and why.
Please report security issues privately through GitHub Security Advisories, not in public issues.
Cached responses are stored in plain text. CacheContext and namespaces partition data
but are not an authorization mechanism. See SECURITY.md.
MIT. See LICENSE.