Two sentences, "How do I reset my password?" and "I forgot my login credentials and cannot sign in.", each cut into WordPiece tokens and each drawn as the first 24 of its 384 embedding values as signed bars, with the cosine similarity 0.669 computed underneath.
The whole mechanism in one picture. The tokenizer lowercases and cuts each sentence into word pieces, "login" becomes "log" and "##in". Six transformer layers turn every piece into 384 numbers, the mean over the pieces is the sentence vector, and it is scaled to length 1. The cosine of two unit vectors is their dot product, so the score is 384 multiplications and a sum.
384 dimensions·6 layers, 22.7M parameters·23 MB as int8·256 word pieces, then cut

What the number means

Cosine similarity runs from -1 to 1 and says how small the angle between two vectors is. With sentence embeddings the useful range is narrower, because two unrelated sentences do not point in opposite directions, they point in unrelated ones, and unrelated in 384 dimensions is close to perpendicular. That is why "The cat sat on the mat." against "Quarterly revenue rose by 12 percent." scores 0.003 rather than something negative.

The bands the tool prints are ours, read off the pairs below. They fit all-MiniLM-L6-v2 and no other model.

PairCosineWord overlapReading
Paris is the capital of France. / The capital of France is Paris.0.985100%near-duplicate
The cat sat on the mat. / The mat sat on the cat.0.966100%near-duplicate, and wrong
Delete the user account / Do not delete the user account0.93567%near-duplicate, and wrong
How do I reset my password? / I forgot my login credentials and cannot sign in.0.66915%related, and a true paraphrase
The cat sat on the mat. / A feline rested on the rug.0.53622%related
How do I reset my password? / How do I reset my router?0.50871%related
Paris is the capital of France. / Berlin is the capital of Germany.0.34950%loosely related
Die Katze sitzt auf der Matte. / The cat sits on the mat.0.1400%unrelated, and wrong
The cat sat on the mat. / Quarterly revenue rose by 12 percent.0.0030%unrelated

Two things stand out. The password row is the one the model is for. Two sentences that share two words out of thirteen and mean the same request land at 0.669. The router row is its mirror, five words out of seven in common and a different request, at 0.508. A word-overlap check ranks those two rows the wrong way round, and every keyword search does the same.

The second thing is the feline. A paraphrase that swaps every content word lands at 0.536, in the same band as the router. We expected higher. The model has seen "cat" next to "feline" often enough, but "rested on the rug" is far from "sat on the mat" in its training data, and a 22-million-parameter model does not have room for every synonym. all-mpnet-base-v2 puts the same pair at 0.687, and drops the negation pair to 0.879, so the bigger model is better on both counts without being right on either. The lesson is that a threshold of 0.8 will miss real paraphrases, and a threshold of 0.5 will accept the router. There is no setting that gets both, only a choice of which error you prefer.

From a sentence to 384 numbers

The model is a BERT-style transformer with six layers, fine-tuned by the sentence-transformers project on 1.17 billion sentence pairs with a contrastive objective. Given one sentence of a pair, it had to pick the partner out of a batch of a thousand candidates. Whatever geometry makes that task solvable is what the vectors encode. The full training recipe is on the model card of sentence-transformers/all-MiniLM-L6-v2.

Three steps happen between the text field and the score, and the tool draws the result of each.

  1. The tokenizer lowercases the text and cuts it into WordPiece tokens from a 30,522-entry English vocabulary. Common words stay whole, rare ones split: "login" becomes "log" and "##in", "unbelievably" becomes "un", "##bel", "##ie", "##va", "##bly". A [CLS] token is added at the front and [SEP] at the end, which is why a six-word sentence shows eight or nine pieces.
  2. The transformer turns every piece into a 384-dimensional vector, with the context of the whole sentence mixed in. "reset" in "reset my password" and "reset" in "reset my router" come out different.
  3. The piece vectors are averaged into one sentence vector, mean pooling, and that vector is scaled to length 1. On unit vectors the cosine is the plain dot product, so the score is 384 multiplications and a sum. The third strip in the tool shows those 384 products. Where both vectors agree in sign the product is positive and pushes the score up, where they disagree it pulls down.

Because of the mean in step three, each value of the final vector is small, typically between -0.15 and 0.15, and no single dimension means anything on its own. Dimension 17 is not "about passwords". Meaning lives in the direction of the whole vector, which is why the strips of two similar sentences look alike as patterns without matching cell for cell.

Where the model is confidently wrong

Three rows in the table above carry the label "and wrong", and each one is a class of failure rather than a single bad pair.

Negation is the famous one. "Delete the user account" and "Do not delete the user account" score 0.935, because the embedding is a mean over eight token vectors and the two extra tokens move that mean very little. The model encodes what a sentence is about, and both sentences are about deleting an account. The same holds for "I love this product" against "I hate this product" at 0.692, and for any pair where the meaning hinges on one word. Nothing built on a bag of token vectors will catch this, and a cross-encoder or a natural-language-inference model is the tool for contradiction.

