Semantic Search Design: When to Compute Embeddings

Fast semantic search depends on one decision: when you call the embedding model. Embed documents once when they are written, cache the embeddings of repeated queries, and the hot path becomes a simple vector comparison. Here is how three common designs compare, and how to pick one for your workload.

Start this project - freeLive cloud workspace in your browser · lands on your portfolio
AI Engineerpythonembeddingssemantic-search

The cost that matters: the embedding call

Semantic search turns text into vectors and finds the documents whose vectors are closest to the query vector. Comparing vectors is cheap. Calling the embedding model is expensive - often tens to hundreds of milliseconds per call, plus network time if the model runs behind an API.

So the design question is not "which similarity function?" It is "how many embedding calls happen on each search?" Take an example workload:

Approach A: embed everything at query time

def search(corpus, query, k=3):
    qv = embed(query)
    scored = []
    for doc in corpus:
        dv = embed(doc)                 # re-embeds every document, every search
        scored.append((dot(qv, dv), doc))
    scored.sort(reverse=True)
    return [doc for _, doc in scored[:k]]

This returns correct results, and it is the simplest code to write. But each search makes one embedding call per document plus one for the query. With 5,000 documents, a single search makes 5,001 model calls. It cannot meet any real latency budget.

Approach B: embed documents once, embed the query every time

_doc_vectors = []

def init(corpus):
    global _doc_vectors
    _doc_vectors = [(embed(d), d) for d in corpus]   # once, at write time

def search(corpus, query, k=3):
    qv = embed(query)                                 # still one call per search
    scored = sorted(((dot(qv, dv), d) for dv, d in _doc_vectors), reverse=True)
    return [d for _, d in scored[:k]]

This is the standard shape of a vector index: documents are embedded when they are added, and the stored vectors are reused. It removes the biggest cost. But each search still waits for one embedding call, so every query takes at least 150ms. At 100 queries per second, that is 15 seconds of model time every second - one host cannot keep up.

Approach C: embed at write time and cache query embeddings

from functools import lru_cache

@lru_cache(maxsize=1024)
def cached_embed(text: str) -> tuple[float, ...]:
    return tuple(embed(text))

def init(corpus):
    global _doc_vectors
    _doc_vectors = [(cached_embed(d), d) for d in corpus]

def search(corpus, query, k=3):
    qv = cached_embed(query)            # cache hit = no model call
    scored = sorted(((dot(qv, dv), d) for dv, d in _doc_vectors), reverse=True)
    return [d for _, d in scored[:k]]

Real search traffic repeats itself: many users ask the same or very similar questions. With a query cache, a repeated query skips the model completely, and the hot path is only the vector comparison - well under a millisecond for a few thousand short vectors. A new query still pays the embedding cost once, and then it is cached.

Note that lru_cache needs a hashable return value, so the vector is stored as a tuple. A cache keyed on the exact text only helps exact repeats. Normalize the query first (trim spaces, lowercase) to raise the hit rate.

Measure the hot path, do not guess

Write a small benchmark that runs the same query twice and times the second run:

import time
init(corpus)
search(corpus, "reset my password")          # warm up
start = time.perf_counter()
results = search(corpus, "reset my password")
print(f"{(time.perf_counter() - start) * 1000:.2f} ms", results)

Run it against each design. The numbers make the choice obvious, and they give you something concrete to put in a design review.

How the design scales

The contract stays the same as the system grows: init(corpus) builds the index, and search(corpus, query, k) reads it. Behind that contract you can change the parts:

Do this project yourself. It's ready in a live cloud workspace in your browser - when you finish, it lands on your portfolio.

Start this project - free

What you'll practice

FAQ

Should I embed documents at query time or at write time?

At write time. Documents change much less often than they are searched, so embed each document once when it is added or edited and store the vector. Re-embedding documents on every search makes latency grow with the size of the corpus.

Should I cache query embeddings?

Yes, when queries repeat - and in most real search traffic they do. A cached query skips the embedding model completely, so the search is only a vector comparison. Key the cache on the normalized query text plus the model name.

When do I need a vector database instead of a Python list?

When a full scan over every vector becomes too slow for your latency budget, usually at tens or hundreds of thousands of vectors. A vector index such as FAISS or pgvector uses approximate nearest-neighbor search so each query compares against a small part of the data.

Can I mix embeddings from two different models?

No. Each model produces its own vector space, so similarity scores between vectors from different models have no meaning. When you change models, re-embed every document and clear the query cache.

Keep learning

Power RAG Search With a Vector DatabaseAI/ML projectGenerate Text Embeddings With OpenAIAI/ML projectBuild an End-to-End RAG PipelineAI/ML projectAI/ML roadmapStep by step to hiredAI/ML interview questionsSTAR answersAll AI/ML projectsProjects hub

Do this project yourself. It's ready in a live cloud workspace in your browser - when you finish, it lands on your portfolio.

Start this project - free
Start this project - free