The lookup path
An exact hash match costs one indexed lookup and skips the embedding call entirely. Only on a miss does the cache embed the query and search by vector similarity, and a neighbour is returned only if it clears a configurable threshold.
~0.5ms
Exact hash lookup
50–150ms
Embedding one query
0.95
Default similarity threshold
20k rows
HNSW verified with EXPLAIN ANALYZE
hit.embedding is why get returns a GetResponse rather than a bare entry. On a miss it carries the vector that was just computed, so the following set does not embed the same text a second time.
Architecture
Cache owns the lookup policy and the failure behaviour. Its two dependencies are structural, so swapping OpenAIEmbedder for a local sentence-transformers model is a class with an embed method, no inheritance, no registration.
Cache
·
Owns the exact → semantic ordering
·
Applies similarity_threshold
·
Catches CacheError and degrades to a miss
Embedder
·
One method: embed(text) -> Embedding
·
OpenAIEmbedder ships with it
·
A local model is a class with an embed method
CacheStore
·
get_exact · search · put · touch · touch_many
·
evict_expired · purge · clear
·
Postgres is the only backend today
Concepts
scope
Partitions the cache. Everything that must not share answers goes in it: the model, and usually the thread or tenant. get_exact, search and clear all filter on it, so entries cannot leak between conversations, or from a weak model to a strong one.
scope = f'{model}|thread:{thread_id}'
fingerprint
The exact-match key: a SHA-256 of the normalised query plus any parameters that should change the answer. Normalisation lowercases and collapses whitespace; parameters are sorted, so argument order does not matter, and repr is used so 0 and ‘0’ do not collide.
fingerprint('What is 2+2?', temperature=0.0)
embedding
A lookup key, never part of a CacheEntry. It is passed alongside on write and used for ordering on read, but the store never returns it, an entry read to bump a counter should not drag 6KB of floats with it.
cache.set(entry, embedding=hit.embedding)
Read the source
Excerpts straight from the repository: the lookup policy, the fingerprint, both protocols, the pgvector backend, the hit buffer, the maintenance scheduler and the settings.
Failure behaviour
Cache catches CacheError and degrades to a miss, so the caller does exactly what it would have done without a cache at all. What is a bug rather than a blip is deliberately left to crash.
Where the split is enforced
Postgres._connection translates only OperationalError and PoolTimeout into StoreError. A DataException from mismatched vector widths stays a psycopg error, because degrading it silently would leave a cache that never hits and never complains.
Schema
Nothing in the library creates or alters tables, so the migration files stay the single description of the schema, which is why alembic upgrade head is not an optional setup step. Migrations are hand-written SQL; with no SQLAlchemy models, --autogenerate has nothing to diff.
llm_cache_embedding_idx
HNSW on vector_cosine_ops. It must match the <=> operator in search, or Postgres drops the index and scans. Verified with EXPLAIN ANALYZE at 20k rows: the index is used, with scope and expiry applied as a filter on top.
llm_cache_expires_at_idx
Partial: only rows that can actually expire are worth indexing for evict_expired. Both get_exact and search filter expires_at in SQL, so an expired entry is already invisible whether or not it has been deleted.
Changing the embedding model usually changes the vector width, and pgvector fixes that width at the column. A different width means a new migration, not just a different value for LLM_CACHE_EMBEDDING_DIMENSIONS.
Maintenance
Maintenance runs three jobs on a single background thread; entering the context manager starts it, leaving stops it and writes out any pending counts. One failing job is logged and the loop carries on, so it cannot silently stop the others. Every store method stays callable directly if you would rather schedule it with cron or a k8s CronJob.
flush
every 5s
Writes buffered hit counts out with one touch_many, collapsing repeats into a single hits + n.
evict_expired
every 5 min
Deletes rows past expires_at. Only reclaims space, expired entries are already invisible to reads.
purge
every 5 min
Trims to max_entries, keeping the most-used. Ranks by hits and created_at.
Run it
Postgres never creates tables, so the library fails against an unmigrated database. Integration tests run against the real docker-compose Postgres and apply the real migrations, which means a broken migration fails here rather than on deploy, only the embedder is stubbed, so no API key is needed.
Migrations are hand-written SQL
There are no SQLAlchemy models, so --autogenerate has nothing to diff and is unused. upgrade head, revision -m, downgrade -1 and current all work as normal.
Tests
uv run pytest
uv run pytest -m unit
uv run pytest -m "not integration"