Caching embeddings
Turning text into embeddings costs real compute. If the same text gets embedded more than once, save the result the first time and reuse it.
- 8 min read
- 3 reading levels
- Published
Read these first
On this page 8
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
Once you have turned a piece of text into an embedding, save that result. Do not compute it again for the same text.
The analogy you have already lived
A librarian who writes a short summary card for every new book, and files it. The next time anyone asks what that book is about, the librarian reads the card. They do not reread the whole book from page one.
The card took real effort to write, once. After that, reusing it is nearly free. An embedding is that summary card, written in numbers instead of words.
Why it exists
An embedding is a list of numbers a model produces for a piece of text, capturing what the text means. You met this idea in semantic caching, where embeddings were used to match similar questions.
Computing an embedding is not free — it runs a model over the text. Many systems embed the same text repeatedly without realising it. A document gets re-indexed after every small website update. A product description gets embedded again every time a search happens. A support article gets embedded fresh on every deploy, even though nobody edited it.
How it works
text: "Refunds take 5-7 business days."
|
v
have we embedded THIS EXACT text before?
| |
yes no
| |
reuse the saved run the embedding
numbers (fast) model, SAVE the result
\ /
v v
numbers ready to use (for search, for a cache, for anything else)The key used to check "have we seen this before" is usually a hash — a short fingerprint computed from the text. Two long identical texts can then be compared by comparing two short fingerprints, instead of the full text.
A real example you have seen
A search box on a shopping website returns results instantly. This happens even though "understanding" your query and every product description involves embeddings. The product descriptions were embedded once, when they were added to the catalogue. Not fresh, on every search, by every shopper.
The honest part
This cache is only correct as long as nothing meaningful about the text has changed. If a document is edited and the embedding is not recomputed, search results quietly start pointing at outdated meaning. Deciding when to invalidate this cache is its own real problem — the next lessons deal with it directly.
Remember this
- Computing an embedding costs real compute; save the result rather than recomputing it.
- Cache by a fingerprint of the exact text, so any change is detected automatically.
- A cached embedding is only correct until the text it represents changes.
What to learn next
- Cache invalidation for RAG — deciding when a cached embedding has to be thrown away.
- Precomputing predictions — running the embedding step ahead of time, on a schedule, instead of on demand.
- Storing vectors as int8 and binary — shrinking what this cache has to store.
Developer — Code and libraries.
Setup
pip install sentence-transformers numpyA persistent embedding cache
import hashlib
import sqlite3
import time
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
db = sqlite3.connect(":memory:") # use a real file path to persist across runs
db.execute("CREATE TABLE embeddings (text_hash TEXT PRIMARY KEY, vector BLOB)")
def text_key(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def embed(text: str) -> np.ndarray:
key = text_key(text)
row = db.execute("SELECT vector FROM embeddings WHERE text_hash = ?", (key,)).fetchone()
if row is not None:
return np.frombuffer(row[0], dtype=np.float32)
vector = model.encode([text])[0].astype(np.float32)
db.execute("INSERT INTO embeddings VALUES (?, ?)", (key, vector.tobytes()))
db.commit()
return vector
documents = [
"Refunds take 5-7 business days.",
"Our support line is open 9am to 6pm.",
"Refunds take 5-7 business days.", # a repeat, e.g. re-indexed after a re-crawl
]
for doc in documents:
start = time.perf_counter()
vec = embed(doc)
ms = (time.perf_counter() - start) * 1000
print(f"{ms:6.1f} ms shape={vec.shape} {doc!r}")
print("rows stored:", db.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0])233.2 ms shape=(384,) 'Refunds take 5-7 business days.' 10.7 ms shape=(384,) 'Our support line is open 9am to 6pm.' 0.0 ms shape=(384,) 'Refunds take 5-7 business days.' rows stored: 2
The first call is slower on every run of this script. It includes the embedding model finishing its own warm-up, not only the text itself. Real timings from this machine; expect different absolute numbers on yours.
Line-by-line walkthrough
sqlite3.connect(":memory:"). Used here so the example is fully self-contained and leaves nothing on disk. Change this to a file path (sqlite3.connect("embeddings.db")) to make the cache survive between separate runs of your program. That is the entire point in real use.
hashlib.sha256(...).hexdigest(). Turns arbitrarily long text into a fixed-length fingerprint. Two different texts producing the same hash is astronomically unlikely at this hash length. That is what makes it safe to use as a cache key.
Storing the vector as BLOB. vector.tobytes() turns the NumPy array into raw bytes SQLite can store directly; np.frombuffer(..., dtype=np.float32) reverses it. Getting the dtype wrong here silently returns garbage numbers, not an error — always fix the dtype at both ends.
Common mistakes
Hashing the wrong thing. If you hash text.lower() but embed the original text, a cache hit can return the wrong embedding whenever case differs meaningfully. Hash exactly what you feed to the model.
Never invalidating on edits. A cache keyed only by, say, a database row ID, rather than the text's own content, silently serves the old embedding after that row is edited. Keying by a hash of the text itself, as above, fixes this automatically. A changed text produces a cache miss on its own.
Storing floats as text. Serialising a vector with str(vector.tolist()) works, but wastes space and CPU time on parsing compared to raw bytes. It gets worse the more vectors you store.
Assuming the model never changes. If you switch embedding models, every previously cached vector was produced by a different model. It is no longer comparable to new ones. Include the model's name and version in the cache key, or clear the cache on a model change.
Try it yourself
Add a model_version column to the table, include it in the key, and store "all-MiniLM-L6-v2" for every row. Then imagine — and write a comment describing — what breaks if you skip this and swap embedding models later.
What to learn next
- Cache invalidation for RAG — deciding when a cached embedding has to be thrown away.
- Precomputing predictions — running the embedding step ahead of time, on a schedule, instead of on demand.
- Storing vectors as int8 and binary — shrinking what this cache has to store.
Researcher — Mathematics and papers.
Cost accounting for embedding caches
Let $N$ be the number of embedding calls a workload makes without caching, and $r$ the fraction of those calls that are exact repeats of previously seen text. The number of embedding-model invocations with caching is $N(1-r)$, so the saving scales directly with the repeat rate — unlike model inference caching, where the value also depends on tail latency, embedding caching value is close to linear in $r$ because embeddings are typically used in bulk, offline pipelines rather than single interactive requests.
For a document corpus of size $D$ re-indexed on a schedule, a content-hash cache reduces re-indexing cost from $O(D)$ embedding calls per run to $O(\Delta)$, where $\Delta$ is the number of documents that actually changed since the last run — the mechanism underlying incremental indexing pipelines.
Cache key design as a correctness boundary
The cache key is a claim about everything that could change the output. For an embedding cache, that includes at minimum: the exact text, the model identifier and version, and any preprocessing applied before embedding (truncation length, normalisation, prompt templates some embedding models require). Omitting any of these from the key does not raise an error. It silently returns a stale or wrong vector — which is why embedding-cache bugs tend to surface as slowly degrading search quality, rather than crashes.
Storage layout trade-offs
- Row-per-vector (as in the demo) — simple, supports point lookups by key, poor for bulk similarity search without an additional index.
- Columnar / memory-mapped arrays (
.npy, Arrow, Parquet) — better for loading an entire embedding matrix for batch similarity search, worse for single-key point updates. - Vector databases (FAISS, Milvus, pgvector) — combine storage with an approximate nearest-neighbour index, at the cost of an additional moving part; appropriate once lookup, not storage, is the bottleneck (see approximate nearest-neighbour search).
Quantised storage
Storing embeddings as float32 is often unnecessary precision. Embedding quantisation — storing vectors as int8 or even binary — trades a small, usually acceptable loss in retrieval accuracy. In exchange it gives a 4x to 32x reduction in storage and memory bandwidth, which matters once a cache holds millions of vectors. See storing vectors as int8 and binary for the mechanics and the accuracy trade-off in detail.
What to learn next
- Cache invalidation for RAG — deciding when a cached embedding has to be thrown away.
- Precomputing predictions — running the embedding step ahead of time, on a schedule, instead of on demand.
- Storing vectors as int8 and binary — shrinking what this cache has to store.