An Eight Mile project · open source
llm-cache
A semantic cache for LLM responses, backed by Postgres and pgvector. An exact hash match on the canonicalised request comes first and skips the embedding call entirely; only on a miss does it embed the query and search by vector similarity. Repeat questions never pay for a model call.
Runtime
Python 3.12+ · uv
Store
Postgres 17 · pgvector
Lookup
sha256 → HNSW cosine
Size
~640 LOC
Cache.get
exact → semantic
The lookup path
Two lookups, and the cheap one goes first.
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.
One method, four calls. Everything above the first branch is a hash lookup; everything below it costs a model call, which is exactly why it runs second.
~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
One class, two protocols.
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
Three ideas the API is built on.
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
Eight files, and that is the whole library.
Excerpts straight from the repository: the lookup policy, the fingerprint, both protocols, the pgvector backend, the hit buffer, the maintenance scheduler and the settings.
llm-cache / src
src/llm_cache
src/llm_cache/store
src/llm_cache/cache.py
@dataclass(frozen=True) class GetResponse: entry: CacheEntry | None = None embedding: Embedding | None = None class Cache: def __init__( self, store: CacheStore, embedder: Embedder, *, similarity_threshold: float = 0.95, ) -> None: self.store = store self.embedder = embedder self.similarity_threshold = similarity_threshold def get(self, scope: str, query: str, fingerprint: str | None = None) -> GetResponse: """Look the query up, degrading to a miss if the cache itself is broken. A cache that raises turns its own outage into the caller's outage, so a StoreError or EmbeddingError is logged and reported as an empty result, the caller then does what it would have done on any miss. """ try: if fingerprint: entry = self.store.get_exact(scope=scope, fingerprint=fingerprint) if entry: return GetResponse( entry=entry, ) embedding = self.embedder.embed(query) entries = self.store.search(scope=scope, limit=1, embedding=embedding) if entries: entry, similarity = entries[0] if similarity >= self.similarity_threshold: return GetResponse(entry=entry, embedding=embedding) return GetResponse(embedding=embedding) except CacheError: log.warning('cache lookup failed, treating as a miss', exc_info=True) return GetResponse() def set(self, entry: CacheEntry, embedding: Embedding | None = None) -> None: """Store the entry, or give up quietly. Failing to write a cache entry costs a future hit and nothing else, so it never propagates to the caller. """ try: self.store.put( entry=entry, embedding=embedding or self.embedder.embed(entry.query) ) except CacheError: log.warning('cache write failed, entry not stored', exc_info=True)
The whole lookup policy in one method: exact first, embed only on a miss, threshold last, and one except that turns any cache failure into a plain miss.
src/llm_cache/keys.py
import hashlib def fingerprint(query: str, **params: object) -> str: normalized = ' '.join(query.lower().split()) material = repr((normalized, sorted(params.items()))) return hashlib.sha256(material.encode()).hexdigest()
The entire exact-match key. Normalisation lowercases and collapses whitespace; params are sorted so argument order cannot change the hash, and repr keeps 0 and ‘0’ apart.
src/llm_cache/store/base.py
from collections.abc import Mapping from typing import Protocol from llm_cache.models import CacheEntry, Embedding class CacheStore(Protocol): def get_exact(self, scope: str, fingerprint: str) -> CacheEntry | None: ... def search( self, scope: str, embedding: Embedding, limit: int ) -> list[tuple[CacheEntry, float]]: ... def put(self, entry: CacheEntry, embedding: Embedding) -> None: ... def touch(self, scope: str, fingerprint: str) -> None: ... def touch_many(self, counts: Mapping[tuple[str, str], int]) -> None: ... def evict_expired(self) -> int: ... def purge(self) -> int: ... def clear(self, scope: str) -> int: ...
The store contract, as a Protocol. A replacement backend needs matching methods and nothing else, no base class, no registration. Note purge is absent from it.
src/llm_cache/store/postgres.py
@contextmanager def _connection(self) -> Generator[psycopg.Connection, None, None]: """Borrow a pooled connection, reporting infrastructure faults as StoreError. Only the "database is unreachable" family is translated. Programming errors — a missing table, mismatched vector dimensions, stay as psycopg errors so they surface as bugs instead of being downgraded to a cache miss. """ try: with self._pool.connection() as conn: yield conn except (psycopg.OperationalError, PoolTimeout) as exc: raise StoreError('cache store unavailable') from exc # get_exact() is the same shape: one indexed lookup on (scope, fingerprint) # with the expires_at filter applied in SQL. def search( self, scope: str, embedding: Embedding, limit: int ) -> list[tuple[CacheEntry, float]]: """Nearest neighbours in `scope`, best first, as (entry, similarity). Returns the top `limit` regardless of how far away they are, deciding what counts as close enough is the caller's threshold to apply, not the store's. """ query = sql.SQL( 'SELECT {columns}, 1 - (embedding <=> %s::vector) AS similarity ' 'FROM {table} ' 'WHERE scope = %s AND (expires_at IS NULL OR expires_at > now()) ' 'ORDER BY embedding <=> %s::vector ' 'LIMIT %s' ).format(columns=_COLUMNS, table=self._table) vector = list(embedding) with self._connection() as conn: with conn.cursor(row_factory=dict_row) as cur: cur.execute(query, (vector, scope, vector, limit)) return [ (CacheEntry(**{k: row[k] for k in _ENTRY_COLUMN_NAMES}), row['similarity']) for row in cur.fetchall() ]
The degradation boundary and the vector query. Only the unreachable-database family becomes a StoreError; the <=> operator must match the index’s cosine ops.
src/llm_cache/embeddings.py
class Embedder(Protocol): """Turns query text into a vector. Structural, so anything with a matching `embed` works: an OpenAI client, a local sentence-transformers model, or a stub in tests. """ dimensions: int def embed(self, text: str) -> Embedding: ... class OpenAIEmbedder: def __init__( self, *, model: str = 'text-embedding-3-small', dimensions: int = 1536, api_key: str | None = None, ) -> None: """`dimensions` must match the vector width in the migration. pgvector fixes the width at the column, so changing this means a new migration, not just a different argument here. """ from openai import OpenAI self._client = OpenAI(api_key=api_key) self._model = model self.dimensions = dimensions def embed(self, text: str) -> Embedding: response = self._client.embeddings.create( model=self._model, input=text, dimensions=self.dimensions ) return response.data[0].embedding
The second Protocol. OpenAI is one implementation, imported lazily inside __init__ so the base install never drags the SDK in.
src/llm_cache/buffer.py
class TouchBuffer: """Accumulates hit counts in memory and writes them out on a timer. Nothing on the read path consults `hits` or `last_used_at`, so recording a hit should never sit between the caller and their response. Counting in memory also collapses repeat hits on the same entry into a single `hits + n`, which matters because each row update Postgres cannot do in place costs an entry in the HNSW index. The trade is durability: up to one interval of counts is lost if the process dies. These are cache statistics, so nothing reconciles against them. """ def touch(self, scope: str, fingerprint: str) -> None: """Record a hit. Never does I/O.""" with self._lock: self._counts[(scope, fingerprint)] += 1 def flush(self) -> None: """Write pending counts. Safe to call directly, e.g. in tests.""" with self._lock: if not self._counts: return batch = self._counts self._counts = Counter() try: self._store.touch_many(batch) except CacheError: # Deliberately dropped rather than merged back: a store that stays down # would otherwise grow this buffer without bound, and losing hit counts # costs nothing but eviction accuracy. log.warning('touch flush failed, %d counts dropped', len(batch), exc_info=True)
Hit counting in memory. A failed flush drops its batch rather than merging it back, so a store that stays down cannot grow the buffer without bound.
src/llm_cache/maintenance.py
class Maintenance: """Runs the cache's periodic housekeeping on one background thread. None of this belongs on the request path. `get_exact` and `search` already filter on `expires_at`, so an expired entry is invisible whether or not it has been deleted, eviction only reclaims space. Calling it from `set()` would make one unlucky request pay for a table-wide DELETE. When a TouchBuffer is passed in, its flush becomes one of these jobs and the buffer should not also be started on its own; one scheduler is easier to reason about than three threads with independent lifetimes. """ def __init__( self, store: CacheStore, *, buffer: TouchBuffer | None = None, flush_interval: float = 5.0, evict_interval: float = 300.0, purge_interval: float = 300.0, ) -> None: self._buffer = buffer self._jobs: list[_Job] = [] if buffer is not None: self._jobs.append(_Job('flush', buffer.flush, flush_interval)) self._jobs.append(_Job('evict_expired', store.evict_expired, evict_interval)) self._jobs.append(_Job('purge', store.purge, purge_interval)) self._stopping = threading.Event() self._thread: threading.Thread | None = None
One scheduler, three jobs, each with its own interval. A job that raises is logged and the loop carries on, so one failure cannot silently stop the others.
src/llm_cache/config.py
class Settings(BaseSettings): """Environment-backed configuration, read once at the application edge. Nothing inside the package constructs this, `Cache`, `Postgres` and the embedders all take explicit arguments, so a library user is never subject to whatever happens to be in the process environment. Applications build one of these at startup and pass the values down. """ model_config = SettingsConfigDict( env_prefix='LLM_CACHE_', env_file='.env', env_file_encoding='utf-8', extra='ignore', ) dsn: str = 'postgresql://llmcache:llmcache@localhost:5432/llmcache' table: str = 'llm_cache' namespace: str = 'default' # Must match vector(n) in the migration; pgvector fixes the width at the # column, so a change here without a migration fails at the first write. embedding_dimensions: int = Field(default=1536, gt=0) # Cosine similarity, so bounded at 1.0. Below roughly 0.9 unrelated questions # start matching, which is worse than missing. similarity_threshold: float = Field(default=0.95, ge=0.0, le=1.0) search_limit: int = Field(default=5, gt=0) # Blank in .env means no expiry rather than zero seconds. ttl_seconds: OptionalSeconds = None
Constructed at the application edge, never inside the library. The comments record the two constraints an operator cannot see from the type alone.
Failure behaviour
A cache that raises turns its own outage into yours.
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.
Failure
Behaviour
Postgres unreachable
Logged, treated as a miss, caller proceeds
Embedding provider fails
Logged, treated as a miss
Missing table, wrong dimensions
Raises, these are bugs rather than blips
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
One table, owned entirely by Alembic.
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
primary key (scope, fingerprint)
scope
text
primary key
fingerprint
text
primary key
query
text
response
text
embedding
vector(1536)
created_at
timestamptz
default now()
expires_at
timestamptz
nullable
hits
integer
default 0
last_used_at
timestamptz
nullable
metadata
jsonb
default '{}'
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
Housekeeping on one thread, off the request path.
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.
Why buffer the hits
2000 hits · 50 entries
Request path
Round trips
store.touch() per hit
633 ms
2000
buffer.touch() per hit
0.4 ms
0
one background flush
—
1
Nothing on the read path consults hits or last_used_at, so recording a hit should not sit between the caller and their response. The trade is that up to one flush interval of counts is lost if the process dies, and a flush that fails drops its batch rather than retrying: these are cache statistics, and nothing reconciles against them.
Run it
Four commands, and the last one is not optional.
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.
llm-cache · setup
$
uv sync
$
docker compose up -d
$
cp .env.example .env
$
uv run alembic upgrade head
pgvector/pgvector:pg17 # the image docker-compose.yml pulls
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"
Settings · env or .env
built at the application edge
LLM_CACHE_DSN
Postgres connection string. Also used by Alembic.
LLM_CACHE_TABLE
Table name, default llm_cache.
LLM_CACHE_NAMESPACE
Default scope prefix.
LLM_CACHE_EMBEDDING_DIMENSIONS
Must match vector(n) in the migration.
LLM_CACHE_SIMILARITY_THRESHOLD
Minimum cosine similarity for a semantic hit.
LLM_CACHE_SEARCH_LIMIT
Candidates fetched before thresholding.
LLM_CACHE_TTL_SECONDS
Entry lifetime; blank means no expiry.
OPENAI_API_KEY
Only needed for OpenAIEmbedder.
The library classes take explicit arguments and never read the environment themselves. Settings is constructed once at your application's edge and the values are passed down.
Behind the project
Built by Eight Mile in London, as part of our software development work — the same engineers who build and run systems like it for clients.