Vibe Engines
YouTube
AI System Design

Design an Embeddings Service

Learn AI system design by building an embeddings service that powers semantic search, RAG and recommendations step by step.

The numbers to beatonline1 query, low latencybatchmillions, high throughputsame modelboth paths

The whole design, in writing

Learn AI system design by building an embeddings service that powers semantic search, RAG and recommendations step by step. An interactive guide covering online vs batch embedding, the model inference path, a batched GPU pipeline that indexes millions of documents, the critical model-versioning/consistency rule, caching and dedup, and the unhappy paths (version drift, cost, chunking, multimodal).

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What is an embeddings service?

Semantic search, RAG, recommendations and clustering all rest on one primitive: turning text (or images) into a vector that captures meaning, so "close in vector space" means "similar in meaning". You need to produce these embeddings for a single query in milliseconds and for millions of documents to build an index — two very different jobs, both GPU-bound.

Clientquery · docs
New in this step: Client.

An embeddings service serves both modes over an embedding model: a low-latency online path for query embeddings and a high-throughput batch pipeline for indexing corpora. The subtle, critical part isn't the model — it's consistency: the query and the entire corpus must be embedded with the same model version, or search silently breaks.

What the new pieces do

Clientclient
Two needs: embed a single query in real time (search), and embed millions of documents to build/refresh an index (batch).

Step 1 · Meaning as a vector

Why embeddings, and why a service?

Keyword search matches words, not meaning — "how to reset my password" misses a doc titled "recovering account access". Embeddings fix that by mapping meaning to vectors. So why not just call the model inline wherever you need one?

Clientquery · docs
The system as it stands at this step.

Why build an embeddings *service* instead of calling the model inline everywhere?

  1. Producing embeddings is heavy GPU inference, needed both at low latency for queries and at massive throughput for indexing — and every consumer must use the identical model/version for vectors to be comparable. Centralizing this into a service gives shared batching, scaling, caching and version control that inline calls can't.

  2. Embeddings are vectors, not text. The reason for a service is operational: shared GPU serving, batching, caching, and — critically — guaranteeing one consistent model version across all consumers.

  3. Vector size is a model property. A service exists to serve embedding inference efficiently in both modes and to enforce version consistency, not to shrink vectors.

Embedding is heavy GPU inference needed in two modes — real-time query embedding and bulk corpus indexing — and every consumer must use the same model version for vectors to be comparable. A shared service centralizes GPU batching, autoscaling, caching, and (most importantly) version control, which scattered inline calls can't guarantee. Meaning-as-a-vector is the primitive; serving it consistently at scale is the system.

Why this piece earns its place

Start with when you should not build this. One caller, one index, a hosted embedding API behind it, and the service is a proxy with a version string in it — a constant in that caller's own config does the same job and costs nothing to operate. It earns its keep at the second consumer, or the second index. Past that, what it buys is a seam you cannot retrofit. Where the weights actually run — a vendor's API now, your own GPUs later, once per-token pricing stops making sense at corpus scale — becomes one deployment's decision instead of an SDK migration in every repository that ever needed a vector. The same seam is where the spend becomes legible. Embedding arrives as one GPU bill, and without a request that records who asked, you cannot tell that a nightly job re-embedding an unchanged corpus is most of it, or slow that job down without touching anything else. Per-consumer attribution and a quota are a couple of fields while you are building this and close to unrecoverable afterwards, because a bill does not remember who caused it.

Step 2 · Two very different jobs

Online vs batch

A search query needs one embedding, now, with tight latency. Indexing a corpus needs millions of embeddings with maximum throughput and no latency pressure. Why can't one path serve both well?

Clientquery · docsEmbeddings APIonline · batch
New in this step: Embeddings API.

Split the paths. Online: embed a single query (or a few) with low latency — small batches, fast turnaround, on the search read path. Batch: embed millions of documents for throughput — large GPU batches through a queue, latency-insensitive, to build or refresh the index. Same model, opposite optimization (latency vs throughput), so the API exposes both and routes accordingly — exactly the batch-vs-streaming split you see across AI serving.

Why this piece earns its place

