Skip to content

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.

ts
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 to number[][]. 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 to RankedResult[] (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

ts
@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.

ts
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

ModelDimensionsNotes
Xenova/all-MiniLM-L6-v2 (default)384Fast, English, a good general-purpose baseline
Xenova/bge-small-en-v1.5384Higher retrieval quality in the same size class
Xenova/multilingual-e5-small384About 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.

MIT licensed. Models come from the Hugging Face Hub under their own licenses; check the one you ship.