Skip to main content
Lesson 6 of 22 · Embeddings, RAG and Semantic Search

Semantic search vs. keyword search

Lesson 6 of 22 Updated July 29, 2026 Developer
On this page
  1. Objectives
  2. The limits of keyword search
  3. Semantic search
  4. Structured comparison
  5. When to use which?
  6. Implementing complete semantic search
  7. Summary

Objectives

  • Understand the limitations of keyword search
  • Discover how semantic search solves these problems
  • Implement a simple semantic 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 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

CriterionKeywords (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.

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