Skip to main content

Embeddings (SemanticEmbedder)

Ok. Embeddings are the odd one out in this course: every other API turns text into more text, and this one turns text into numbers. SemanticEmbedder maps a string to a fixed-length vector — that's embeddinggemma-300m running fully on-device — where meaning becomes a direction you can measure, so "close in meaning" turns into "close in space." By the end of this page you'll embed a batch of documents, embed a query, and rank them by cosine similarity for semantic search that never leaves the tab.

What you'll build

  • Gate SemanticEmbedder.create() behind an availability() check, and wire its download monitor for a real progress bar.
  • Embed one string or a whole batch with embed(), and read back Float32Array vectors in positional order.
  • Pick the right taskType on embed(), and use the retrieval-query / retrieval-document asymmetry for search.
  • Compare two vectors with a short cosine-similarity function, in plain JavaScript.
  • Build a semantic search — index the docs once, embed only the query per search, reuse one session — and destroy() on teardown.
Prerequisites

Desktop Chrome with built-in AI, plus a caveat the other lessons don't carry: this API is bleeding-edge. SemanticEmbedder is an experimental, Intent-to-Prototype API — Chrome Canary only, behind the chrome://flags/#semantic-embedder-api flag, not approved to ship on stable. On stable Chrome, availability() returns unavailable — that's expected here, not a bug. If the availability states are new to you, start with Setup & the availability lifecycle, and keep the compatibility matrix handy for what's stable where. This page assumes you already know the four availability states.

Check availability, then create with a monitor

Same loop as every API in this course — ask, create, use, tear down. Check availability() first; if it isn't unavailable, call create(). And create() does take options: a signal to abort and a monitor callback whose downloadprogress event fires as the model provisions — same monitor shape as the other built-in APIs. e.loaded is a 0..1 fraction (e.total is always 1), so you get a real progress bar, not a spinner. One catch worth naming: starting the download needs a user gesture, so call create() from a click handler the first time — once the model is on disk, later create() calls don't need a fresh one.

demo.js
if (typeof SemanticEmbedder === 'undefined') {
// No built-in embedder here — Canary + flag only. Degrade gracefully (see lesson 2).
}

const status = await SemanticEmbedder.availability();
// "unavailable" | "downloadable" | "downloading" | "available"
if (status === 'unavailable') throw new Error('SemanticEmbedder unavailable');

// create() takes { signal, monitor }. Call it from a click so the download can start.
const embedder = await SemanticEmbedder.create({
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
updateBar(e.loaded); // e.loaded is 0..1; e.total is always 1
});
},
});

A real percentage, not a spinner. The upside on top: the embedding model is small — a couple hundred megabytes, a rounding error next to Gemini Nano's multi-gig download. It lands once, then it's instant.

Embed a string, then a batch

Now the part you came for. embed() takes one string or an array of strings and hands back an object with an embeddings array — each entry is { values: Float32Array }, one vector per input, in the same order you passed them. For embeddinggemma-300m each vector is 768 numbers. When you're indexing a collection, pass the whole array in one call: it's cheaper than looping and the results come back positionally, so embeddings[i] matches input[i].

demo.js
// One string:
const single = await embedder.embed('How do I reset my password?');
const vector = single.embeddings[0].values; // Float32Array, length 768

// A whole batch — results are positional, embeddings[i] matches documents[i]:
const documents = [
'Our refund window is 30 days from purchase.',
'Reset your password from the account settings page.',
'Two-factor codes come from your authenticator app.',
];
const { embeddings } = await embedder.embed(documents);
console.log(embeddings.length, embeddings[0].values.length); // 3 768

One session, many calls. Create the embedder once and reuse it across every embed() — don't spin up a fresh one per string.

Match the task type

Here's the part the docs bury. The options object takes a taskType, and it tells the model how the text will be used so it can shape the vector for that job. Comparing two sentences for likeness is not the same job as searching a corpus, and the model can shape the numbers differently for each. It's strictly an optional hint, though — a browser whose model doesn't support the distinction is free to ignore it, so treat it as "help when it can," not a guaranteed knob.

