A user types "laptop" into your search bar and gets an empty state. Your database contains hundreds of articles about "notebooks". Different strings, identical intent, zero matches. This is the hard ceiling of lexical search: traditional token matching evaluates character sequences rather than meaning. It cannot infer that "buy" and "purchase" denote identical operations, or that "shipping" and "delivery" refer to the same logistical state.
Semantic search bypasses this lexical limit by comparing conceptual vectors directly. Using Transformers.js, you can execute this entire pipeline inside the browser runtime: zero servers, zero API tokens, and zero outbound network traffic once the model weights load.
What Sentence Embeddings Represent
Transformer models cannot compute over raw strings. Text must first be transformed into a numerical representation. An embedding is the final output of this conversion: a sequence mapped into a dense array of floating-point values known as a vector.
The underlying mechanism relies on geometric proximity. Sentences with similar conceptual semantics are mapped to coordinates that sit physically close within a shared vector space.
The default model for this pipeline, all-MiniLM-L6-v2, projects any input string into a 384-dimensional vector space. Fine-tuned on the SNLI and MultiNLI datasets, the network optimizes these geometric coordinates so that phrases like "fast shipping" and "cheap delivery options" become immediate spatial neighbors. Conversely, an unrelated phrase like "car engine repair" is projected far across the space.
"fast shipping" --> [ 0.042, -0.121, ..., 0.089 ] \
Spatial Neighbors (High Similarity)
"cheap delivery options" --> [ 0.039, -0.118, ..., 0.095 ] /
"car engine repair" --> [-0.512, 0.801, ..., -0.344 ] --> Distant Vector
Individual dimensions are not human-interpretable; dimension 47 does not correspond to an isolated property. For information retrieval, only the distance between vectors matters. A short vector distance indicates semantic alignment, while a large distance indicates unrelated content.
Mean Pooling and Unit Normalization
By default, raw Transformer architectures emit one vector per token. Every word or subword receives its own coordinate. To perform sentence-level retrieval, you must collapse those token vectors into a single sentence vector.
Mean pooling achieves this by calculating the average of all token vectors, weighted against the attention mask to ensure padding tokens do not alter the calculation. Unit normalization scales the resulting composite vector to a magnitude of 1, drastically simplifying subsequent similarity math.
In Transformers.js, passing { pooling: 'mean', normalize: true } handles both operations automatically:
javascriptimport { pipeline } from '@xenova/transformers'; // Downloads and initializes the model on first run (cached by the browser thereafter) const extractor = await pipeline( 'feature-extraction', 'Xenova/all-MiniLM-L6-v2', { progress_callback: console.log } ); // Generates a single, normalized 384-dimensional vector const output = await extractor( 'This is a sample sentence.', { pooling: 'mean', normalize: true } ); const embedding = output.data; // Float32Array of 384 values
Without these options, the pipeline returns unpooled, token-level tensors. Those are useful for named entity recognition, but they cannot be used for sentence-level semantic search.
Tensor Outputs and Batched Ingestion
The Tensor instance returned by the feature-extraction pipeline exposes three primary properties:
dims:[1, 384]for a single sentence, or[N, 384]for an $N$-length batch. The second dimension is locked to 384 for this model architecture.type:'float32', confirming that each value is a standard 32-bit float.data: The underlying linear buffer stored in row-major order. A batch of 3 sentences forms a contiguous array of $3 \times 384 = 1,152$ numbers.
Calling output.tolist() transforms the tensor into nested JavaScript arrays, while output[0].data isolates the 384-number buffer for the first sentence.
Batching inputs is critical for throughput. Rather than issuing serial extractor invocations, passing an array of strings processes the entire set inside a single forward pass:
javascriptconst outputs = await extractor( [ 'I want to buy a new laptop.', 'Where can I get a car?', 'My package delivery is delayed.', 'How do I make a purchase online?' ], { pooling: 'mean', normalize: true } );
Because the Transformer architecture is optimized for parallel matrix math, embedding ten sentences in a single batched pass takes nearly the same time as embedding one. When indexing a document corpus, batching yields massive speedups over serial execution.
Cosine Similarity: The Math Behind Search
With your documents and search queries converted into coordinates, you score semantic alignment using cosine similarity. This measures the cosine of the angle between two vectors: 1.0 represents identical direction (equivalent meaning), while 0 represents orthogonality (unrelated concepts).
Because we enforced normalize: true, every vector already has a magnitude of 1. This reduces the cosine similarity formula to a simple dot product, requiring only element-wise multiplication and accumulation:
$$\text{Cosine Similarity} = \mathbf{A} \cdot \mathbf{B} = \sum_{i=1}^{n} A_i B_i$$
javascriptfunction cosineSimilarity(vec1, vec2) { let sum = 0; for (let i = 0; i < vec1.length; i++) { sum += vec1[i] * vec2[i]; } // Clamping guards against float precision artifacts yielding values like -0.000000001 return Math.max(0, sum); }
Scores for normalized sentence embeddings generally follow these ranges:
| Score Range | Similarity Meaning |
|---|---|
| 0.8 - 1.0 | Near-duplicate meaning |
| 0.6 - 0.8 | Very similar |
| 0.4 - 0.6 | Related |
| 0.2 - 0.4 | Loosely related |
| 0.0 - 0.2 | Unrelated |
The SemanticSearch Architecture
Production semantic retrieval decouples ingestion from search time:
indexDocuments(documents): Accepts an array of objects containing atextproperty, batches the text through the extractor, and stores the resulting vectors alongside the document metadata.search(query): Runs one single forward pass to generate the query vector (typically under 100ms), iterates across the cached index, runscosineSimilarity, and sorts the result set.save()andload(): Serializes vector arrays to avoid re-generating static data across browser sessions.
Because scoring cached vectors is pure linear algebra in JavaScript, the retrieval loop completes in sub-millisecond time.
[In-Memory Index] (Cached 384-dim Vectors)
│
├─ Cosine Scoring Loop ◄── Query Vector (Generated in ~100ms)
│ (Sub-millisecond)
▼
[Ranked Search Results]
Offloading Execution to a Web Worker
Executing Transformer inference directly on the main thread is a non-starter for production applications. Heavy tensor operations block UI updates, freezing inputs, scrolling, and CSS animations.
Web Workers isolate model execution in a background thread. Communication is coordinated through message passing and correlation identifiers:
javascript// main.js const worker = new Worker('worker.js', { type: 'module' }); const promises = new Map(); let requestId = 0; worker.onmessage = (event) => { const { id, type, data } = event.data; if (type === 'progress') { console.log(data); // Feed progress directly to the UI } else if (type === 'result') { promises.get(id)?.resolve(data); promises.delete(id); } }; async function embedInWorker(sentences) { const id = requestId++; return new Promise((resolve, reject) => { promises.set(id, { resolve, reject }); worker.postMessage({ id, type: 'embed', data: sentences }); }); }
javascript// worker.js import { pipeline } from '@xenova/transformers'; let extractor = null; self.onmessage = async (event) => { const { id, type, data } = event.data; if (type === 'embed') { if (!extractor) { extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { progress_callback: (status) => self.postMessage({ id, type: 'progress', data: status }) }); } const output = await extractor(data, { pooling: 'mean', normalize: true }); self.postMessage({ id, type: 'result', data: output.data }); } };
This singleton pattern ensures the pipeline is initialized only once. The message id acts as a correlation key, ensuring concurrent async inference calls resolve to their corresponding promises.
Storage and Scale Profiles
Generating embeddings is computationally expensive, so persistent caching is essential for repeat visits:
localStorage: Stores roughly 5 MB depending on the engine. A 12-document collection with 384-dimensional floating-point vectors consumes roughly 200 KB when serialized to JSON.IndexedDB: Provides high-capacity, structured client-side storage for larger datasets without strict 5 MB boundaries.
Linear scans using JavaScript loops operate smoothly up to a few hundred documents. When scaling beyond that threshold, brute-force scoring introduces latency. For larger client-side corpora, running an in-browser PostgreSQL instance with the pg_embedding extension provides approximate nearest neighbor indexing entirely within the browser sandbox.
Model Selection Matrix
Choosing a model depends on your target language coverage and performance requirements:
| Model | Profile | Use Case |
|---|---|---|
Xenova/all-MiniLM-L6-v2 | Fast, lightweight footprint | Best default for English retrieval workflows |
Xenova/multilingual-e5-base | Multilingual support, cross-lingual alignment | Mixed-language corpora (e.g., matching English queries to French or German documents) |
With Xenova/multilingual-e5-base, cross-lingual queries work out of the box. The model maps shared concepts across languages into the same vector space, so an English query naturally surfaces matching French or German documents without a separate translation step.
The entire architecture requires just four clear stages: initialize the pipeline, batch-embed the index, compute the query vector, and rank using cosine similarity. The math and mechanics you use here form the foundation for client-side recommendations, deduplication systems, and retrieval-augmented generation.
