Semantic search vs. keyword search
On this page
Objectives
- Understand the limitations of keyword search
- Discover how semantic search solves these problems
- Implement a simple semantic search
The limits of keyword search
Classic search—TF-IDF, BM25, Elasticsearch—relies on lexical matching: it looks for the exact words from the query within the documents. This mechanism is fast, predictable, and perfectly reliable as long as the user employs the same vocabulary as the documents. The problem is that they almost never do.
The small commented corpus below shows the four typical situations when facing the query “how to feed a puppy.”
# Keyword search
query = "how to feed a puppy"
# ✅ Found: contains the same words
doc_a = "How to feed a 3-month-old puppy"
# ❌ Not found: different words, same meaning
doc_b = "Feeding the young dog"
# ❌ Not found: rephrasing
doc_c = "Dietary regimen for newborn puppies"
# ⚠️ False positive: same words, different meaning
doc_d = "How to feed a startup project like a puppy"
Only the first document is retrieved, even though the second and third answer the question just as well. Worse, the fourth ranks highly solely because it shares words with the query, even though it discusses business management. Synonyms, rephrasing, and ambiguity: lexical search fails at all three.
Semantic search
Semantic search compares the meaning of texts, not their words. You encode the corpus once and for all, encode the query when it arrives, and rank the documents by similarity.
from openai import OpenAI
import numpy as np
client = OpenAI()
def embed(text: str) -> np.ndarray:
response = client.embeddings.create(
input=text,
model="text-embedding-3-large"
)
return np.array(response.data[0].embedding)
# Corpus
documents = [
"How to feed a 3-month-old puppy",
"Feeding the young dog",
"Dietary regimen for newborn puppies",
"How to feed a startup project like a puppy",
"The basics of Italian cuisine",
]
# Index the corpus
corpus_embeddings = np.array([embed(doc) for doc in documents])
# Search
query = "how to feed a puppy"
query_embedding = embed(query)
# Calculate similarities
similarities = corpus_embeddings @ query_embedding
# Sort by relevance
results = sorted(
enumerate(similarities), key=lambda x: x[1], reverse=True
)
for idx, score in results:
print(f"{score:.4f} | {documents[idx]}")
Expected result:
0.9123 | How to feed a 3-month-old puppy
0.8756 | Dietary regimen for newborn puppies
0.8421 | Feeding the young dog
0.4532 | How to feed a startup project like a puppy
0.1245 | The basics of Italian cuisine
The three relevant documents occupy the top three spots despite entirely different phrasing, and the false positive drops to 0.45—well below the threshold that would bring it to the top. The model understood that “puppy” serves as a comparison here, not the subject.
Structured comparison
| Criterion | Keywords (BM25) | Semantic (embeddings) |
|---|---|---|
| Synonyms | ❌ Fails | ✅ Works |
| Rephrasing | ❌ Fails | ✅ Works |
| Multilingual | ❌ Impossible | ✅ Possible |
| Spelling errors | ❌ Fails | ✅ Tolerates |
| Exact match | ✅ Excellent | ⚠️ Sometimes imprecise |
| Proper nouns, codes | ✅ Excellent | ⚠️ May confuse |
| Speed (large corpus) | ✅ Very fast | ⚠️ Requires an index |
| Cost | ✅ Free | ⚠️ API calls |
When to use which?
Reading the table gives a simple rule: the closer a query is to an identifier, the more appropriate lexical search becomes. A product reference like XR-4471-B, a date, an exact category, or a rare proper noun should be searched word-for-word; an embedding model will tend to confuse that reference with its neighbors. This is also the default choice when the corpus is massive and the embedding budget is zero.
Semantic search takes the lead as soon as the user expresses themselves in natural language. A FAQ, customer support, technical documentation consulted by non-specialists, a multilingual corpus where the question is asked in French and the answer written in English, or even a vague, exploratory query: in all these cases, the gap between the user’s vocabulary and that of the documents is precisely what embeddings know how to bridge.
Since these two profiles are complementary rather than competitive, the best response often consists of combining both approaches to cover all cases. That is the subject of Lesson 9.
Implementing complete semantic search
In practice, you encapsulate indexing and querying in a small class. This avoids recalculating the corpus vectors for every query—the most costly mistake beginners make.
class SemanticSearch:
def __init__(self, model: str = "text-embedding-3-large"):
self.client = OpenAI()
self.model = model
self.documents: list[dict] = []
self.embeddings: np.ndarray | None = None
def index(self, documents: list[dict]):
"""Indexes a list of documents {id, text, ...}."""
self.documents = documents
texts = [d["text"] for d in documents]
response = self.client.embeddings.create(
input=texts,
model=self.model
)
vectors = sorted(response.data, key=lambda x: x.index)
self.embeddings = np.array(
[v.embedding for v in vectors], dtype=np.float32
)
def search(self, query: str, k: int = 5) -> list[dict]:
"""Searches for the k most relevant documents."""
query_emb = self.client.embeddings.create(
input=query,
model=self.model
).data[0].embedding
similarities = self.embeddings @ np.array(query_emb)
top_k = np.argsort(similarities)[-k:][::-1]
return [
{**self.documents[i], "score": float(similarities[i])}
for i in top_k
]
# Usage
engine = SemanticSearch()
engine.index([
{"id": 1, "text": "How to install Python on Windows"},
{"id": 2, "text": "Guide to configuring the Python environment"},
{"id": 3, "text": "The basics of the Python language"},
])
results = engine.search("set up Python on my PC")
for r in results:
print(f"[{r['score']:.3f}] {r['text']}")
Test this engine with the query “set up Python on my PC”: none of these five words appear in document 1, which talks about “installing Python on Windows.” Yet it should rank first. Then modify the query to talk about “configuring the environment” and watch the ranking shift to document 2—you now have a minimal test bench to verify your index’s behavior before populating it seriously.
Summary
- Keyword search fails with synonyms and rephrasing
- Semantic search compares meaning via embeddings
- Each approach has its strengths: hybrid search combines both
- A simple semantic engine can be built in a few dozen lines