Two paths means two deployments, and most of the payoff is in the second word. They do not scale on the same signal — the online fleet reacts to request rate and tail latency, the batch fleet to how much work is still waiting — and no single autoscaler reads both, so a merged deployment is always sized for whichever workload shouts loudest. They tolerate different hardware, too: batch work is restartable and nobody is waiting on it, so it can sit on capacity that may be reclaimed mid-run, which would be indefensible underneath a live search request. Blast radius differs as well. A bad deploy to the batch fleet delays an index; the same mistake on the online fleet takes search down now. Different rollout caution, different pager. The split also changes the shape of the API, which is easy to miss while it is drawn as one box. Online is text in, vector out. Batch cannot be — a caller that posts a corpus and waits on the connection times out long before the work is done — so that side hands back a job id, and needs status, progress and cancel to go with it. One endpoint, two contracts.

  • online1 query, low latency
  • batchmillions, high throughput
  • same modelboth paths

What the new pieces do

Embeddings APIgateway
Splits real-time single-embed requests from bulk indexing jobs, and checks the cache before spending a GPU.

Step 3 · The query path

Online embedding

When a user searches, you must embed their query fast — it's on the critical path before the vector lookup even runs. What does the online path optimize?

Embeddings APIonline · batchOnline Embedderlow-latency
New in this step: Online Embedder.