taskTypeWhen to use it
semantic-similaritySymmetric "are these two alike?" — dedup, paraphrase, "related".
retrieval-queryThe short thing a user typed into a search box.
retrieval-documentThe stored items you're searching over — index a corpus with this.
classificationAssigning text to preset labels.
clusteringGrouping many texts by similarity.

Retrieval is the one case where the two sides use different task types on purpose. Embed your stored documents with retrieval-document, embed the user's live query with retrieval-query, then compare across. When the model honors the hint, that asymmetric pairing does better than embedding both sides the same way. For a symmetric "are these two things alike" — where neither side is privileged — use semantic-similarity on both.

demo.js
// Index time — the stored documents:
const { embeddings: docVecs } = await embedder.embed(documents, {
taskType: 'retrieval-document',
});

// Search time — the user's query:
const { embeddings: [queryVec] } = await embedder.embed(userQuery, {
taskType: 'retrieval-query',
});

Omit taskType and the text is embedded with no hint. It works. When the model does honor the hint, you're leaving a little relevance on the table for free.

Compare with cosine similarity

Two vectors, one number. Cosine similarity is the cosine of the angle between them: 1 means the same direction (identical meaning), 0 means unrelated, -1 means opposite. It's a dot product over the two magnitudes, and it's eight lines of plain JavaScript — no library, no import, no vector database.

demo.js
function cosineSimilarity(a, b) {
if (a.length !== b.length) throw new Error('length mismatch');
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom; // in [-1, 1]
}

That's the whole comparison engine. Everything below is calling it in a loop and sorting the results.

Put the three pieces together and you get search that matches on meaning, not keywords. Split it the way a real search splits: an index step and a search step. Index once — embed the documents with retrieval-document and cache the vectors. Then every search embeds only the query with retrieval-query and scores it against the cached vectors. Same embedder both times — create it once, reuse it, don't re-embed the corpus on every keystroke. Ask "how long do I have to return something" and the refund line comes back first — even though it never says the word "return."

demo.js
let embedder = null;      // one session, reused across searches
let docVectors = null; // the cached index: [{ text, values }]

async function ensureEmbedder() {
if (!embedder) embedder = await SemanticEmbedder.create({ /* monitor(m){…} */ });
return embedder;
}

// INDEX — embed the corpus once, cache the vectors.
async function index(documents) {
await ensureEmbedder();
const { embeddings } = await embedder.embed(documents, {
taskType: 'retrieval-document',
});
docVectors = documents.map((text, i) => ({ text, values: embeddings[i].values }));
}

// SEARCH — embed ONLY the query, score against the cached index.
async function search(query) {
await ensureEmbedder();
const { embeddings: [queryVec] } = await embedder.embed(query, {
taskType: 'retrieval-query',
});
return docVectors
.map((d) => ({ text: d.text, score: cosineSimilarity(queryVec.values, d.values) }))
.sort((a, b) => b.score - a.score);
}

No server took that query. No Pinecone, no Weaviate, no pgvector indexed those docs. The index is a Float32Array, the query is an angle, and the whole thing ran on the user's own machine with the network graph flat at zero.

Shrink vectors with Matryoshka truncation

One more trick, because embeddinggemma-300m is a Matryoshka model — the important information is packed into the leading dimensions. So you can lop a 768-dim vector down to 512, 256, or 128 by keeping the first N values and renormalizing, and it still compares meaningfully. Smaller vectors, less storage, faster compares, a modest hit to accuracy. Pick one size and stick to it: only ever compare vectors truncated to the same length.

