DEV Community

Cover image for Vector Embeddings Explained: Build Semantic Search in Python
galian for Cursuri AI

Posted on

Vector Embeddings Explained: Build Semantic Search in Python

Search for "reset my password" in a keyword-based system and a help article titled "How to recover your account credentials" won't match — not one word overlaps. Yet any human knows they mean the same thing. Closing that gap between characters and meaning is what vector embeddings do, and they're the quiet engine behind semantic search, RAG, recommendation systems, and most of the "AI that understands you" experiences shipped since 2023.

This is a practical guide. We'll cover what an embedding actually is, why cosine similarity is the operation you'll use constantly, and then build a real, working semantic search engine in Python — first with pure NumPy so you see the mechanics, then with the tools you'd actually reach for in production. By the end you'll have code that runs and a mental model that transfers to every embedding-powered feature you build next.

What an embedding actually is

An embedding is a list of numbers — a vector — that represents a piece of content as a point in high-dimensional space. An embedding model (a neural network trained on enormous text corpora) reads your text and outputs, say, 384 or 1,536 floating-point numbers. The magic isn't the numbers themselves; it's the property the training instills: texts with similar meaning land close together in that space, and unrelated texts land far apart.

That's the whole idea. "How do I reset my password?" and "I forgot my login credentials" produce vectors that sit near each other. "What's the weather in Cluj?" produces a vector off in a completely different region. The model has effectively turned meaning into geometry — and geometry is something a computer can measure with plain arithmetic.