The online embedder serves single or small-batch query embeddings with minimal latency: keep the model warm and resident, use small prompt-flushed batches (don't wait to fill a big batch), and consider a smaller/faster embedding model where quality allows. The output is a single vector handed to nearest-neighbor search. This path is latency-critical because it gates every search request, so it's tuned for fast first-response, not throughput.

Why this piece earns its place

Two things about this path only show up once you have run it. The first is that scaling in is not free. Model weights have to be loaded onto a GPU before a replica can answer anything, which is measured in seconds, not in the units the rest of this path is measured in — so a fleet that scales down overnight pays that whole cost into the first morning spike, and the latency you see in the graph is queueing, not inference. You provision for peak and treat the idle headroom as the price of sitting on the search path. The second is that the model is often not where the time goes. A single short query is a small amount of GPU arithmetic wrapped in a request, a tokenizer, a transfer and a response, and at that size the wrapper can outweigh the matrix multiply — so reaching for a smaller model before measuring that split buys you a quality loss and very little latency. And decide now what search does when this path is unavailable, because search has no answer of its own: fall back to keyword retrieval, serve from cache, or fail the request. That is a product decision, and you do not want to be making it during the incident.

  • warm modelno cold start
  • small batchlatency first
  • 1 vector→ ANN search

What the new pieces do

Online Embedderservice
Serves single/small-batch query embeddings fast (small batches to keep latency low), on the read path of search.

Step 4 · Inside the model

Text (or image) → vector

What does the embedding model actually produce, and what properties matter for using its output?

Online Embedderlow-latencyEmbedding Modelweights + version
New in this step: Embedding Model.

The model maps an input to a fixed-dimension vector (e.g. 384–3072 dims) — for text, typically pooling the transformer's token representations; for images or multimodal, an encoder into the same space. The vector is usually L2-normalized so cosine similarity = dot product. Key properties you must track and keep consistent: the model + version, the dimension, the similarity metric, and any normalization — because every vector compared must share all of them.

Why this piece earns its place

Once the vectors are normalized, cosine, dot product and Euclidean distance rank identically — for unit vectors the distance falls exactly as the dot product rises, so they are one decision wearing three names. That freedom is bought by the normalization, not by the metric, and it is worth knowing which one is doing the work: an approximate index is built around a single distance function, so the metric is chosen when the index is created rather than passed with a query, and trading it costs a rebuild. Dimension is the expensive property. It sets what a million vectors cost in memory and how long a search over them takes, it is picked before the corpus exists, and shrinking it later is the same job as changing models. And same model has to mean the same weights and the same inference code, because pooling and truncation live in the serving library — a routine dependency bump can move the space while the version string sits exactly where it was. Pin the serving image beside the weights, and keep a few fixed sentences and their expected vectors as a fixture every deploy checks itself against. Nothing else catches a space that moved quietly.

  • fixed dims384–3072
  • normalizecosine = dot
  • metricpart of the space

What the new pieces do

Embedding Modeldata
The model that maps text (or images) to a fixed-dimension vector. Its VERSION must match across query and corpus.

Step 5 · Index the corpus

The batch embedding pipeline

To power search over millions of documents, you must embed them all and store the vectors — a huge, bursty, GPU-heavy job. How do you run it efficiently?

Batch Embedderbatched · autoscaledBatch Queuedocs to embedEmbedding Modelweights + versionVector Storeindexed vectors
New in this step: Batch Embedder, Batch Queue, Vector Store.

You must embed millions of docs and store the vectors. How do you run it efficiently?

  1. One-at-a-time synchronous calls waste the GPU (tiny batches) and would take forever for millions of docs, while also competing with live queries. Bulk work belongs on a throughput-optimized batch pipeline with large batches.

  2. Loading millions of docs at once is fragile and unbounded. You stream them through a queue and process in large batches, writing results incrementally to the vector store, so it's scalable and restartable.

  3. Documents flow through a job queue to worker(s) that embed them in large GPU batches (maximizing throughput/cost) and write the resulting vectors to the vector store, incrementally building the index. It's scalable, restartable, and isolated from the low-latency query path.

Run a batch pipeline: stream documents through a queue to GPU workers that embed them in large batches (maximizing throughput and cost-efficiency) and write the vectors to the vector store, incrementally building the index. It's autoscalable on queue depth, restartable (track progress so a failure resumes, not restarts), and isolated from the online path so a giant indexing run can't slow live queries. This is how you turn a corpus into a searchable index.

Why this piece earns its place

The GPUs are usually not the bottleneck here — the store is. Writing into an approximate index is not an append; the structure has to absorb every insert, so a fleet tuned to saturate the GPUs will outrun the writer and the queue backs up behind storage rather than the model. Batch the writes as deliberately as you batch the inference, and for a first full build consider constructing the index offline and loading it instead of inserting a corpus row by row. The second surprise is poison inputs. Inside a queue with retries, one document the tokenizer chokes on is redelivered forever, burning the fleet on a page nobody will ever search for. Dead-letter it and carry on; a backfill that skips a handful of documents beats one that stops dead partway through. The other thing one queue hides is that two workloads are sharing it. A full backfill and the trickle of newly published documents are not the same priority, and a single FIFO puts the trickle behind millions of items — a document published this morning is not searchable until the backfill finishes, which is a freshness bug nobody will attribute to the queue. Two queues, or a priority lane, and steady-state indexing keeps its own promise while the big job runs underneath it.

  • queuestream millions
  • big batchesthroughput
  • → vector storebuild the index

What the new pieces do

Batch Embedderservice
Embeds documents in large GPU batches for maximum throughput, writing vectors to the store to build the index.
Batch Queuebus
Buffers bulk indexing jobs so millions of documents drain through the GPU fleet at a sustainable rate.
Vector Storedata
Where document embeddings land (a vector database), later queried by nearest-neighbor search for retrieval.

Step 6 · The rule that breaks everything

Model versioning & consistency

Here's the AI-specific trap that silently destroys search quality. Your corpus was embedded months ago with model v1. You upgrade to a better model v2 and start embedding queries with it. Suddenly results are garbage — with no error. Why, and what's the rule?

ClientEmbeddings APIOnline EmbedderBatch EmbedderBatch QueueEmbedding ModelVector Store
The system as it stands at this step. · swipe to pan the diagram

Corpus embedded with v1; you upgrade queries to v2. Results turn to garbage. Why?

  1. Each model defines its own vector space; a v2 query vector and v1 document vectors have no meaningful geometric relationship, so nearest-neighbor distances are nonsense — yet nothing errors. The absolute rule: query and corpus must share the identical embedding model and version, which means upgrading the model requires re-embedding the entire corpus (often via a versioned re-index and cutover).

  2. It's not model quality — it's incompatibility. Even a better model produces vectors in a different space that can't be compared to old ones. You must re-embed the corpus with the new model before switching queries to it.

  3. The store is fine; the vectors are simply from two incompatible models. Mixing model versions in one index silently breaks similarity. Re-embed everything under one version.

Vectors from different models live in different spaces and are not comparable — a v2 query against a v1 corpus produces meaningless distances, and nothing errors. So the rule is absolute: the query and the entire corpus must be embedded with the identical model + version. Upgrading the embedding model therefore means re-embedding the whole corpus — typically build a new versioned index in the background, then atomically cut over queries and corpus together. Track the model version on every vector so you never mix them.

Why this piece earns its place

The switch itself should be a name, not a copy. Queries resolve an index name and the cutover moves that name, so nothing races and nothing half-switches — and the same move made backwards is the rollback, which matters because a relevance regression arrives days later in a human's complaint, not in a monitor at the moment you switch. Both indexes exist for as long as a full corpus embed takes, so peak storage and peak embedding spend are roughly doubled across that window. That, rather than taste, is the honest answer to how often you can afford to change models. The thing that decides whether a second migration is even possible is less obvious: keep the exact text you embedded, durably, beside the vector. If the only copy of it lives in the source system, the next upgrade has to re-read a corpus that has moved on since, and what you build is not a re-embedding of the old index but a different corpus in a new space — which destroys the one comparison the migration rests on, because a difference in results no longer tells you whether the model improved or the documents changed underneath you.

  • same model+versionquery = corpus
  • model changere-embed all
  • versioned reindexthen cut over

Step 7 · Don't re-embed the same thing

Caching & storage

The same query is typed by thousands of users; the same document gets re-indexed unchanged. Embeddings are deterministic for a given (text, model) — so recomputing them is pure waste. How do you avoid it, and where do vectors live?

Embeddings APIonline · batchEmbedding Cache(text,model)→vec
New in this step: Embedding Cache.

Cache embeddings keyed by (text, model version) — since the mapping is deterministic, a cache hit skips the GPU entirely (huge for popular queries and unchanged docs on re-index). Deduplicate identical inputs before embedding. Store query embeddings ephemerally (they're cheap to recompute) and document vectors durably in the vector store with their model version and source id. Only genuinely new (text, version) pairs cost a GPU pass.

Why this piece earns its place

The key is the exact bytes of the input, which quietly makes whatever runs before the hash — trimming, collapsing whitespace, case folding — part of the key, versioned by nothing. Change that preprocessing in an ordinary deploy and every existing entry stays perfectly valid and becomes unreachable in the same instant: the hit rate goes to zero at once, not gradually, and the fleet behind it meets a corpus-sized miss rate nobody planned for. Which is the second note — size the online fleet for the miss rate you would have without the cache, or a cache outage stops being a cost event and becomes an availability event on the search path. The other trap is letting both paths share one cache. Their access shapes are opposites: queries are a small hot head, a backfill is a long one-shot stream, so plain LRU lets the backfill evict exactly the entries that were earning the hits, and the query hit rate collapses precisely while the expensive job runs. Give the batch path its own space, or let it read without populating. Entries are immutable, so eviction is a cost knob rather than a correctness one — but each entry is a vector, not a short string, so the ceiling arrives sooner than you expect.

  • cache(text, version) → vector
  • dedupskip identical inputs
  • storevectors + version + id

What the new pieces do

Embedding Cachecache
Caches embeddings keyed by (text + model version). Since embeddings are deterministic, identical inputs skip the GPU.

Step 8 · The sharp edges

Chunking, cost, drift & multimodal

Real embedding workloads hit: documents longer than the model's input limit, GPU cost at corpus scale, the ever-present version-drift risk, and multimodal/multilingual needs.

ClientEmbeddings APIEmbedding CacheOnline EmbedderBatch EmbedderBatch QueueEmbedding ModelVector Store
The system as it stands at this step. · swipe to pan the diagram

Chunk long documents to fit the model's token limit (with overlap for context) and embed chunks, since retrieval works at chunk granularity anyway. Control cost with batching, caching/dedup, autoscaling, and right-sized models (bigger isn't always worth it). Guard against version drift by version-tagging every vector and refusing to mix versions in one index. Support multimodal/multilingual by choosing models that embed images/text/languages into a shared space when the product needs cross-modal or cross-lingual search. And re-embed incrementally as content changes.

Why this piece earns its place

Chunk size and overlap are part of an index's identity, exactly like the model version. Change them and the stored vectors no longer correspond to the passages the new rules would produce, so a half-migrated index holds two different segmentations of the same corpus and retrieval quietly prefers whichever one happens to be shorter. Tweaking chunking is a full backfill wearing a smaller hat — version it in the same field the model version lives in, and cut over the same way. The other sharp edge is deletion. Vectors outlive their sources: a page is unpublished, a document is redacted, and nothing in the embedding path is watching, so the chunks sit in the index and keep being returned to people who should not see them. Store the source document id on every chunk and make deletion a first-class job rather than something the next full re-index eventually cleans up. And pick the model before the corpus exists. Right-sizing reads like a tuning exercise, but the cost of being wrong is one more full re-embed, so a bake-off on a sample with real queries is cheap now and expensive to postpone.

You did it

You just designed an embeddings service.

ClientEmbeddings APIEmbedding CacheOnline EmbedderBatch EmbedderBatch QueueEmbedding ModelVector Store
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • Embeddings map meaning to vectors; a shared service gives consistent, scalable GPU serving in two modes.
  • Online (single query, low latency) and batch (millions of docs, throughput) are opposite optimizations.
  • The online path keeps the model warm with small batches — it gates every search request.
  • The model outputs a fixed-dimension, normalized vector; dimension, metric and version define the space.
  • A batch pipeline (queue → big GPU batches → vector store) indexes corpora, restartably and isolated from queries.
  • The rule that matters most: query and corpus must share the same model+version, so a model change re-embeds everything.
  • Cache by (text, version) since embeddings are deterministic; chunk long docs; guard against version drift.

Deep cut · 20:41

A better model. Worse search.

The interactive build above lays out the embeddings service: an online path that embeds a query in real time, a batch path that indexes millions of pages through a queue in large GPU batches, chunking long pages to the model’s input limit, a cache, and the model-versioning rule that keeps queries and pages on the same model. This film is an autopsy. The morning after a bank app switched its search to a better AI model, “I was charged twice” came back as “how to change your profile photo”, support calls tripled, and nothing had crashed. The film builds the service one reasonable fix at a time, with the search function written out line by line as pseudo code, then replays the Sunday night card by card to find how a better model made search worse.

  • See the wreck: only the question side switched to the new model. Every page on the shelves still carried the old model’s address, and each model had learned its own map, so the question landed in the profile-photo corner. No error fired, because both models write vectors of the same length, and the one test passed because the cache answered it with a vector the old model had already written.
  • Take it into the interview: split an online path for queries from a batch path for pages, with a queue and GPU batches; chunk long pages with a little overlap; key the cache by the text and the model version; stamp every vector with its model version and fail loudly on a mismatch; when the model changes, re-index into a new index in the background, test it on real questions, then switch queries and index together in one atomic step, and keep the old index a week to roll back.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why build an embeddings service instead of just calling the model wherever you need a vector?

    Because embedding is heavy GPU inference that is consumed in two very different modes and, critically, must be consistent across every consumer — none of which scattered inline calls handle well. The two modes are a low-latency online path (embed a single query in milliseconds on the search read path) and a high-throughput batch path (embed millions of documents to build an index), which have opposite optimization goals and shouldn't compete for the same undifferentiated capacity. Centralizing embedding into a service lets you share GPU batching and autoscaling across both, cache deterministically, and — most importantly — enforce a single model version everywhere. That last point is the real reason: every vector that will ever be compared (a query against the corpus, or documents against each other) must come from the identical model and version, and the only reliable way to guarantee that across many teams and code paths is to route all embedding through one service that owns the version. Inline calls invite version drift, which silently destroys search quality.

  2. Why do online and batch embedding need separate paths?

    Because they optimize for opposite things on the same model. The online path serves a single query embedding (or a handful) and sits directly on the search critical path, so latency is everything: you keep the model warm to avoid cold starts, use small promptly-flushed batches rather than waiting to accumulate a large batch, and may even use a smaller, faster model, accepting lower GPU utilization as the price of a fast response, because even tens of milliseconds matter when this runs before retrieval on every query. The batch path embeds millions of documents to build or refresh an index, where there is no latency pressure at all but throughput and cost dominate, so you do the opposite: stream documents through a queue and process them in large GPU batches to maximize utilization and minimize cost per vector. If you conflated the two, either live queries would wait behind giant indexing batches, or bulk indexing would run in tiny latency-optimized batches and waste enormous GPU. Separating them lets each hit its target, and you additionally reserve or prioritize capacity so that a big indexing run can never starve live query embedding.

  3. Why does mixing embedding model versions silently break search, and how do you upgrade a model?

    Because each embedding model defines its own vector space, and vectors are only meaningful relative to other vectors from the same space. When you upgrade from model v1 to v2, a v2 embedding of a query and the v1 embeddings of your documents have no consistent geometric relationship — the coordinates mean different things — so nearest-neighbor distances between them are essentially random. The insidious part is that nothing errors: the vector store happily computes distances and returns "nearest" results, they're just semantically nonsense, so relevance collapses silently and you may not notice until users complain. The rule is therefore absolute: the query and the entire corpus must be embedded with the identical model and version. Upgrading the model consequently requires re-embedding the whole corpus, which you do without downtime by building a new versioned index in the background under v2, and then atomically cutting over queries and corpus to v2 together (keeping the v1 index available until the switch). You also version-tag every stored vector with the model version that produced it, so a mismatch can be detected and prevented rather than silently degrading results.

  4. Why are embeddings so cacheable, and what must the cache key include?

    An embedding is a pure, deterministic function of its inputs: for a given piece of text and a given model version, the model always produces the exact same vector. That determinism makes embeddings ideal for caching, and the savings are large in practice because inputs repeat heavily — the same query is typed by thousands of users, and re-indexing a corpus often re-encounters documents whose content hasn't changed. So you cache the resulting vector and, on a hit, skip the GPU inference entirely, along with deduplicating identical inputs within a batch before embedding. The crucial subtlety is the cache key: it must include the model version, not just the text, because the same text embedded by v1 and by v2 yields different, non-interchangeable vectors — keying on text alone would serve a stale v1 vector after a model upgrade and reintroduce the version-mismatch problem. Document vectors are stored durably in the vector store (with their version and source id), while query embeddings can be cached ephemerally since they're cheap to recompute; either way, only genuinely new (text, version) pairs ever cost a GPU pass, which makes caching and dedup among the biggest cost levers in the service.

  5. Why must long documents be chunked before embedding, and how does that interact with retrieval?

    Embedding models have a bounded input length (a maximum number of tokens they can encode into a single vector), and beyond that limit they either truncate — silently dropping the rest of a long document — or can't process it at all. More fundamentally, compressing a very long document into one fixed-dimension vector loses too much: a single vector can't faithfully represent many distinct topics spread across a long text, so its similarity to any specific query is muddy. The solution is to split long documents into chunks that fit the model's input limit (typically with some overlap so context spanning a chunk boundary isn't lost) and embed each chunk into its own vector. This aligns naturally with how retrieval works: search returns the most relevant chunks, which are the right granularity to feed into a RAG prompt or to show as results, rather than whole documents where the relevant passage is buried. So chunking is both a hard constraint of the model's input size and a quality choice that makes retrieval precise, and the batch pipeline performs it as a preprocessing step before embedding, recording each chunk's source document and position so results can be traced back.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. An embeddings service is warranted because embedding is…

    Shared GPU serving, batching, caching and one enforced model version — not doable inline.

  2. Online vs batch embedding are…

    Online favors small warm batches; batch favors large batches through a queue.

  3. The single most important consistency rule is…

    Vectors from different models aren't comparable; a model change means re-embedding everything.

  4. Embeddings cache well because they are…

    Same text + same model = same vector; the cache key must include the model version.

  5. Upgrading the embedding model requires…

    You can't mix versions in one index; build a versioned reindex and switch query+corpus together.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Embed a query: turn a single search query into one vector in real time, before the nearest-neighbor lookup even runs.
  • Index a corpus: embed millions of documents and write their vectors to the store to build or refresh the index.
  • Serve both modes: a low-latency online path and a high-throughput batch path over the same model, routed by one API.
  • Enforce one version: guarantee every vector ever compared — a query against the corpus — comes from the identical model and version.
  • Cache & dedup: skip the GPU for repeated inputs, keyed by (text, model version).

