Embedding and Semantic Similarity

If you have ever searched a document by keyword and missed the answer because the wording was different, you have already felt the limitation of exact text matching. Embeddings solve this by converting text into numbers that capture meaning. Semantic similarity then measures how close those meanings are, even when the words differ.
This post walks through that pipeline with concrete Python examples:
- Word embeddings with GloVe (classic, static vectors)
- Sentence/chunk embeddings with modern models via Ollama
- Semantic similarity with cosine similarity on sample text
The running example is a Wikipedia-style article about Southern Methodist University (SMU), stored in SMU_Wiki.txt.
What Is an Embedding?
An embedding is a numeric representation of text. Instead of storing a sentence as words, we convert it into a vector — a list of numbers — that captures its meaning. Texts with similar meaning usually end up close together in this vector space.
Consider these two sentences. They share the word bank, but the meaning is different:
- “We had a picnic on the river bank.”
- “She works at a major banking company.”
A keyword search for “bank” matches both. An embedding-based system can place them in different regions of vector space, because the surrounding context differs.
Embeddings are the foundation of modern NLP applications including semantic search, retrieval-augmented generation (RAG), clustering, recommendation, and duplicate detection.
Word Embedding
A word embedding maps each word (or subword token) to a dense vector. The model learns these vectors from large text corpora so that words appearing in similar contexts receive similar vectors.
Word embeddings are useful for:
- Capturing local lexical relationships (synonyms, morphology, related concepts)
- Understanding word sense in a static way (one vector per word type)
- Classical NLP tasks: text classification features, NER, POS tagging
- Teaching the core idea before moving to contextual and sentence-level embeddings
Classic examples include Word2Vec, GloVe, and fastText.
Example: GloVe (glove-wiki-gigaword-50)
GloVe (Global Vectors for Word Representation) learns word vectors from word co-occurrence statistics in a large corpus. The model name breaks down as:
| Part | Meaning |
|---|---|
glove | GloVe algorithm |
wiki-gigaword | trained on Wikipedia 2014 + Gigaword 5 |
50 | each word is a 50-dimensional vector |
Load the model with Gensim:
import gensim.downloader as api
model = api.load("glove-wiki-gigaword-50")
model["bank"]
This returns a 50-number vector for the word bank:
array([ 0.66488 , -0.11391 , 0.67844 , 0.17951 , 0.6828 , -0.47787 ,
-0.30761 , 0.17489 , -0.70512 , -0.55022 , 0.1514 , 0.10214 ,
... 50 values total ...], dtype=float32)
Each number is a learned feature. We rarely interpret individual dimensions by hand; what matters is the overall direction of the vector relative to other words.
Comparing word meanings
model.similarity("bank", "river") # 0.414
model.similarity("bank", "credit") # 0.765
| Pair | Similarity | Interpretation |
|---|---|---|
bank vs river | 0.41 | Related (river bank), but different domain |
bank vs credit | 0.76 | Stronger match in the financial sense |
Even though both pairs contain the word bank, the embedding space tells us which neighbor is closer in meaning.
Find the nearest words to bank:
model.most_similar([model["bank"]], topn=5)
[('bank', 1.00),
('banks', 0.87),
('securities', 0.80),
('banking', 0.80),
('investment', 0.78)]
This is a clear financial cluster. GloVe captured domain association from co-occurrence patterns in training data.
Limitation of static word embeddings
GloVe assigns one vector per word, regardless of context. The word bank has a single vector whether you mean a river bank or a financial institution. Modern transformer models produce contextual embeddings where the same word gets different vectors depending on surrounding text. Sentence-level embedding models (covered next) build on this idea at the document level.
Sentence and Chunk Embedding
A sentence embedding (or chunk embedding) maps an entire sentence or text passage to one fixed-length vector.
| Word embedding | Sentence embedding | |
|---|---|---|
| Input unit | single word/token | sentence, paragraph, or chunk |
| Output | one vector per word | one vector per text |
| Typical use | lexical similarity, classical NLP | semantic search, RAG, clustering |
| Example | GloVe, Word2Vec | embeddinggemma, mxbai, nomic, qwen3-embedding |
Sentence embeddings power:
- Semantic search — find passages by meaning, not exact keywords
- RAG — retrieve relevant context before generation
- Clustering — group similar documents
- Duplicate detection — find near-duplicate content
- Recommendation — match items by semantic profile
In this post, we compare several popular embedding models using Ollama to embed text from an SMU Wikipedia-style article.
Loading the sample document
with open("SMU_Wiki.txt", "r", encoding="utf-8") as f:
text = f.read()
The file describes SMU’s history, academics, campus, athletics, and enrollment. It is long enough to illustrate an important practical issue: context length limits.
A quick word count:
text.split(" ")
len(text.split(" "))
# 2861 words (approximate; token count will differ)
Words are not tokens. Tokenizers split text into subword pieces, so token count is usually different from word count. For embedding models, context length is measured in tokens, not characters or words.
Comparing Popular Embedding Models
Before embedding, it helps to compare model properties. These four families are widely used in 2025–2026 workflows:
| Model | Developer | Parameters | Embedding dim | Context length |
|---|---|---|---|---|
| embeddinggemma | 300M | 768 | 2K | |
| mxbai-embed-large | mixedbread-ai | 335M | 1024 | 512 |
| nomic-embed-text | nomic-ai | 137M | 768 | 8K |
| qwen3-embedding | Alibaba/Qwen | 0.6B / 4B / 8B | 1024 / 2560 / 4096 | 32K / 40K / 40K |
What each column means
- Number of parameters: model size (learned weights). Larger models are often more accurate but need more memory and compute.
- Embedding dimension: length of the output vector (
len(embedding)). This is not the same as context length. - Context length: maximum number of input tokens per embedding call. Longer context supports larger chunks; shorter context requires splitting documents first.
Critical distinction: embedding dim vs context length
These are often confused. Example with mxbai-embed-large on Ollama:
| Property | Value | Meaning |
|---|---|---|
| Embedding dimension | 1024 | output vector has 1024 numbers |
| Context window | 512 | input limited to 512 tokens |
So len(emb_mxbai) == 1024 does not contradict a 512-token context limit. You always get a 1024-length vector; the model just reads at most 512 tokens of input.
Implication for our SMU text
The SMU article is roughly ~3000 tokens (and ~2861 words). If we embed the entire document in one call:
- embeddinggemma (2K context) → truncates to first ~2048 tokens
- mxbai-embed-large (512 context) → truncates to first 512 tokens
- nomic-embed-text (8K context) → likely fits the whole document
- qwen3-embedding (32K–40K) → easily fits the whole document
For mxbai and other short-context models, chunk the document before embedding.
Embedding with Ollama
Ollama provides a simple local API for running embedding models with direct GPU support. We use LangChain’s wrapper:
from langchain_ollama import OllamaEmbeddings
embedder_mxbai = OllamaEmbeddings(model="mxbai-embed-large:335m")
embedder_gemma = OllamaEmbeddings(model="embeddinggemma:300m")
Important: OllamaEmbeddings is not callable like a function. Use .embed_query() for a single string:
emb_mxbai = embedder_mxbai.embed_query(text)
emb_gemma = embedder_gemma.embed_query(text)
print(len(emb_mxbai)) # 1024
print(len(emb_gemma)) # 768
Each model returns one vector for the entire input (subject to context truncation):
| Model | Output shape | Notes |
|---|---|---|
| mxbai-embed-large | (1024,) | 512-token input limit |
| embeddinggemma | (768,) | 2048-token input limit |
Can you run mxbai on Hugging Face with GPU?
Yes. Ollama is convenient, but not required:
import torch
from sentence_transformers import SentenceTransformer
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1", device=device)
emb = model.encode("The sky is blue because of Rayleigh scattering")
print(len(emb)) # 1024
Semantic Similarity
Semantic similarity measures how close two texts are in meaning, not in exact wording.
We already saw this at the word level with GloVe:
-
bankvsriver→ 0.41 -
bankvscredit→ 0.76
At the sentence level, the idea is the same: convert each text to a vector, then measure closeness.
Cosine similarity
The most common metric is cosine similarity. It measures the angle between two vectors, ignoring magnitude:
\[\text{cosine}(a, b) = \frac{a \cdot b}{\|a\| \|b\|}\]Interpretation:
| Value | Meaning |
|---|---|
| 1.0 | very similar meaning (same direction) |
| 0.0 | unrelated (orthogonal) |
| -1.0 | opposite meaning (rare in practice) |
Rules when comparing embeddings
- Compare within the same model when ranking passages (gemma with gemma, mxbai with mxbai).
- Do not subtract vectors of different dimensions (
emb_gemmais 768-dim,emb_mxbaiis 1024-dim). - Respect context limits — truncate or chunk long documents before embedding.
- You cannot reverse an embedding to text — vectors are a one-way semantic summary.
Hands-on example: paraphrase vs unrelated sentence
import numpy as np
from langchain_ollama import OllamaEmbeddings
def cosine_similarity(a, b):
a = np.array(a)
b = np.array(b)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
embedder = OllamaEmbeddings(model="embeddinggemma:300m")
emb1 = embedder.embed_query("Southern Methodist University is in Dallas.")
emb2 = embedder.embed_query("SMU is located in Dallas, Texas.")
emb3 = embedder.embed_query("The moon orbits Earth.")
print("Similar sentences:", cosine_similarity(emb1, emb2))
print("Unrelated sentence:", cosine_similarity(emb1, emb3))
Results with embeddinggemma:
Similar sentences: 0.854
Unrelated sentence: 0.218
The paraphrased SMU sentences score 0.85 — high semantic similarity despite different wording. The unrelated astronomy sentence scores 0.22 — much lower. This is exactly the behavior semantic search relies on.
Summary
| Concept | What it is | Example in this post |
|---|---|---|
| Embedding | text → vector of numbers | GloVe bank → 50-dim vector |
| Word embedding | one vector per word (static) | GloVe: bank closer to credit than river |
| Sentence embedding | one vector per text chunk | mxbai → 1024-dim, gemma → 768-dim |
| Embedding dimension | output vector length | len(emb_mxbai) = 1024 |
| Context length | max input tokens | mxbai = 512 tokens |
| Semantic similarity | meaning closeness | paraphrase → 0.85, unrelated → 0.22 |
| Cosine similarity | standard comparison metric | angle between two vectors, -1 to 1 |
Three ideas worth carrying forward:
- Embeddings turn language into geometry. Similar meanings land near each other in vector space.
- Word and sentence embeddings solve different problems. GloVe teaches the intuition; modern models like embeddinggemma and mxbai power search and RAG.
- Context length and embedding dimension are different knobs. Always check both before embedding long documents.
Everything downstream — semantic search, RAG, clustering — is built on these vectors and the similarity scores between them.