generate-embedding tutorial
Turn any text on a web page into a 384-dimensional sentence embedding — locally, in the browser, with no API key.
<generate-embedding> is a vanilla-JS custom element that watches its own text, sends it to a dedicated web worker, and emits a sentence embedding using the lightweight all-MiniLM-L6-v2 model (via Transformers.js). It prefers WebGPU when available and falls back to WASM, so everything stays on the client.
Install from npm
npm install generate-embedding
npm install runs a postinstall script that downloads the model weights (~68 MB) and ONNX Runtime wasm binaries into node_modules/generate-embedding/models/ and node_modules/generate-embedding/ort/. Set GENERATE_EMBEDDING_SKIP_MODEL_DOWNLOAD=1 to skip the download — the component will fetch the model from the Hugging Face hub on first use instead.
Import the package once in your app to register the custom element and inject its styles:
import 'generate-embedding';
Then use the element in HTML:
<generate-embedding contenteditable="true">
The quick brown fox jumps over the lazy dog.
</generate-embedding>
Serving the worker, models, and ORT files
The component loads three things over HTTP at runtime:
/generate-embedding-worker.js— the embedding web worker./models/— the locally downloaded model weights./ort/— the ONNX Runtime wasm binaries.
You must copy these from node_modules/generate-embedding/ into your public / static folder so they are served at those paths:
# Example: copy into a Vite / webpack public directory
cp node_modules/generate-embedding/dist/generate-embedding-worker.js public/
cp node_modules/generate-embedding/dist/*.mjs public/
cp node_modules/generate-embedding/dist/*.wasm public/
cp -r node_modules/generate-embedding/models public/
cp -r node_modules/generate-embedding/ort public/
If you serve the package from a sub-path or a CDN, override the URLs with attributes:
<generate-embedding
worker-url="/assets/generate-embedding-worker.js"
model-path="/assets/models/"
ort-path="/assets/ort/"
contenteditable="true"
>
Edit me.
</generate-embedding>
If the worker or model files are not served, the component still works: Transformers.js falls back to fetching the model from the Hugging Face hub and the worker falls back to CDN-hosted ONNX Runtime binaries. The offline / self-hosted guarantee only applies when the files are served locally.
Programmatic API
After importing the package, the embeddings client is available on window.embeddings for debugging and advanced use:
import 'generate-embedding';
const { embedTexts, cosineSimilarity, getEmbedderStatus } = window.embeddings;
const vectors = await embedTexts(['hello world', 'goodbye world']);
console.log(cosineSimilarity(vectors[0], vectors[1]));
You can also import the client module directly if your bundler handles the bare imports:
import { embedTexts, cosineSimilarity } from 'generate-embedding/lib';
In this tutorial you will:
- Get the project running locally.
- Embed editable text with
<generate-embedding>. - Compare two sentences with cosine similarity.
- Build a tiny semantic search engine.
- Persist embeddings in markup.
- Build and deploy a production bundle.
Prerequisites
- Node.js 18 or later
- A Chromium-based browser for tests (Playwright); runtime works in any modern browser
- About 100 MB of disk space for model weights and ONNX Runtime binaries
Quick start (clone and hack)
To run the source repo locally:
git clone https://github.com/lnsy-dev/generate-embedding.git
cd generate-embedding
npm install # downloads model weights (~68 MB) and ORT wasm binaries
npm start # opens the dev server on http://localhost:3000
The first npm install runs a postinstall script. If you want to skip the local download, set GENERATE_EMBEDDING_SKIP_MODEL_DOWNLOAD=1; the component will fetch the model from the Hugging Face hub the first time it runs instead.
Bundler examples
webpack
Copy the worker, wasm, model, and ORT assets to your output directory with CopyWebpackPlugin:
new CopyWebpackPlugin({
patterns: [
{ from: 'node_modules/generate-embedding/dist/generate-embedding-worker.js', to: 'generate-embedding-worker.js' },
{ from: 'node_modules/generate-embedding/dist/*.mjs', to: '[name][ext]' },
{ from: 'node_modules/generate-embedding/dist/*.wasm', to: '[name][ext]' },
{ from: 'node_modules/generate-embedding/models', to: 'models' },
{ from: 'node_modules/generate-embedding/ort', to: 'ort' },
],
});
Vite
Use vite-plugin-static-copy or a post-build script to copy the same files into public/ or dist/.
Static / no bundler
If you are writing plain HTML, copy the assets manually and import the bundle from a CDN or relative path:
<script type="module">
import 'https://unpkg.com/generate-embedding';
</script>
Note: CDN usage still requires the worker, models, and ORT files to be served. The easiest path for static sites is to copy the files from
node_modules/generate-embedding/into your site root as shown above.
Step 1 — Embed editable text
Open index.html (it is also the webpack dev-server template). Drop a <generate-embedding> element anywhere in the body and make it contenteditable:
<generate-embedding id="demo" contenteditable="true">
The quick brown fox jumps over the lazy dog.
</generate-embedding>
The component watches innerText. Whenever you stop typing for 300 ms, it sends the text to the worker and publishes the result in two ways:
- The element's
vectorattribute, which contains a JSON string of the 384 numbers. - A
EMBEDDING-RESULTevent with the full result object.
Listen to the event in plain JavaScript:
const demo = document.getElementById('demo');
demo.addEventListener('EMBEDDING-RESULT', (event) => {
console.log('text:', event.detail.text);
console.log('vector:', event.detail.vector); // number[384]
console.log('duration:', event.detail.duration); // milliseconds
});
If you are using the dataroom-js helpers bundled with this project, you can also use on:
demo.on('EMBEDDING-RESULT', (data) => {
const preview = data.vector.slice(0, 5).map(n => n.toFixed(4)).join(', ');
console.log(`Embedding: [${preview} …]`);
});
Refresh the page, click the sentence, and edit it. The browser console will show a new vector every time you pause typing.
Step 2 — Compare sentences with cosine similarity
Embeddings turn sentences into points in space. Two points that point in the same direction are semantically similar. The cosine similarity between two vectors is a number from -1 to 1; for normalized sentence embeddings it is effectively 0 (unrelated) to 1 (very similar).
Add two elements to your page:
<generate-embedding id="a" contenteditable="true">A cat naps in the sun.</generate-embedding>
<generate-embedding id="b" contenteditable="true">A kitten sleeps in the sunshine.</generate-embedding>
<p id="similarity">Similarity: —</p>
Then compute similarity whenever either element emits a result:
function cosineSimilarity(a, b) {
let dot = 0;
let normA = 0;
let 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];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
let vectorA = null;
let vectorB = null;
function updateSimilarity() {
if (!vectorA || !vectorB) return;
const score = cosineSimilarity(vectorA, vectorB);
document.getElementById('similarity').textContent =
`Similarity: ${score.toFixed(3)}`;
}
document.getElementById('a').addEventListener('EMBEDDING-RESULT', (e) => {
vectorA = e.detail.vector;
updateSimilarity();
});
document.getElementById('b').addEventListener('EMBEDDING-RESULT', (e) => {
vectorB = e.detail.vector;
updateSimilarity();
});
Try changing sentence B to The stock market closed lower today. The similarity score will drop sharply.
Step 3 — Build a tiny semantic search engine
Because every embedding lives in the same 384-dimensional space, you can find the sentence closest to a query by comparing vectors.
Create a small corpus:
<ul id="corpus">
<li><generate-embedding>A cat naps in the sun.</generate-embedding></li>
<li><generate-embedding>The stock market closed lower today.</generate-embedding></li>
<li><generate-embedding>How do I bake sourdough bread?</generate-embedding></li>
</ul>
<input id="query" type="text" value="kitten sleeping" />
<ol id="results"></ol>
Then rank the corpus against the query vector:
const queryEl = document.getElementById('query');
const corpusEls = [...document.querySelectorAll('#corpus generate-embedding')];
const resultsEl = document.getElementById('results');
async function search() {
const queryText = queryEl.value.trim();
if (!queryText) return;
// Create a temporary element to embed the query.
const temp = document.createElement('generate-embedding');
temp.textContent = queryText;
document.body.appendChild(temp);
const queryVector = await new Promise((resolve) => {
temp.addEventListener('EMBEDDING-RESULT', (e) => resolve(e.detail.vector), { once: true });
});
const scored = corpusEls
.map((el) => {
const vector = el.hasAttribute('vector')
? JSON.parse(el.getAttribute('vector'))
: null;
return {
text: el.innerText,
score: vector ? cosineSimilarity(queryVector, vector) : -1,
};
})
.sort((a, b) => b.score - a.score);
resultsEl.innerHTML = scored
.map((item) => `<li>${item.text} — <strong>${item.score.toFixed(3)}</strong></li>`)
.join('');
temp.remove();
}
queryEl.addEventListener('change', search);
When you run the search, the A cat naps in the sun. item should rank highest because it is semantically closest to kitten sleeping.
Step 4 — Persist embeddings in markup
Every successful embed writes two attributes to the element:
generated— a marker that the vector matches the current text.vector— the JSON embedding.
If you save that markup and reload it, the component detects the persisted vector and re-emits it immediately with cached: true, without starting the worker. This is useful for server-rendered pages, static sites, or documents saved to disk.
Example persisted markup:
<generate-embedding generated vector="[0.0123,0.0456,...]">
A cat naps in the sun.
</generate-embedding>
The vector must be a valid 384-dim array and the text must be non-empty. As soon as the user edits the text, the generated marker is removed and a fresh embedding is computed.
Step 5 — Configure the worker and backend
By default the component loads /generate-embedding-worker.js, /models/, and /ort/ from the same origin. You can override these with attributes:
<generate-embedding
worker-url="/my-worker.js"
model-path="/assets/models/"
ort-path="/assets/ort/"
backend="wasm"
debounce="500"
contenteditable="true"
>
Edit me.
</generate-embedding>
| Attribute | Default | Purpose |
|---|---|---|
worker-url |
/generate-embedding-worker.js |
Override the worker script URL. |
model-path |
/models/ |
Base URL for locally served model files. |
ort-path |
/ort/ |
Base URL for ONNX Runtime wasm files. |
backend |
auto | wasm or webgpu to force a backend. |
debounce |
300 |
Debounce interval in ms for text changes. |
Use backend="wasm" when testing in headless environments or when WebGPU is unreliable. The Playwright tests force WASM with ?embedBackend=wasm.
Step 6 — Run the tests
The project has two test suites.
Fast unit tests (worker protocol, lifecycle, refcounting) with Vitest:
npm run test:unit
End-to-end tests with Playwright, including memory profiling:
npx playwright install chromium # first time only
npm test
Run tests against the production build:
npm run test:prod
Step 7 — Build for production
npm run build
This produces dist/ with:
main.min.js— the bundled app (custom element + CSS).generate-embedding-worker.js— the embedding worker.chunks/— any dynamic chunks.models/andort/— copied frommodels/andort/if they were downloaded.
Serve dist/ from any static host. Make sure models/ and ort/ are served at the paths configured by model-path and ort-path. If they are missing, the component falls back to the Hugging Face hub.
Events reference
| Event | Detail | When |
|---|---|---|
EMBEDDING-STATUS |
{ backend, dtype, model, ready, dims } |
Model pipeline is ready. |
EMBEDDING-PROGRESS |
Transformers.js progress payload | Model download / load progress. |
EMBEDDING-RESULT |
{ text, vector, duration, cached } |
New or restored embedding. |
EMBEDDING-ERROR |
{ error } |
Initialization or inference failed. |
How it works
src/generate-embedding.jsdefines the custom element and observesinnerText.src/lib/embeddings.jsmanages a shared, reference-counted worker.src/embed-worker.jsruns the model in a web worker so the UI thread never blocks.scripts/download-model.jsdownloads model weights and ORT binaries at install time.- Webpack bundles the main app and the worker as separate entries.
When the last <generate-embedding> element is removed from the DOM, the worker is terminated and the model runtime is freed from memory.
Next steps
- Store embeddings in IndexedDB or on a server to avoid recomputation.
- Build a recommendation UI by clustering similar sentences.
- Combine with vector search libraries such as
usearchorhnswlibfor larger corpora.
License
Unlicense — see package.json.