A few properties worth internalizing before we write code:

  • Dimensionality is fixed per model. A given model always outputs the same length (e.g. 384 for all-MiniLM-L6-v2, 1,536 for OpenAI's text-embedding-3-small). You can't mix vectors from different models — they live in different spaces.
  • The individual numbers are not interpretable. Dimension 200 doesn't mean "formality" or "topic." Meaning is distributed across all dimensions at once. Don't try to read them.
  • Distance is the entire point. You almost never care about a vector's absolute position — only how close it is to other vectors.

Cosine similarity: the one operation you'll use everywhere

To ask "how similar are these two texts?", you compare their vectors. The near-universal choice for text embeddings is cosine similarity: it measures the angle between two vectors, ignoring their magnitude.

Why the angle and not, say, straight-line (Euclidean) distance? Because for text embeddings, direction encodes meaning while length often encodes uninteresting things like text length or token count. Two documents about the same topic point the same way even if one is a sentence and the other a paragraph. Cosine similarity captures exactly that.

The formula is just the dot product of the two vectors divided by the product of their magnitudes:

cos(θ) = (A · B) / (‖A‖ · ‖B‖)
Enter fullscreen mode Exit fullscreen mode

It returns a value from -1 to 1, though for most modern text embeddings you'll see results land in roughly the 0-to-1 range:

  • ~1.0 — nearly identical meaning
  • ~0.5 — loosely related
  • ~0.0 — unrelated

That single number, computed against a corpus of stored vectors, is semantic search. Everything else is optimization.

Build a semantic search engine from scratch

Let's make it concrete. We'll use sentence-transformers, which runs a capable embedding model locally — no API key, no network calls, so you can run this offline right now.

pip install sentence-transformers numpy
Enter fullscreen mode Exit fullscreen mode

Step 1 — Embed a corpus

from sentence_transformers import SentenceTransformer
import numpy as np

# A small, fast, widely used model. 384-dimensional output.
model = SentenceTransformer("all-MiniLM-L6-v2")

documents = [
    "How to reset your account password",
    "Refund policy for annual subscriptions",
    "Setting up two-factor authentication",
    "Our office hours and contact details",
    "How to recover a locked account",
]

# Encode the whole corpus once. Shape: (5, 384)
doc_embeddings = model.encode(documents)
print(doc_embeddings.shape)  # (5, 384)
Enter fullscreen mode Exit fullscreen mode

That doc_embeddings array is your search index. In a real app you compute it once, when a document is created or updated, and store it — never on every query.

Step 2 — Cosine similarity in NumPy

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    """Cosine similarity between vector `a` and each row of matrix `b`."""
    a_norm = a / np.linalg.norm(a)
    b_norm = b / np.linalg.norm(b, axis=1, keepdims=True)
    return b_norm @ a_norm
Enter fullscreen mode Exit fullscreen mode

Ten lines, no dependencies beyond NumPy. This is the actual core of semantic search — the rest is plumbing.

Step 3 — Search

def search(query: str, k: int = 3):
    query_vec = model.encode(query)
    scores = cosine_similarity(query_vec, doc_embeddings)
    ranked = np.argsort(scores)[::-1][:k]  # top-k, highest first
    return [(documents[i], float(scores[i])) for i in ranked]

for doc, score in search("I forgot my login credentials"):
    print(f"{score:.3f}  {doc}")
Enter fullscreen mode Exit fullscreen mode

Run it, and you'll get something like:

0.62  How to reset your account password
0.55  How to recover a locked account
0.31  Setting up two-factor authentication
Enter fullscreen mode Exit fullscreen mode

Notice what happened: the query "I forgot my login credentials" shares zero words with "How to reset your account password," yet it ranked first. A keyword search would have returned nothing. That's the payoff — you matched on meaning, not on string overlap. This shift from lexical to semantic matching is the foundation every retrieval-augmented system builds on, and it's the starting point of a structured course on RAG and retrieval-augmented generation that goes from this toy index to production retrieval.

From toy to production: what changes

The NumPy version is perfect for learning and fine for a few thousand documents. Past that, three things force an upgrade.

You need a vector database

Computing cosine similarity against every stored vector on every query is O(n) — fine at 5 documents, painful at 5 million. Vector databases solve this with Approximate Nearest Neighbor (ANN) indexes (HNSW is the common one) that trade a sliver of accuracy for enormous speed, returning near-neighbors in milliseconds over huge corpora.

You have good open-source options:

  • pgvector — a Postgres extension. If your data already lives in Postgres, this is often the pragmatic choice: vectors and relational data in one place, one backup story.
  • Chroma / Qdrant / Weaviate / Milvus — purpose-built vector stores with richer filtering and scaling stories.
  • FAISS — a library (not a server) from Meta for fast similarity search when you want to manage the index yourself.

A minimal Chroma example shows how little the mental model changes:

import chromadb

client = chromadb.Client()
collection = client.create_collection("docs")

collection.add(documents=documents, ids=[f"d{i}" for i in range(len(documents))])

results = collection.query(query_texts=["I forgot my login credentials"], n_results=3)
print(results["documents"])
Enter fullscreen mode Exit fullscreen mode

Chroma embeds the text for you and handles the index. Same concept, production ergonomics.

Chunking matters more than the model

You rarely embed whole documents. A 30-page PDF becomes one vector that's an average of everything and a good match for nothing. In practice you chunk documents into passages (a few hundred tokens, often with slight overlap) and embed each chunk. Get chunking wrong and even a great embedding model returns mush — which is one of the most common reasons retrieval systems quietly underperform. Chunking strategy, overlap, and metadata are exactly the unglamorous details that separate a demo from a dependable system, and they're covered in depth in a course on advanced LLM integration for production apps.

Choosing and swapping embedding models

Embedding models differ in dimensionality, speed, cost, and language coverage — and critically, you must embed your corpus and your queries with the same model. Change the model and you re-embed everything. For multilingual apps (Romanian included), pick a model with strong multilingual training rather than an English-first one, or your non-English recall will suffer silently. Public benchmarks like the MTEB leaderboard on Hugging Face are the sane starting point for comparing models on retrieval quality rather than vibes.

Hybrid search: when semantic alone isn't enough

Here's a lesson that surprises people: pure semantic search is worse than keyword search for certain queries. Ask for an exact product code, a specific error like ERR_CONN_REFUSED, a person's name, or an acronym, and embeddings can betray you — they match on meaning, and a precise identifier has little semantic meaning to spread around. The embedding for ERR_CONN_REFUSED sits near "connection problems" generally, so a document about a different connection error can outrank the exact match.

The production answer is hybrid search: run both a keyword search (classic lexical scoring like BM25) and a semantic search, then combine the rankings. Keyword search nails exact terms, identifiers, and rare words; semantic search nails paraphrase and intent. Together they cover each other's blind spots.

The standard way to merge the two result lists is Reciprocal Rank Fusion (RRF) — a simple, robust formula that scores each document by its rank in each list rather than by raw scores that live on incompatible scales:

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
    """Fuse multiple ranked lists of doc IDs into one score per doc."""
    scores: dict[str, float] = {}
    for ranking in rankings:            # e.g. [keyword_results, semantic_results]
        for rank, doc_id in enumerate(ranking):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return dict(sorted(scores.items(), key=lambda x: x[1], reverse=True))
Enter fullscreen mode Exit fullscreen mode

Because RRF only looks at positions, you don't have to normalize BM25 scores against cosine similarities — a notorious apples-to-oranges trap. Most serious vector databases (Weaviate, Qdrant, and others) now offer hybrid search with RRF built in, precisely because "just embeddings" quietly underperforms on real, messy query logs. If you take one production lesson from this article, make it this: measure your recall on real queries, and reach for hybrid the moment exact-match queries show up.

Where embeddings show up beyond search

Semantic search is the gateway, but the same vectors power a lot more:

  • RAG (retrieval-augmented generation) — retrieve relevant chunks by embedding similarity, then feed them to an LLM as grounding context. Embeddings are the retrieval half; without good retrieval, generation hallucinates.
  • Deduplication & clustering — near-duplicate detection and topic clustering fall out of distances almost for free.
  • Recommendations — "items similar to this one" is a nearest-neighbor query in embedding space.
  • Classification — embed labeled examples, then classify new items by nearest neighbors, often without training a dedicated model.

The through-line: any time you need "similar in meaning" rather than "matches exactly," embeddings are the tool. Building these features end to end — API to product, with the retrieval and orchestration wired up properly — is the spine of a hands-on course on building AI applications in Python with the OpenAI and Anthropic SDKs.

Common mistakes that cost you hours

A few traps that catch almost everyone the first time:

  1. Re-embedding the corpus on every query. Embed documents once, store the vectors, embed only the incoming query at search time.
  2. Mixing models. Query vectors and document vectors must come from the same embedding model. A silent mismatch produces garbage rankings with no error.
  3. Forgetting to normalize. If you compute raw dot products instead of cosine similarity (and your vectors aren't already unit-normalized), longer texts get an unfair boost. Normalize, or use a library that does.
  4. Embedding documents that are too large. One vector per giant document averages meaning into uselessness. Chunk first.
  5. Trusting a single similarity threshold forever. The "good enough" cutoff depends on your model and data. Measure it on real queries; don't hardcode 0.8 because a blog post said so.

Conclusion

Vector embeddings are one of those ideas that feels like magic until you see the mechanics — and then it's just geometry. Text becomes a point in space, meaning becomes distance, and search becomes "find the nearest points." You built exactly that in a few lines of Python, from raw NumPy cosine similarity to a Chroma-backed index, and the same core idea scales from a toy corpus to millions of documents behind an ANN index.

Start where we started: embed a handful of your own documents, run a query that shares no keywords with the right answer, and watch it surface anyway. Once that clicks, RAG, recommendations, and semantic features stop looking like separate topics and start looking like one tool applied five ways. If you want the structured path from here — retrieval, chunking, evaluation, and production wiring — Cursuri-AI.ro builds it step by step.


Sources & further reading:

This article is educational content. Model names, dimensions, and library APIs evolve; verify current details in the official documentation before building production systems.

Top comments (0)