The qualities that shape everything

Each one names the mechanism that buys it.

Fast query embedding on the search path
The online embedder keeps the model warm and resident and uses small, prompt-flushed batches — latency over utilization — because it gates every search request.
Bulk indexing can’t starve live queries
Online and batch run on separate paths, so a giant, latency-insensitive indexing run is isolated from the latency-critical query path.
Index millions of docs efficiently
A batch pipeline streams docs through a queue to GPU workers that embed in large batches and write vectors to the store — autoscalable on queue depth and restartable.
Never silently break search relevance
Query and the whole corpus must share one model + version; a model change means a background versioned re-index then an atomic cutover, with every vector version-tagged.
Don’t pay the GPU for repeats
Embeddings are deterministic, so cache by (text, model version) — a hit skips the GPU entirely — and dedup identical inputs before embedding.
Embed documents longer than the model limit
Chunk long docs to fit the token limit (with overlap for context) and embed the chunks, which is also the granularity retrieval works at.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A shared embeddings service over calling the model inline wherever you need a vector

Embedding is heavy GPU inference used in two opposite modes and, critically, must stay version-consistent across every consumer; scattered inline calls can’t share GPU batching, autoscaling and caching or enforce one model version, and they invite the version drift that silently destroys search quality.

Separate online and batch paths over one undifferentiated path for both