Word order is the second. "The cat sat on the mat." and "The mat sat on the cat." score 0.966. Position embeddings exist in the model, but for sentence similarity the training signal rarely rewarded telling these apart, so it did not learn to.

Language is the third. The German sentence and its English translation score 0.140, below "Paris" against "Berlin". The vocabulary is English, so "Katze" is cut into pieces the model has only seen in English words, and the pieces mean nothing. A multilingual model puts that pair above 0.8.

There is a quieter fourth. "Python is a programming language." against "Pythons are large snakes." scores 0.524, comfortably in the related band, because the surface form dominates when the sentences are short. Short inputs have few tokens to average, and the shared rare word weighs more than the difference in meaning. The model works best on full sentences with some context, worse on two-word titles and single keywords.

Finding duplicates in a list

Switch the tool to one per line and every pair of lines is scored, which is n·(n-1)/2 cosines. Fourteen support tickets are 91 pairs, five hundred lines are 124,750, and the arithmetic is the cheap part. The embedding is what costs, one model call per line, and the page caches every vector, so pasting a longer version of the same list only embeds the new lines.

On the fourteen sample tickets the model pairs "Cannot log in after resetting my password" with "I reset my password and now I cannot sign in" at 0.89, and the two VAT complaints at the same score. The two settings crashes come in at 0.78 and the two CSV export requests at 0.77, both under a threshold of 0.80 and both real duplicates. Lower the threshold to 0.75 and they appear, and so does nothing false, on this list. The one it misses outright is the pair about the cancelled subscription and the repeated charge, at 0.44, the same complaint in two vocabularies. That is the class of miss to expect from a 23 MB model, and the reason to read the matrix rather than only the list.

The full matrix is drawn up to 40 lines and the hover shows every value, which is faster for finding a threshold than any formula. Past a few thousand items the full matrix stops being practical, and that is where approximate nearest-neighbour indexes such as HNSW take over, in FAISS, pgvector or a hosted vector database. They return the top matches for each item without scoring every pair, at the cost of occasionally missing one.

Every pair with its score can be downloaded as CSV, sorted from most to least similar.

The same score in Python and JavaScript

sentence-transformers is the reference. The number it prints for the negation pair is the fp32 counterpart of the 0.935 the tool shows.

$ python -c "from sentence_transformers import SentenceTransformer, util; m = SentenceTransformer('all-MiniLM-L6-v2'); a, b = m.encode(['Delete the user account', 'Do not delete the user account']); print(util.cos_sim(a, b).item())"
0.9458737373352051
python 3.14.7 · sentence-transformers 6.1.0 · torch 2.14.0 · macos 26.6.2

The JavaScript version uses the same ONNX export the page runs, through transformers.js, in Node or in the browser:

import { pipeline } from '@huggingface/transformers';

const embed = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
const [a, b] = await Promise.all(['Delete the user account', 'Do not delete the user account']
	.map((t) => embed(t, { pooling: 'mean', normalize: true })));
const cosine = a.data.reduce((sum, x, i) => sum + x * b.data[i], 0);

Two details matter. The pooling and normalize options are what turn token vectors into a sentence vector, and leaving them off returns a matrix of token vectors instead. And dtype q8 picks the 23 MB file, while the default in Node is the 90 MB fp32 model.

For numpy without a framework, cosine is np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)), and for a whole list at once util.paraphrase_mining(model, sentences) returns every pair above zero, sorted.

A table of six sentence embedding models with their vector size, parameter count, maximum input length in tokens, language coverage and weight file size: all-MiniLM-L6-v2, all-MiniLM-L12-v2, bge-small-en-v1.5, all-mpnet-base-v2, paraphrase-multilingual-MiniLM-L12-v2 and multilingual-e5-small.
The model on this page is the smallest of the family and the only one that fits a browser tab without a wait. all-mpnet-base-v2 is the usual upgrade for English at four times the size, and anything not in English needs one of the two multilingual models, which cost twenty times the download for the vocabulary alone. Scores from different models are not comparable: a threshold tuned on one is meaningless on another.

Which embedding model

all-MiniLM-L6-v2 is on this page because it is the smallest model that produces usable sentence vectors, and small is the only way a model runs in a tab without a wait. It is not the best model, and the sentence-transformers authors say so themselves. Their default recommendation for English quality is all-mpnet-base-v2, twelve layers and 768 dimensions at 438 MB, which scores a few points higher on their benchmarks and takes about five times as long per sentence.

The token limit is the other axis. MiniLM-L6 reads 256 word pieces and was trained on 128, and paraphrase-multilingual-MiniLM-L12-v2 stops at 128 outright. bge-small-en-v1.5 and multilingual-e5-small read 512 at the same 384 dimensions, which makes them the better choice for paragraphs. The e5 models want a "query: " or "passage: " prefix in front of every text and lose quality without it.

Hosted embeddings from OpenAI, Cohere or Voyage produce longer vectors, 1024 to 3072 dimensions, from far larger models, and they are better on every benchmark that matters. They also mean every text leaves your machine, which is the trade this page exists to avoid. We take the small local model for duplicate detection on our own data and for anything under NDA, and it costs us the last few benchmark points. The cases it gets wrong are the cases listed above, and no model of any size gets those right by embedding alone.

