Modalities

Embedding model inference at scale

Embedding models look simple next to autoregressive LLMs, but serving them at millions of requests per second introduces its own set of challenges: dynamic batching, sequence length padding, pooling strategies, and the surprising importance of getting your normalization right.

After spending weeks on autoregressive LLMs, I found embedding model inference almost refreshing. No KV cache. No speculative decoding. No continuous batching headaches. You take a piece of text, run it through a transformer encoder, and get back a fixed-size vector. Simple, right?

It is, until you need to serve a few thousand requests per second with p99 latency under 20 milliseconds. Then things get interesting fast.

What makes embeddings different from LLMs

An embedding model like E5, BGE, or GTE is typically a BERT-style encoder. The forward pass is a single shot: you feed in a sequence of tokens, every layer runs once, and you extract a vector from the final hidden states. There is no autoregressive loop, no token-by-token generation, no KV cache that grows with sequence length.

This changes the performance profile completely:

The padding problem

The first surprise when serving embeddings at scale is how much time you waste on padding. BERT-style models need fixed-length tensors within a batch. If your batch has sequences of length 12, 45, and 510, the naive approach pads everything to 512 tokens. That means the 12-token sequence burns 500 tokens worth of compute doing nothing useful.

There are two main approaches to fix this:

# Sequence packing with FlashAttention
# Instead of padding to max_len:
# input_ids: [B, max_len] with lots of padding

# Pack sequences end-to-end:
# packed_ids: [total_tokens]  (no padding)
# cu_seqlens: [0, len1, len1+len2, ...]  (cumulative lengths)

from flash_attn import flash_attn_varlen_func

output = flash_attn_varlen_func(
    q, k, v,
    cu_seqlens_q=cu_seqlens,
    cu_seqlens_k=cu_seqlens,
    max_seqlen_q=max_len,
    max_seqlen_k=max_len,
)

With sequence packing, I have seen throughput improvements of 2x to 4x on workloads with highly variable sequence lengths. The improvement scales with how skewed your length distribution is.

Pooling: mean, CLS, or last token

After the forward pass, you need to reduce the per-token hidden states into a single vector. The choice of pooling strategy matters more than most people realize, because it interacts with how the model was trained:

Getting this wrong is a silent killer. The embeddings will still be 1024-dimensional vectors that look reasonable, but retrieval quality will be quietly degraded. Always check the model card for the correct pooling strategy.

Pitfall

When using mean pooling, make sure you mask out padding tokens from the average. A common bug is to include padding zeros in the mean, which dilutes the embedding. Multiply hidden states by the attention mask before averaging, and divide by the mask sum, not the sequence length.

Normalization and similarity

Most modern embedding models are trained with cosine similarity, which means the vectors should be L2-normalized before storage and search. This seems trivial, but it has a practical implication for serving: you should normalize on the server side, not the client side. This way every downstream consumer gets correctly normalized vectors, and you avoid a class of bugs where one client forgets to normalize and gets garbage similarity scores.

# Server-side normalization
import torch.nn.functional as F

embeddings = model(input_ids, attention_mask)
embeddings = mean_pool(embeddings, attention_mask)
embeddings = F.normalize(embeddings, p=2, dim=-1)  # L2 normalize

# Now cosine similarity == dot product
# which is what vector databases optimize for

Quantization and distillation

Because embedding models are compute-bound (not memory-bound like LLM decode), quantization helps differently here. INT8 quantization of an embedding model does not primarily reduce memory bandwidth pressure. Instead, it doubles the throughput of matrix multiplications on Tensor Cores that support INT8 (A100, H100). On an H100, INT8 gives roughly 2x the TOPS compared to FP16.

The quality impact depends heavily on the model. I have seen models where INT8 dynamic quantization causes less than 0.1% degradation in retrieval recall, and others where it drops by 2 to 3%. Always measure on your actual retrieval benchmark before deploying quantized embeddings in production.

For even higher throughput, consider using a smaller distilled model. A well-distilled 33M parameter model can match a 330M parameter model on many benchmarks while running 10x faster. Models like bge-small-en-v1.5 (33M params) versus bge-large-en-v1.5 (335M params) illustrate this tradeoff well.

Batching strategy for production

The optimal serving setup for embedding models differs from LLMs. Here is what I have found works well:

Embedding inference is the rare case where "just throw a bigger GPU at it" is often the wrong answer. A well-optimized pipeline on a T4 can outperform a naive setup on an A100, because the bottleneck is almost always in the batching and padding, not raw compute.

Connection to vector search

The embedding model is only half the story. What you do with the vectors afterward matters just as much. The choice of embedding dimension (384, 768, 1024, or higher) directly affects vector index size and search speed. Some models like Nomic Embed support Matryoshka representation learning, where you can truncate the vector to a shorter dimension (say 256 instead of 768) with minimal quality loss. This halves your index memory and speeds up search.

If you are building an embedding pipeline, think about the full loop: model size, quantization, pooling, normalization, dimension, index type. Each choice compounds. Get them all right and you can serve millions of queries per day on a single GPU. Get one wrong and you will be throwing hardware at a software problem.

Next up: Whisper ASR, where the encoder-decoder architecture brings back some of the complexity we just avoided.