Embeddings and semantic search
createTextEmbedder() turns text into vectors with a sentence-embedding model, all-MiniLM-L6-v2 by default (~23 MB, 384 dimensions). Vectors are mean-pooled and L2-normalised, so cosine similarity is a dot product.
import { createTextEmbedder } from 'ngx-transformers';
readonly embedder = createTextEmbedder(); // Xenova/all-MiniLM-L6-v2
const vectors = await this.embedder.embed(['one', 'two']); // number[][], one vector per text
const score = await this.embedder.similarity('car', 'automobile'); // ~0.8, in [-1, 1]
const ranked = await this.embedder.rank('how do I make my app faster?', docs);
// [{ text: 'Use trackBy and virtual scrolling...', score: 0.28, index: 2 }, ...]Try it: semantic search story.
API
embed(texts): one string or an array; always resolves tonumber[][].embed([])resolves to[]without loading the model.similarity(a, b): cosine similarity of two texts.rank(query, documents): embeds the query and the documents in one batch and resolves toRankedResult[](text,score,index) sorted by similarity, best first.cosineSimilarity(a, b)is exported on its own for vectors you already have.
Semantic search in a component
@Component({
/* ... */
})
export class SearchComponent {
readonly embedder = createTextEmbedder();
readonly results = signal<RankedResult[]>([]);
readonly documents = ['Enable OnPush change detection.', 'Use trackBy for long lists.' /* ... */];
async search(query: string) {
this.results.set(await this.embedder.rank(query, this.documents));
}
}rank() re-embeds the documents on every call, which is fine for a few hundred short texts. For a larger corpus, embed the documents once and keep the vectors; see the next section.
Search over a large corpus
For more than a few hundred documents, embed once and keep the vectors in an index. Two zero-dependency packages that run in the browser fit here: chunklet splits documents into chunks that keep exact source offsets and heading breadcrumbs, and minivec is an HNSW vector store with JSON persistence.
import { chunkMarkdown } from 'chunklet';
import { MiniVec } from 'minivec';
import { createTextEmbedder } from 'ngx-transformers';
readonly embedder = createTextEmbedder(); // 384-dimensional vectors
readonly index = new MiniVec<{ text: string; headings?: string[] }>({ dim: 384 });
async ingest(markdown: string) {
const chunks = chunkMarkdown(markdown, { maxTokens: 256 });
const vectors = await this.embedder.embed(chunks.map((chunk) => chunk.text));
chunks.forEach((chunk, i) =>
this.index.add(`chunk-${chunk.index}`, vectors[i], {
text: chunk.text,
headings: chunk.meta?.headings,
}),
);
}
async search(query: string) {
const [vector] = await this.embedder.embed(query);
return this.index.search(vector, { k: 5 }); // [{ id, score, meta: { text, headings } }]
}index.toJSON() snapshots the store, graph included, for IndexedDB; MiniVec.fromJSON() restores it without re-embedding. Run the embedder in a Web Worker to keep the page responsive while ingesting. browser-rag is the complete pipeline: chunklet, ngx-transformers and minivec, fully in the browser.
Other embedding models
| Model | Dimensions | Notes |
|---|---|---|
Xenova/all-MiniLM-L6-v2 (default) | 384 | Fast, English, a good general-purpose baseline |
Xenova/bge-small-en-v1.5 | 384 | Higher retrieval quality in the same size class |
Xenova/multilingual-e5-small | 384 | About 100 languages; prefix texts with query: or passage: as the model card asks |
Vectors from different models are not comparable; re-embed everything when you switch.