Most product search is a keyword match. Someone types "check if my idea has demand", your listing says "validate your startup with market data", and the search box comes back empty. Semantic search fixes that by comparing meaning instead of words, and until recently it meant an embeddings API, a bill per request and your catalog leaving your server.
EmbeddingGemma 2 is Google's open embedding model, released on October 6, 2026 under Apache 2.0. It has 740M parameters in total, but the text part alone is 270M, small enough for a laptop or a cheap server. It puts text, code, images, video and audio into one space of 768 numbers per item, so a sentence can find a screenshot.
I tested every step below on a MacBook Pro (M3 Pro, 18 GB), and the index is real data: the 2,218 live product listings on Nick Launches. The search results, timings and sizes in this guide are what I got, not what the model card promises.
Install
- Python
- 3.12
- Package
- sentence-transformers[image]
- Download
- 1.4 GB, no token needed
A clean virtual environment, then one package with its image extra:
uv venv -p 3.12 .venv && source .venv/bin/activate
uv pip install "sentence-transformers[image]" numpy
# without uv:
# python3.12 -m venv .venv && source .venv/bin/activate
# pip install "sentence-transformers[image]" numpyThat pulled sentence-transformers 6.1.0, transformers 5.20.1 and PyTorch 2.14.1 for me. The model downloads on first use: one 1.4 GB file with every encoder in it, whichever ones you load. It came down without a Hugging Face account or token.
ImportError: EmbeddingGemma2VideoProcessor requires the Torchvision library. The processor imports torchvision even when the vision encoder is switched off. The [image] extra installs it.Load only the encoders you need
- Text only
- 270M parameters
- Load time
- 4.7 s on an M3 Pro
- Precision
- float32 or bfloat16
The model is built from parts: a text backbone and embedder, an optional vision encoder and an optional audio encoder. Switch off what you do not use and the model in memory shrinks to match:
import torch
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(
"google/embeddinggemma-2",
model_kwargs={"torch_dtype": torch.float32},
config_kwargs={"vision_config": None, "audio_config": None}, # text only
)
print(model.device) # mps:0 on a Mac, cuda:0 on NVIDIA, else cpu
print(sum(p.numel() for p in model.parameters())) # 271002624| You need | config_kwargs | Size |
|---|---|---|
| Text and code | {"vision_config": None, "audio_config": None} | 270M |
| Text and images | {"audio_config": None} | 440M |
| Text and audio | {"vision_config": None} | 570M |
| Everything, video included | {} | 740M |
Embed your catalog
- Document format
- title: … | text: …
- 2,218 listings
- 42 s on GPU, ~2.3 min on CPU
- Index size
- 6.8 MB
EmbeddingGemma 2 was trained with short prompts in front of every input, and it searches best when you use them. Documents get title: {title} | text: {content}, or title: none when there is no title. My catalog is one JSON line per product with a name, a tagline and a description:
import json
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
rows = [json.loads(line) for line in open("catalog.jsonl")]
docs = [f"title: {r['name']} | text: {r['tagline']}. {r['description']}" for r in rows]
model = SentenceTransformer(
"google/embeddinggemma-2",
model_kwargs={"torch_dtype": torch.float32},
config_kwargs={"vision_config": None, "audio_config": None},
)
vectors = model.encode(docs, prompt="", batch_size=32, normalize_embeddings=True)
np.save("catalog-768.npy", vectors) # shape (2218, 768)prompt="" matters: the documents already carry their own prefix with the real product name, so this stops the library from adding its default title: none prefix on top. normalize_embeddings=True lets a plain dot product act as cosine similarity later.
On the M3 Pro's GPU the 2,218 listings (about 420 characters each) took 42 seconds. On CPU only it ran at about 16 listings a second, so roughly 2.3 minutes. The saved index is 6.8 MB.
Search it
- Query prompt
- task: search result | query:
- Math
- One dot product
- Query time
- 30 to 60 ms once warm
Queries get the other prompt, task: search result | query: . The library has it saved under the name query, so you do not type it:
q = model.encode("check if my startup idea has real demand before I build it",
prompt_name="query", normalize_embeddings=True)
scores = vectors @ q
for i in np.argsort(-scores)[:3]:
print(round(float(scores[i]), 3), rows[i]["name"])
# 0.788 Demand Discovery AI
# 0.782 IdeaScope
# 0.78 Idea LaunchFor a few thousand rows, NumPy is the whole vector database: multiplying a 2,218 by 768 matrix is instant. Here are six searches I ran against the live listings, next to a plain keyword search that requires every word longer than three letters:
| Search | Keyword hits | EmbeddingGemma 2, top 3 |
|---|---|---|
| check if my startup idea has real demand before I build it | 0 | Demand Discovery AI 0.788, IdeaScope 0.782, Idea Launch 0.780 |
| find people on Reddit who complain about the problem my product solves | 0 | ReplyHey 0.723, RedLeads.app 0.722, IdeaFast 0.715 |
| turn my selfies into professional headshots | 1 | FotoPro 0.805, FreeHeadshot 0.776, AI Pocket Studio 0.723 |
| listen to my ebooks offline | 0 | Strophe Reader 0.756, Certyneo Reader 0.688, Geeke 0.686 |
| post the same content to every social network at once | 0 | One Shot Socials 0.798, PostPeer 0.760, instadl 0.744 |
| record a product demo video of my screen | 1 | Rendemo 0.763, ScreenshotPop 0.758, Stepshots 0.749 |
The first result is right in all six, where the keyword search found one listing at best. Further down it gets looser: instadl saves social posts rather than publishing them, ScreenshotPop styles screenshots rather than recording video, and the two readers under Strophe open PDFs rather than reading aloud. Check your own top results by hand before you ship. On the Mac's GPU each search took 30 to 60 ms once warm; the first few took up to 400 ms.
Shrink the vectors, carefully
- Sizes
- 768, 512, 256, 128
- 256 dims
- 6.8 MB → 2.3 MB
- My result
- 5 of 6 searches held up
The model is trained so the first 512, 256 or 128 numbers of each vector still work on their own. Cut, then normalize again:
small = vectors[:, :256]
small = small / np.linalg.norm(small, axis=1, keepdims=True)
# or at encode time:
# model.encode(docs, prompt="", truncate_dim=256, normalize_embeddings=True)Google's benchmark puts 256 dimensions close to lossless, and on my six searches the top five stayed the same or lost one result in five cases. The ebook search kept only two of its top five. At 2,218 rows the full index is 6.8 MB, so I keep 768. Shrink when you have millions of rows and the memory bill is real, and test your own searches when you do.
Find a screenshot from a sentence
- Encoders
- Text + vision, 440M
- 16 screenshots
- 9.5 s to embed
- Result
- 5 of 5 found first
Load the vision encoder too, and images go into the same space as your text. An image is an input with an <|image|> placeholder and a file:
import glob
model = SentenceTransformer(
"google/embeddinggemma-2",
model_kwargs={"torch_dtype": torch.float32},
config_kwargs={"audio_config": None}, # text + images
)
files = sorted(glob.glob("screenshots/*"))
shots = np.stack([
model.encode({"text": "<|image|>", "image": [f]}, normalize_embeddings=True)
for f in files
])
q = model.encode("a table of sales leads with deal size", prompt_name="query", normalize_embeddings=True)
print(files[int(np.argmax(shots @ q))])I indexed the 16 screenshots from two of our guides and described five of them in plain words. Every description found the right screenshot first:
| Description | Top result | Score |
|---|---|---|
| Google search results showing Reddit threads | Google's Forums tab with r/SaaS threads | 0.723 |
| a character counter for SEO titles and descriptions | The Meta Title and Description tool | 0.746 |
| approve a change an AI agent made to my data | A Busabase change request diff | 0.690 |
| list of launch directories with how often AI cites them | The Launch Directories tracker sorted by AI cited | 0.765 |
| a table of sales leads with deal size | A Busabase table of leads | 0.739 |
That is a help center where "where do I approve changes" lands on the right screenshot, or a changelog you can search by what the feature looks like. The text and image process peaked at about 2.1 GB of memory on my Mac.
{"text": "Leads table. <|image|>", "image": ["leads.png"]}. Images take no text prompt in front.Serve it from your app
- Server
- Python standard library
- CPU query
- 70 to 280 ms end to end
- Box
- 2 GB RAM minimum
Your web app probably is not written in Python. Keep the model in one small process and call it over HTTP. This server uses only the standard library, runs on CPU, and is what I timed:
import json
import numpy as np
import torch
from http.server import BaseHTTPRequestHandler, HTTPServer
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(
"google/embeddinggemma-2",
device="cpu",
model_kwargs={"torch_dtype": torch.float32},
config_kwargs={"vision_config": None, "audio_config": None},
)
rows = [json.loads(line) for line in open("catalog.jsonl")]
vectors = np.load("catalog-768.npy")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
q = model.encode(body["q"], prompt_name="query", normalize_embeddings=True)
scores = vectors @ q
top = np.argsort(-scores)[: body.get("k", 5)]
out = [{"slug": rows[i]["slug"], "name": rows[i]["name"],
"score": round(float(scores[i]), 3)} for i in top]
data = json.dumps(out).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.end_headers()
self.wfile.write(data)
HTTPServer(("127.0.0.1", 8092), Handler).serve_forever()python search_server.py &
curl -s -X POST localhost:8092 -H 'content-type: application/json' \
-d '{"q":"an offline ebook reader that reads aloud","k":3}'
# [{"slug": "strophe-reader", "name": "Strophe Reader", "score": 0.832}, ...]From your app's server code, it is one fetch:
export async function semanticSearch(q: string, k = 5) {
const res = await fetch("http://127.0.0.1:8092", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ q, k }),
});
return (await res.json()) as Array<{ slug: string; name: string; score: number }>;
}On CPU, with the GPU unused, three test searches came back in 70 to 280 ms each, the slowest being the first one after start. Loading the text model takes about 1.5 GB of memory, so give it a box with at least 2 GB of RAM, or 4 GB if the box also runs your app. Keep it bound to 127.0.0.1 and put your own auth in front if anything outside the box needs it.
ggml-org/embeddinggemma-2-GGUF (310 MB at Q8_0), for running it under llama.cpp instead of Python. Support for the architecture landed in llama.cpp on October 6, 2026. The older build I had installed refused to load it with unknown model architecture: 'gemma-embedding2', so update llama.cpp before you try that route.What it actually costs
- The model: $0. Apache 2.0, per the model card, and the weights are a free download.
- Indexing 2,218 listings: 42 seconds on a Mac's GPU, about 2.3 minutes on CPU. No API bill.
- Serving: whatever a box with 2 GB or more of RAM costs you, or $0 if it runs next to your app on a server you already pay for.
When a hosted embedding API is still the better call
- Your app runs only on serverless functions. A 1.4 GB model does not fit in a cold start. You need one process that stays up, or an API.
- You are indexing tens of millions of rows. At 16 rows a second on CPU, that is weeks of compute. Rent a GPU for the backfill or pay per token.
- You already pay for a vector database with built-in embeddings. Then the saving is small and the extra moving part is not worth it.
For a catalog, a docs site or a help center in the thousands of rows, local wins.
Troubleshooting
- ImportError about Torchvision on load. Install
sentence-transformers[image], even for text only. See step 1. - A warning that image decoding with torchvision is deprecated. It still works. Installing
torchcodec0.16 or newer makes the warning go away. - Scores that look random, or NaN. Check you are not loading in float16, and that queries use
prompt_name="query"while documents use thetitle: … | text: …format. - Search quality dropped after shrinking. Go back to 768 dimensions or try 512. See step 5.
- llama.cpp says unknown model architecture. Your build predates October 6, 2026. Update it.
You're set
You have a search box that understands what people mean, an index you can rebuild in minutes, and screenshots you can find by describing them. Start with your docs or your catalog, check the top three results for twenty real searches by hand, and only then think about shrinking or scaling.
Built search into your product? Launch it on the weekly board: launching is free, every launch is reviewed by a person, and it gets a permanent listing page. Submit it here.
Disclaimer: Nick Launches is not affiliated with Google. Tested on October 19, 2026 on macOS with Python 3.12, sentence-transformers 6.1.0, transformers 5.20.1 and PyTorch 2.14.1, against the 2,218 live listings on Nick Launches that day. Scores and timings will differ on your data and hardware; check the model card if something does not match.