One rule holds across all of them. Scores from two models are not on the same scale, so a threshold tuned for one is a random number for another.

Vectors, scores and thresholds

What is a good cosine similarity threshold for duplicate detection?

For all-MiniLM-L6-v2, 0.90 catches the same sentence reworded and 0.80 catches the same request phrased differently, while 0.70 already pulls in sentences that only share a topic. The number is model-specific. The same pair scores differently under bge-small or an OpenAI embedding, so a threshold has to be tuned on the model that produced the vectors, ideally by scoring a few hundred known pairs and reading the value where the false positives start.

Can cosine similarity be negative?

Yes, down to -1 in theory. With sentence embeddings it rarely drops below -0.1, and anything under 0.3 reads as unrelated with this model.

Cosine similarity, dot product or euclidean distance: which one for embeddings?

They rank pairs identically once the vectors are normalised to length 1, which sentence-transformers does by default. The dot product of two unit vectors is their cosine, and the euclidean distance is sqrt(2 - 2·cos), a monotonic function of it. Vector databases prefer the dot product because it is one multiply-add per dimension without a square root, so store normalised vectors and use whichever metric the index supports. Only with unnormalised vectors do the three disagree, and then cosine is the one that ignores text length.

How do I compute cosine similarity in Python?

With numpy: np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)). With sentence-transformers: util.cos_sim(a, b). With scikit-learn: cosine_similarity([a], [b])[0][0].

Does all-MiniLM-L6-v2 work for German, French or Spanish text?

No. It was trained on English pairs and its tokenizer is the English BERT vocabulary, so a German sentence and its English translation score 0.14 here. Use paraphrase-multilingual-MiniLM-L12-v2 or multilingual-e5-small for other languages.

How many tokens can all-MiniLM-L6-v2 handle?

256 word pieces, roughly 180 to 200 English words. sentence-transformers truncates silently at that length, and the model was fine-tuned on 128, so quality drops before the limit. The tool above shows the piece count for each text and says when a text was cut. For longer documents, split into paragraphs, embed each, and either compare the best-matching pair or average the vectors.

Why does adding "not" barely change the similarity score?

Because a sentence embedding is a mean over the token vectors, and one short token among eight moves that mean very little. "Delete the user account" and "Do not delete the user account" score 0.94 with this model. Embeddings measure what a sentence is about, not whether it affirms or denies it. Anything where polarity matters, such as sentiment, contradiction or instructions, needs a classifier or a cross-encoder on top.

Can sentence embeddings run in the browser without a server?

Yes. transformers.js runs the ONNX export of all-MiniLM-L6-v2 through WebAssembly, 23 MB as int8, and a short sentence embeds in a few tens of milliseconds on a laptop CPU. Put the model in a web worker so the page stays responsive while it loads, and keep the files in the Cache API so the second visit skips the download. Larger models such as bge-base or all-mpnet-base-v2 also run, at 100 to 440 MB, which is where WebGPU starts to matter.

What is the difference between all-MiniLM-L6-v2 and all-mpnet-base-v2?

Size and quality. MiniLM-L6 has 6 layers, 384 dimensions and 22.7M parameters, mpnet-base has 12 layers, 768 dimensions and 110M parameters, so it is about five times slower and about five times the download. On the sentence-transformers benchmarks mpnet scores a few points higher on retrieval and semantic textual similarity. MiniLM is the choice when latency, memory or a browser matters, mpnet when a server is doing the work and quality is the point.

Is cosine similarity the same as semantic similarity?

No. Cosine is the arithmetic, semantic similarity is what the embedding model was trained to encode in the angle. A bad model gives a precise cosine of a meaningless number.

How do I find duplicate sentences or tickets in a CSV file?

Embed every row, compute the cosine for every pair, and keep the pairs above a threshold. In Python that is model.encode(rows) followed by util.paraphrase_mining(model, rows), which returns the pairs sorted by score. For a few thousand rows the full pairwise matrix is fine in memory. Beyond that, use an approximate nearest-neighbour index such as FAISS or HNSW in a vector database. Paste the column into the tool above, one row per line, and it lists the pairs above the threshold and offers every pair as CSV.

Mean pooling or CLS pooling for sentence embeddings?

Whichever the model was trained with. all-MiniLM-L6-v2 and most sentence-transformers models use mean pooling over the token vectors, and taking their [CLS] vector instead gives visibly worse similarity scores. bge and e5 were trained with CLS pooling. The pooling mode is in 1_Pooling/config.json of a sentence-transformers repo, so read it there instead of guessing.

How do I compare two texts semantically in JavaScript?

Install @huggingface/transformers, create a feature-extraction pipeline with Xenova/all-MiniLM-L6-v2, embed both texts with pooling set to mean and normalize set to true, and take the dot product of the two arrays. The snippet is in the code section above and runs in Node and in the browser alike.