What is semantic search and how does it differ from keyword (lexical) search?
Keyword (lexical) search matches documents based on exact word overlap. BM25 (Best Match 25) is the gold standard — it extends TF-IDF with document length normalization. Fast, transparent, excellent for exact matches.
Semantic search uses embedding models to find documents with similar meaning, regardless of word choice. 'Heart attack' finds documents about 'myocardial infarction'.
Comparison:
| Aspect | Keyword (BM25) | Semantic |
|---|---|---|
| Matching | Exact words | Meaning/intent |
| Speed | Very fast (inverted index) | Fast with ANN index |
| Out-of-vocabulary | Fails on unknown words | Handles typos, synonyms |
| Exact terms | Excellent | Can miss exact codes/IDs |
| Setup | Simple (Elasticsearch) | Requires embedding model + vector DB |
| Explainability | High (term scores) | Lower (vector similarity) |
Hybrid search (recommended for production):
Hybrid score = α × BM25_score + (1-α) × cosine_similarity
Or use Reciprocal Rank Fusion (RRF) — combine rank positions from both systems without needing to tune α.
When semantic search dominates: Long natural language queries, question answering, multilingual search, voice search, user-generated content with spelling errors.
When BM25 dominates: Product code lookup, SKU search, legal citation, technical documentation with precise terminology, very short queries.
# Hybrid search with Reciprocal Rank Fusion
from rank_bm25 import BM25Okapi
import numpy as np
def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
"""Combine multiple ranked lists using RRF."""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, 1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
class HybridSearcher:
def __init__(self, documents: list[dict]):
self.docs = documents
texts = [d['text'].split() for d in documents]
self.bm25 = BM25Okapi(texts)
def search(self, query: str, top_k: int = 5) -> list[dict]:
# BM25 retrieval
bm25_scores = self.bm25.get_scores(query.split())
bm25_ranking = sorted(range(len(self.docs)),
key=lambda i: bm25_scores[i], reverse=True)
# Dense retrieval (simplified)
q_vec = embed(query)
dense_scores = [
(cosine_similarity(q_vec, embed(d['text'])), i)
for i, d in enumerate(self.docs)
]
dense_ranking = [i for _, i in sorted(dense_scores, reverse=True)]
# RRF fusion
fused = reciprocal_rank_fusion([bm25_ranking, dense_ranking])
return [self.docs[i] for i in fused[:top_k]]