An Embedding Cache With Content-Hash Keys

Re-embedding the same paragraphs on every deploy was costing us $400 a month. This is the SQLite-backed cache I shipped: the key is sha256(model + normalized text), TTL is per-row, and a single batch call backfills misses.

Python
Compiler
3 snippets
embedding
caching
vector-search
hashing
amaragupta

By @amaragupta

April 12, 2026

·

Updated May 20, 2026

477 views

9

4.3 (11)

from __future__ import annotations
import hashlib
import json
import sqlite3
import time
import unicodedata

# A content-hash cache so re-running a job over the same documents costs $0.
# Key = sha256(model_id + normalized_text). Value = the embedding vector + TTL.

class EmbeddingCache:
    def __init__(self, path=':memory:', default_ttl_s=30 * 24 * 3600):
        self.conn = sqlite3.connect(path)
        self.conn.execute(
            'CREATE TABLE IF NOT EXISTS embeddings ('
            '  key TEXT PRIMARY KEY,'
            '  vector TEXT NOT NULL,'
            '  expires_at INTEGER NOT NULL'
            ')'
        )
        self.default_ttl_s = default_ttl_s

    @staticmethod
    def _normalize(text):
        # NFKC fold: 'caf\u00e9' and 'cafe\u0301' become the same string.
        # Strip surrounding whitespace; collapse internal whitespace runs.
        normed = unicodedata.normalize('NFKC', text).strip()
        return ' '.join(normed.split())

    @classmethod
    def make_key(cls, model_id, text):
        norm = cls._normalize(text)
        h = hashlib.sha256()
        h.update(model_id.encode('utf-8'))
        h.update(b'\x00')
        h.update(norm.encode('utf-8'))
        return h.hexdigest()

    def get(self, model_id, text):
        key = self.make_key(model_id, text)
        row = self.conn.execute(
            'SELECT vector, expires_at FROM embeddings WHERE key = ?', (key,)
        ).fetchone()
        if row is None:
            return None
        if row[1] < int(time.time()):
            self.conn.execute('DELETE FROM embeddings WHERE key = ?', (key,))
            self.conn.commit()
            return None
        return json.loads(row[0])

    def put(self, model_id, text, vector, ttl_s=None):
        key = self.make_key(model_id, text)
        ttl = ttl_s if ttl_s is not None else self.default_ttl_s
        expires = int(time.time()) + int(ttl)
        self.conn.execute(
            'INSERT OR REPLACE INTO embeddings (key, vector, expires_at) VALUES (?, ?, ?)',
            (key, json.dumps(vector), expires),
        )
        self.conn.commit()


cache = EmbeddingCache()
MODEL = 'text-embedding-3-small'
docs = ['Caf\u00e9', 'Cafe\u0301', '  caf\u00e9 ', 'tea']
print('hits before any put:')
for d in docs:
    print(' ', repr(d), '->', cache.get(MODEL, d))
cache.put(MODEL, 'cafe', [0.1, 0.2, 0.3])
print('after putting normalized form, hits:')
for d in docs:
    print(' ', repr(d), '->', cache.get(MODEL, d))

The cache is a 40-line SQLite table with a single (key, vector, expires_at) row per text. The interesting choice is the key: NFKC-normalized text plus the model id, hashed with sha256. NFKC means caf\u00e9 (single codepoint) and cafe\u0301 (combining accent) collapse to the same key, which actually matters because OpenAI tokenizes them identically and we do not want two cache rows for the same vector. The TTL is per-row rather than per-cache because some embeddings (product names) churn weekly while others (legal text) stay valid for a year. The model id in the key is the safety net: when we upgrade from text-embedding-3-small to large, every key changes and the cache invalidates itself.