They optimize opposite things on the same model — online needs low latency (warm model, small prompt-flushed batches) on the search path, batch needs throughput (large GPU batches through a queue); conflate them and live queries wait behind giant indexing batches, or bulk indexing wastes the GPU in tiny batches.

A queued batch pipeline in large GPU batches over embedding docs one at a time through the online API

One-at-a-time synchronous calls use tiny batches that waste the GPU, take forever for millions of docs, and compete with live queries; streaming through a queue to workers embedding in large batches maximizes throughput, stays isolated from the query path, and is restartable rather than one fragile in-memory job.

Re-embed the whole corpus under one model + version over hot-swapping to a better model over the old vectors

Vectors from different models live in different spaces, so a v2 query against a v1 corpus produces meaningless nearest-neighbor distances — and nothing errors, relevance just silently collapses. Even a better model is incompatible, so you build a new versioned index in the background and atomically cut over query and corpus together.

Cache and dedup by (text, model version) over re-embedding every input on the GPU

Embeddings are deterministic for a given (text, model) and inputs repeat heavily — the same query typed by thousands of users, unchanged docs on re-index — so caching by that key skips the GPU entirely; the key must include the version, or a v2 upgrade would serve stale v1 vectors.

The answer, out loud

What a strong answer to “Design an Embeddings Service” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    Name the two jobs

    I'd start by pinning down what this service is actually for. The primitive is meaning as a vector — text or an image goes in, a fixed-dimension vector comes out, and close in that space means close in meaning. Semantic search, RAG, recommendations, clustering all rest on it. But there are two jobs here, not one. One is embedding a single query in real time, on the search path, before the nearest-neighbour lookup even runs. The other is embedding millions of documents to build or refresh an index. Same model, completely different shape of work, so I'd design for both from the start.

  2. 3–8 min

    Why a service, not an inline call

    The tempting shortcut is to call the model wherever you happen to need a vector. I'd argue against that for one reason above the others: every vector that will ever be compared has to come from the same model and version, and scattered inline calls have no way to guarantee it. A service also gets you shared GPU batching, autoscaling and caching, which matter — but version control is the real argument. Concretely, I'd keep the model name out of the request. It lives in the service's config, pinned per index, because the moment a caller can pick it, someone will, and the versions drift apart.

    Built in step 1: Why embeddings, and why a service?
  3. 8–13 min

    Split online from batch

    Then I'd split the paths behind one API. Online is a request-response call that runs before every single search, so I'd keep the weights resident and accept poor GPU utilization to get one vector back fast. Batch is the opposite. No user is blocked on it, so I'd let work pile up and hand the GPUs as much of it at a time as they'll take. Those two are optimizing against each other, which is the whole argument for two paths: tuned for latency, a merged path wastes most of the GPU it is given on bulk work; tuned for throughput, every search sits behind whatever indexing job is running. So I'd route on request type at the API.

    Built in step 2: Online vs batch
  4. 13–18 min

    Hold the space constant

    Before I build the batch side I'd nail down what "the same model" actually means, because the space isn't only the weights. It's the dimension, the normalization and the similarity metric together. Vectors are normalized so cosine is a dot product, and if one writer path skips that step the store holds two magnitudes and ranking quietly favours the bigger ones. So I'd treat dimension and metric as fixed for the life of an index — chosen when it's created, not passed per query. Two vectors are only comparable if they came out of that same space, and everything after this rests on that.

    Built in step 4: Text (or image) → vector
  5. 18–24 min

    Make indexing restartable

    On the batch side I'd be explicit that this is a pipeline, not a script. The queue lets it autoscale on depth and, more importantly, resume — a failure near the end of a long run should pick up where it stopped, not re-spend the GPU on the whole corpus. I'd write every vector keyed by its document, its chunk and the model version that produced it, so a retry overwrites instead of appending. Documents longer than the model's input limit get chunked first, with a little overlap, which costs nothing conceptually because retrieval works at chunk granularity anyway.

    Built in step 5: The batch embedding pipeline
  6. 24–31 min

    The rule that actually breaks things

    Now the part I'd spend the most time on, because it is the one that bites. Vectors from different models live in different spaces. If the corpus was embedded with v1 and I start embedding queries with v2 — even a better v2 — the distances between them are meaningless, and nothing errors. Search doesn't fail; relevance just collapses, and you hear about it from users. So the rule is absolute: the query and the entire corpus share one model and one version. Upgrading means re-embedding everything. I'd build the new versioned index in the background, keep writing new and edited documents into both while that runs, compare the two on real questions, then move queries and corpus over together in one step. I'd also stamp every vector with the version that produced it and have both paths reject a mismatch — a version tag nobody checks is just a comment.

    Built in step 6: Model versioning & consistency
  7. 31–37 min

    Stop paying for repeats

    For cost, the lever is that embeddings are deterministic — the same text through the same model always gives the same vector. So I'd cache on the text and the model version, and a hit skips the GPU entirely. That pays twice: the same query typed by thousands of people, and a re-index where most documents haven't changed. The version has to be in the key; without it a v2 upgrade would happily serve stale v1 vectors and drop me straight back into the mismatch I just spent all that time avoiding. I'd dedup identical inputs inside a batch too, keep query vectors ephemeral, and store document vectors durably with their version and source id.

    Built in step 7: Caching & storage
  8. 37–43 min

    Close on the trade-off

    To close: one service, two paths over one model, a queue and large batches for indexing, a cache keyed by text and version, chunking for long documents, and one version across query and corpus, enforced on every write. The trade-off is that centralizing makes search depend on this service — one outage takes query embedding and indexing down together — and the version discipline turns a model upgrade into a planned migration with two indexes alive at once, rather than a config change. I'd take both, because the alternative fails silently. With more time I'd go to multimodal and multilingual, where you need a model that puts images and text into one shared space, and to how I'd prove a new index is actually better before cutting over to it.