demo.js
function truncate(values, dims) {
const sliced = values.slice(0, dims); // keep leading N dims
let norm = 0;
for (let i = 0; i < sliced.length; i++) norm += sliced[i] * sliced[i];
norm = Math.sqrt(norm);
if (norm === 0) return sliced;
const out = new Float32Array(sliced.length);
for (let i = 0; i < sliced.length; i++) out[i] = sliced[i] / norm; // renormalize
return out;
}

const small = truncate(vector, 256); // supported sizes: 768 | 512 | 256 | 128

Destroy the session

A live embedder holds the model in memory. Lighter than a LanguageModel session, but leak enough of them and you'll still feel it. So destroy() when you're done, and wire it to teardown so a closed tab doesn't strand one.

Free it.

demo.js
embedder.destroy();

// Wire it to teardown so a closed tab doesn't leak the model:
window.addEventListener('beforeunload', () => embedder?.destroy());
Try it

Run it locally: open 11-embeddings/index.html from the chrome-ai-course repo in desktop Chrome Canary. Or use the hosted demo: on-device semantic search (with the API walkthrough).

Expected: you edit a short list of documents, hit Index documents, then type a query and Search. The documents re-rank by cosine similarity — the closest-in-meaning line jumps to the top, score and all, with nothing sent over the network. Indexing happens once; each search only embeds the query.

Requires: Chrome Canary with #semantic-embedder-api enabled — see Setup & the availability lifecycle. On stable Chrome you'll see unavailable, and the demo says so instead of breaking.

Gotchas & troubleshooting

Most of the pain here is the price of admission: this is an experimental, Canary-only API, so half the trouble is just getting availability() to say anything but unavailable.

availability() returns unavailable for almost everyone

Symptom: the demo reports unavailable on your machine. Cause: it's Canary-only behind a flag — it isn't on stable Chrome. Fix: use Chrome Canary, set #semantic-embedder-api to Enabled and #optimization-guide-on-device-model to Enabled BypassPerfRequirement, then relaunch. On stable Chrome, treat unavailable as the normal path and degrade to a message, not a blank page.

create() rejects with "unable to create a session"

Symptom: create() rejects with "The device is unable to create a session to run the model." Cause: usually you called create() outside a user gesture while the model still had to download — starting the download needs a click. Fix: call create() from a click handler and pass a monitor so you can show downloadprogress; once the model is on disk, later create() calls succeed without a fresh gesture.

The wrong task type weakens your results

Symptom: search returns weaker matches than you expected. Cause: you embedded both sides the same way, or used semantic-similarity for retrieval. Fix: retrieval-document for the corpus, retrieval-query for the query; semantic-similarity only for symmetric comparisons. Remember taskType is an option on embed(), not on create() — and it's a hint the model may ignore, so it helps when supported, never hurts.

QuotaExceededError on a long input

Symptom: embed() rejects with QuotaExceededError. Cause: the input is longer than the model will take in one shot. Fix: shorten the text, or split it into passages and embed each — you were going to store passage-level vectors for search anyway.

Cosine scores go random after a model update

Symptom: cached vectors stop matching sensibly. Cause: vectors from two different model versions aren't comparable, even at the same length — and neither are two different Matryoshka sizes. Fix: persist the model identity next to any cached vectors, re-embed the corpus when the model changes, and only compare vectors truncated to the same length.

Recap

  • SemanticEmbedder.availability() gates create(); create() takes { signal, monitor }, and the monitor's downloadprogress gives you a real progress bar.
  • embed() takes a string or an array and returns { embeddings: [{ values: Float32Array }] } in positional order — 768 dims for embeddinggemma-300m.
  • taskType lives on embed(); use retrieval-document for the corpus and retrieval-query for the query — it's an optional hint the model may ignore.
  • Index the corpus once and reuse one session; each search embeds only the query, then ranks by a plain-JS cosineSimilarity.
  • Matryoshka truncation shrinks vectors to 512 / 256 / 128 dims — same size on both sides, always.
  • destroy() frees the model, and vectors only compare within the same model version.

Everyone else is renting a vector database to measure an angle.

Next steps


Next: WebMCP.