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 anavailability()check, and wire its download monitor for a real progress bar. - Embed one string or a whole batch with
embed(), and read backFloat32Arrayvectors in positional order. - Pick the right
taskTypeonembed(), and use theretrieval-query/retrieval-documentasymmetry 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.
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.
- JavaScript
- TypeScript
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
});
},
});
type Availability = 'unavailable' | 'downloadable' | 'downloading' | 'available';
const status: Availability = await SemanticEmbedder.availability();
if (status === 'unavailable') throw new Error('SemanticEmbedder unavailable');
const embedder = await SemanticEmbedder.create({
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
updateBar(e.loaded); // 0..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].
- JavaScript
- TypeScript
// 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
interface SemanticEmbedding { values: Float32Array; }
interface EmbedResult { embeddings: SemanticEmbedding[]; }
const single: EmbedResult = await embedder.embed('How do I reset my password?');
const vector: Float32Array = single.embeddings[0].values; // length 768
const documents: string[] = [
'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);
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.
taskType | When to use it |
|---|---|
semantic-similarity | Symmetric "are these two alike?" — dedup, paraphrase, "related". |
retrieval-query | The short thing a user typed into a search box. |
retrieval-document | The stored items you're searching over — index a corpus with this. |
classification | Assigning text to preset labels. |
clustering | Grouping 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.
- JavaScript
- TypeScript
// 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',
});
type TaskType =
| 'semantic-similarity'
| 'retrieval-query'
| 'retrieval-document'
| 'classification'
| 'clustering';
const docTask: TaskType = 'retrieval-document';
const queryTask: TaskType = 'retrieval-query';
const { embeddings: docVecs } = await embedder.embed(documents, { taskType: docTask });
const { embeddings: [queryVec] } = await embedder.embed(userQuery, { taskType: queryTask });
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.
- JavaScript
- TypeScript
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]
}
function cosineSimilarity(a: Float32Array, b: Float32Array): number {
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;
}
That's the whole comparison engine. Everything below is calling it in a loop and sorting the results.
Build a semantic search
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."
- JavaScript
- TypeScript
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);
}
interface Ranked { text: string; score: number; }
interface DocVector { text: string; values: Float32Array; }
let embedder: SemanticEmbedderInstance | null = null;
let docVectors: DocVector[] | null = null;
async function ensureEmbedder(): Promise<SemanticEmbedderInstance> {
if (!embedder) embedder = await SemanticEmbedder.create({ /* monitor(m){…} */ });
return embedder;
}
async function index(documents: string[]): Promise<void> {
await ensureEmbedder();
const { embeddings } = await embedder!.embed(documents, {
taskType: 'retrieval-document',
});
docVectors = documents.map((text, i) => ({ text, values: embeddings[i].values }));
}
async function search(query: string): Promise<Ranked[]> {
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.
- JavaScript
- TypeScript
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
function truncate(values: Float32Array, dims: number): Float32Array {
const sliced = values.slice(0, 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;
return out;
}
const small: Float32Array = truncate(vector, 256);
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.
- JavaScript
- TypeScript
embedder.destroy();
// Wire it to teardown so a closed tab doesn't leak the model:
window.addEventListener('beforeunload', () => embedder?.destroy());
embedder.destroy();
window.addEventListener('beforeunload', () => embedder?.destroy());
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.
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.
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.
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.
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.
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()gatescreate();create()takes{ signal, monitor }, and the monitor'sdownloadprogressgives you a real progress bar.embed()takes a string or an array and returns{ embeddings: [{ values: Float32Array }] }in positional order — 768 dims forembeddinggemma-300m.taskTypelives onembed(); useretrieval-documentfor the corpus andretrieval-queryfor 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
- The Prompt API (LanguageModel) — the on-device model that generates the text you just matched against the right context.
- WebMCP: the page as a tool surface — expose your page's own actions as tools an agent can call.
- Shipping & compatibility — what's stable, what's still flagged like this one, and what stays desktop-only.
Next: WebMCP.