What this teaches

Learn AI system design by building an embeddings service that powers semantic search, RAG and recommendations step by step. An interactive guide covering online vs batch embedding, the model inference path, a batched GPU pipeline that indexes millions of documents, the critical model-versioning/consistency rule, caching and dedup, and the unhappy paths (version drift, cost, chunking, multimodal).

Key takeaways

  • Embeddings map meaning to vectors; a shared service gives consistent, scalable GPU serving in two modes.
  • Online (single query, low latency) and batch (millions of docs, throughput) are opposite optimizations.
  • The online path keeps the model warm with small batches — it gates every search request.
  • The model outputs a fixed-dimension, normalized vector; dimension, metric and version define the space.
  • A batch pipeline (queue → big GPU batches → vector store) indexes corpora, restartably and isolated from queries.
  • The rule that matters most: query and corpus must share the same model+version, so a model change re-embeds everything.
  • Cache by (text, version) since embeddings are deterministic; chunk long docs; guard against version drift.

Concepts covered

  • What is an embeddings service?
  • Why embeddings, and why a service?
  • Online vs batch
  • Online embedding
  • Text (or image) → vector
  • The batch embedding pipeline
  • Model versioning & consistency
  • Caching & storage
  • Chunking, cost, drift & multimodal
RUN IT YOURSELF

