An Eight Mile Services 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.

View on GitHub

Runtime

Python 3.12+ · uv

Store

Postgres 17 · pgvector

Lookup

sha256 → HNSW cosine

Size

~640 LOC

Cache.get

exact → semantic

MISSstore.get_exact(scope, fp)sha256 key · one indexed lookupembedder.embed(query)50–150ms · only reached on a missstore.search(scope, vec, k)hnsw · vector_cosine_opssimilarity >= thresholdotherwise a miss, carrying the vectorexact hit~0.5ms, no embeddingsemantic hitentry + embedding

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.

MISSstore.get_exact(scope, fp)sha256 key · one indexed lookupembedder.embed(query)50–150ms · only reached on a missstore.search(scope, vec, k)hnsw · vector_cosine_opssimilarity >= thresholdotherwise a miss, carrying the vectorexact hit~0.5ms, no embeddingsemantic hitentry + embedding

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

class

·

Owns the exact → semantic ordering

·

Applies similarity_threshold

·

Catches CacheError and degrades to a miss

Embedder

protocol

·

One method: embed(text) -> Embedding

·

OpenAIEmbedder ships with it

·

A local model is a class with an embed method

CacheStore

protocol

·

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.

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, not 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.