Embeddings beyond search: classifying, routing and grouping text
Embeddings can classify, route, cluster and deduplicate text. A worked example in Python: the logistic-regression heads behind Gemma Gemini's router.
Most people meet embeddings through search, where a query and documents become vectors and the nearest documents come back. Once text is a point in space, you can also label it, route it to the right handler, group it with its neighbors or flag it as unusual.
On Gemma Gemini, embeddings pick who answers. EmbeddingGemma 2 turns each message into 768 numbers, and two small classifiers turn those into a routing decision. How embedding models turn text into vectors covers how text becomes a vector.
Six jobs for one vector
A good embedding model puts related texts close together, so almost every use comes down to measuring a distance or learning a boundary.
| Job | What you do with the vectors | Labels you need |
|---|---|---|
| Classification | Train a small model that maps a vector to a label | Some examples per label |
| Routing | Classify, with one label per destination | Some examples per destination |
| Clustering | Group vectors that sit near each other | None |
| Deduplication | Flag pairs above a similarity threshold | A few pairs to set the threshold |
| Anomaly detection | Flag inputs far from a reference set | Only the reference set |
| Recommendations | Suggest items near the ones a person liked | The person's history |
The MTEB benchmark grades clustering by running mini-batch k-means on each model's vectors (MTEB paper), and EmbeddingGemma 2's model card has a prefix for it, task: clustering | query: .
Deduplication is cheap because each text is embedded once; comparing two texts is then a dot product, which equals cosine similarity on unit-length vectors (scikit-learn).
Anomaly detection and recommendations read the same distances from opposite ends: a support ticket far from every past ticket deserves a human look, and an article close to three a reader finished is a good suggestion.
A linear head on a frozen model
A classification head is a small model trained on top of the embeddings, and the simplest useful one is logistic regression. It multiplies each of the 768 numbers by a learned weight, adds a bias, and passes the sum through a sigmoid to get a probability between 0 and 1. MTEB grades classification with this same setup.
Why 118 labels were enough
Most of the work happened before we labeled anything. Per the MTEB authors, most recent fine-tuned embedding models train with a contrastive loss on positive and negative text pairs, pulling related texts together. A coding question and a greeting reach the head already far apart, and our Gemma-side messages (greetings, quick facts, short rewrites) land in different regions from the Gemini side (code, math, medical questions), so one flat boundary separates most of them.
EmbeddingGemma 2's text-only configuration has about 270M parameters (740M with its image and audio encoders), and none of them are retrained. Training sets only the head's 769 numbers. With 118 training messages in 768 dimensions, a boundary that fits every training label is easy to find, so a perfect training score means little.
scikit-learn's logistic regression applies L2 regularization by default, and smaller values of its C setting mean a stronger penalty (scikit-learn). We used C=4.0 with balanced class weights, and judged the head on messages it never saw.
The worked example: Gemma Gemini's router
We hand-labeled 164 messages: 118 for training, from greetings and translations to code, health questions and current events. The other 46 were written afterward in a deliberately different style (lowercase, typos, rambling context, indirect asks) and used only for testing.
Our first attempt tried to get the decision directly out of EmbeddingGemma 2. It scored 64%, and we dropped it. The version that worked embeds each message with the model card's classification prefix, task: classification | query: , and trains two logistic-regression heads on the 768 numbers: one for P(Gemma answers well), one for P(needs the web).
| Test | Messages | Accuracy |
|---|---|---|
| Route head, cross-validation | 118 training set | 97% |
| Jev, our previous cloud router | 118 training set | 94% |
| Route head trained on the 118 | 46 held out | 93% |
| Jev | 46 held out | 98% |
| Web head trained on the 118 | 46 held out | 100% |
Two ways to route covers where each router missed. After testing, we retrained the production heads on all 164 messages.
On the live router, "hi there" scores 0.80 and stays on Gemma, while "Prove that the square root of 2 is irrational" scores 0.20 and goes to Gemini 4 Argon. "What's the weather in NYC today?" scores 0.94, and 0.90 on the web head, so an Exa search runs first and Gemma answers from the results.
The heads ship inside the app as a JSON file of about 16 KB, and scoring takes microseconds. Embedding is the slow step, about 20 ms per message on an Apple M4 Max laptop and a 36 ms median on the CPU of the Jetson Orin Nano that also serves Gemma (how both fit in 8 GB).
The recipe in code
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.linear_model import LogisticRegression
PREFIX = "task: classification | query: "
model = SentenceTransformer(
"google/embeddinggemma-2", device="cpu",
config_kwargs={"vision_config": None, "audio_config": None}, # text only, 270M
)
def embed(texts):
return model.encode([PREFIX + t for t in texts], normalize_embeddings=True)
messages = ["hi there", "Write a haiku about rain",
"Prove that the square root of 2 is irrational",
"Find the race condition in a Go worker pool"]
labels = [1, 1, 0, 0] # 1 = Gemma answers well; we tested with 118, then trained production on all 164
head = LogisticRegression(C=4.0, class_weight="balanced", max_iter=4000)
head.fit(embed(messages), labels)
v = embed(["Write a short poem about the sea"])[0] # 768 numbers
z = v @ head.coef_[0] + head.intercept_[0] # one dot product
p_gemma = 1 / (1 + np.exp(-z)) # and a sigmoid
This needs sentence-transformers 6.1.0 or newer and transformers 5.19.0 or newer (pip install -U sentence-transformers transformers). Our production heads used vectors from ggml-org's Q8_0 GGUF under Ollama, which match the original model's at cosine similarity 0.998 or higher. Run the model in bfloat16 or float32: the model card says float16 returns NaN or degraded embeddings.
Trying it on your own text
- Label a few dozen examples per class, covering the inputs you expect.
- Write the test set separately, in a different voice. Our head scored 97% in cross-validation and 93% on the separately written test set, and that gap is what the second set is for.
- Use one task prefix for training and live traffic.
- Keep a fallback. In Gemma Gemini, keyword rules decide when the embedding call fails or takes over 2.5 seconds.
We replaced our cloud router with EmbeddingGemma 2 tells how the head ran beside Jev as a second opinion before becoming the only router on October 13, 2026.
Gemma Gemini sends each message to Gemma 4 E2B or to Gemini 4 Argon, and shows you why.
Try Gemma Gemini