The vector that ranked first for being big

One corpus of 60 documents, five queries, ranked two ways — by cosine similarity, and by a raw dot product on the same vectors left unnormalized — running for real in your browser. It prints both top-5s side by side, then the two numbers that separate them: how much relevance the raw dot product loses, and how strongly each ranking correlates with nothing but document length. It then re-runs the whole measurement on a corpus 3x larger, so you can see the damage is the mechanism and not the size of the toy. Change LEN_MAX at the top, hit Run, and watch the gap open and close.

HOW TO READ THE CODE — 5 IDEAS
  1. It is one identity, not a preference: dot(a,b) == cosine(a,b) * |a| * |b|. Skip the division and you have not chosen a cheaper metric, you have added both magnitudes to the score (step 5).
  2. Topic and length are drawn independently here, so length carries zero relevance signal — yet rho(rank, length) is -0.79 for the dot product against -0.19 for cosine, and R-precision falls from 58/60 to 37/60.
  3. The cut is R — how many documents are actually on topic — not a constant top-10. That matters: against 12 relevant documents a fixed top-10 saturates after two intruders, so it reports a clean sweep on a bigger corpus while the ranking stays just as wrong. At 180 documents the demo still prints 170/180 vs 119/180, rho -0.80.
  4. The control rules out the stand-in embedder: normalize everything, then let one writer path ship unnormalized — length is gone (rho -0.11) and a path holding a third of the corpus takes 51 of 60 top-R slots while on-topic falls to 29/60. Two magnitudes in one store, and nothing raises an error (steps 5 and 8).
  5. The fix is the cheaper half: normalized once at write time, cosine is the dot product — identical order, identical scores to 1e-16, and query-time square roots drop from 61 to 1 (steps 2 and 5).
CPython · WebAssembly
built to turn meaning into vectors, one query and a million docs at a time — make the calls, swap the model, run the gauntlet.
Finished this one? 0 / 65 AI System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More AI System Designs