Module 5: Hybrid Search — combining keyword + semantic for queries that need both
Capsule 05: Weighted hybrid blending — when one signal is clearly better than the other
Capsule description
RRF (capsule 04) assumes your two rankings (BM25 and semantic) are equally trustworthy. That assumption works well in most cases. But there are domains where one signal is clearly superior: in a purely narrative search engine, semantic always wins; in a product catalog with SKUs, BM25 wins almost every time. RRF "averages" both, which dilutes the strong signal instead of exploiting it.
Weighted hybrid blending solves that by letting you weight the signals with an α (alpha) parameter: score = α × semantic + (1-α) × bm25. If semantic is 70% more trustworthy in your domain, you give it α=0.7. More control, more tuning complexity, but sometimes a notable gain.
This capsule teaches you the correct formula (with score normalization), how to tune α empirically, and when weighted is worth the extra effort vs when RRF is enough.
By the end of this capsule you'll be able to:
- ✅ Implement weighted blending with correct score normalization
- ✅ Tune
αfor your corpus with a grid search over the eval set - ✅ Design dynamic
αrouting based on query type - ✅ Decide when weighted beats RRF and when it doesn't
- ✅ Anticipate the main mistake: comparing unnormalized scores (incompatible ranges)
- ✅ Implement the hybrid pattern "RRF by default + weighted for critical cases"
Estimated time: 25-30 minutes
Why you need normalization before blending
The naive formula is a trap:
# ❌ Does NOT work
hybrid_score = α × bm25_score + (1-α) × semantic_score
The problem: the ranges are incompatible.
BM25 score: typical range 0 - 50+
Cosine similarity: typical range 0.7 - 1.0 (with normal text embeddings)
Cosine distance: typical range 0 - 0.3 (what ChromaDB returns)
If you add 0.5 × 30 + 0.5 × 0.85, the first term dominates by magnitude (15.0 vs 0.425), not by relevance. Any reasonable α will leave BM25 dominating the whole ranking.
The solution: normalize both scores to [0, 1] before combining.
import numpy as np
def normalize_scores(scores: list[float], method: str = "minmax") -> list[float]:
"""Normalizes scores to the [0, 1] range."""
if not scores:
return scores
arr = np.array(scores, dtype=float)
if method == "minmax":
# Min-max scaling: (x - min) / (max - min)
min_v = arr.min()
max_v = arr.max()
if max_v - min_v < 1e-10:
return [0.5] * len(scores)
return ((arr - min_v) / (max_v - min_v)).tolist()
elif method == "zscore":
# Z-score normalization: (x - mean) / std
mean = arr.mean()
std = arr.std()
if std < 1e-10:
return [0.5] * len(scores)
zscores = (arr - mean) / std
# Map to [0, 1] with a sigmoid
return (1 / (1 + np.exp(-zscores))).tolist()
elif method == "rank":
# Rank-based: convert scores to normalized positions
ranks = np.argsort(np.argsort(-arr)) # rank 0 = best
return (1 - ranks / max(len(arr) - 1, 1)).tolist()
raise ValueError(f"Unknown method: {method}")
Recommendation: minmax is the reasonable default. rank is more robust to outliers (a BM25 score of 50 against typical scores of 5-10 won't distort the rest).
The correct implementation of weighted blending
# weighted_blending.py
from dataclasses import dataclass
@dataclass
class WeightedHybridResult:
doc_id: str
final_score: float
semantic_score_normalized: float
bm25_score_normalized: float
def weighted_hybrid(
semantic_results: dict, # {"ids": [...], "distances": [...]}
bm25_results: dict, # {"ids": [...], "scores": [...]}
alpha: float = 0.5, # semantic's weight (0 = BM25 only, 1 = semantic only)
top_k: int = 5,
normalize: str = "minmax",
) -> list[WeightedHybridResult]:
"""
Combines the semantic and BM25 rankings with weights α and (1-α).
Important: converts cosine distance to similarity (1 - distance) so that
"higher = better" holds in both rankings before normalizing.
"""
# Convert cosine distance → cosine similarity
semantic_similarities = [1.0 - d for d in semantic_results["distances"]]
semantic_normalized = normalize_scores(semantic_similarities, method=normalize)
# BM25 is already "higher = better"
bm25_normalized = normalize_scores(bm25_results["scores"], method=normalize)
# Combine
combined = {}
for doc_id, sem_score in zip(semantic_results["ids"], semantic_normalized):
combined[doc_id] = {
"semantic": sem_score,
"bm25": 0.0, # default if it doesn't appear in BM25
}
for doc_id, bm25_score in zip(bm25_results["ids"], bm25_normalized):
if doc_id in combined:
combined[doc_id]["bm25"] = bm25_score
else:
combined[doc_id] = {
"semantic": 0.0,
"bm25": bm25_score,
}
# Compute the final score
results = []
for doc_id, scores in combined.items():
final = alpha * scores["semantic"] + (1 - alpha) * scores["bm25"]
results.append(WeightedHybridResult(
doc_id=doc_id,
final_score=final,
semantic_score_normalized=scores["semantic"],
bm25_score_normalized=scores["bm25"],
))
# Sort by descending score
results.sort(key=lambda x: -x.final_score)
return results[:top_k]
End-to-end usage
import chromadb
from chromadb.utils import embedding_functions
from rank_bm25 import BM25Okapi
import os
# ChromaDB and BM25 setup (assume it's already configured)
collection = ...
bm25_index = ...
all_doc_ids = ...
def hybrid_search_weighted(query: str, alpha: float = 0.5, top_k: int = 5):
# Semantic search
sem_results = collection.query(query_texts=[query], n_results=30)
semantic_data = {
"ids": sem_results["ids"][0],
"distances": sem_results["distances"][0],
}
# BM25
query_tokens = query.lower().split()
bm25_scores_all = bm25_index.get_scores(query_tokens)
top_bm25_indices = sorted(range(len(bm25_scores_all)), key=lambda i: -bm25_scores_all[i])[:30]
bm25_data = {
"ids": [all_doc_ids[i] for i in top_bm25_indices],
"scores": [float(bm25_scores_all[i]) for i in top_bm25_indices],
}
# Weighted fusion
final = weighted_hybrid(semantic_data, bm25_data, alpha=alpha, top_k=top_k)
return final
Tuning α with a grid search
The optimal α depends on your corpus and your queries. The right way to find it is empirically.
def grid_search_alpha(eval_set, alpha_values=[0.0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0]):
"""
Finds the α that maximizes recall over the eval set.
α=0 → BM25 only (doesn't use semantic)
α=1 → semantic only (doesn't use BM25)
"""
results = {}
for alpha in alpha_values:
recalls = []
for item in eval_set:
top_5 = hybrid_search_weighted(item.query, alpha=alpha, top_k=5)
top_5_ids = [r.doc_id for r in top_5]
relevant = set(item.expected_doc_ids)
hits = sum(1 for doc_id in top_5_ids if doc_id in relevant)
recall = hits / len(relevant) if relevant else 0
recalls.append(recall)
avg_recall = sum(recalls) / len(recalls)
results[alpha] = avg_recall
print(f"α={alpha}: recall@5={avg_recall:.2%}")
best_alpha = max(results, key=results.get)
print(f"\nBest α: {best_alpha} (recall={results[best_alpha]:.2%})")
return best_alpha
Typical output:
α=0.0: recall@5=72.5% ← BM25 only
α=0.2: recall@5=78.3%
α=0.4: recall@5=82.1%
α=0.5: recall@5=84.5% ← the peak
α=0.6: recall@5=83.7%
α=0.8: recall@5=78.9%
α=1.0: recall@5=68.2% ← semantic only
Best α: 0.5
The reading: the curve has an inverted-U shape — the extremes (BM25 only or semantic only) are worse than the mix. The optimum is in the middle. The exact shape depends on the domain.
Dynamic α routing by query type
For a corpus with very varied queries (some technical, some conceptual), a single global α isn't optimal. Better: detect the query type and use an adaptive α.
import re
def detect_query_type(query: str) -> str:
"""Detects the query type with a simple heuristic."""
has_identifier = bool(re.search(r'[A-Z][a-z]+[A-Z][a-z]+|[a-z]+_[a-z]+', query))
has_error_code = bool(re.search(r'[A-Z]{2,}_?[0-9A-Z_]+', query))
has_version = bool(re.search(r'\bv?\d+\.\d+(\.\d+)?', query))
word_count = len(query.split())
if has_identifier or has_error_code or has_version:
return "exact_match" # prioritize BM25
if word_count > 8 and "?" in query:
return "conceptual" # prioritize semantic
return "balanced"
def choose_alpha(query: str) -> float:
"""Picks α based on the query type."""
query_type = detect_query_type(query)
if query_type == "exact_match":
return 0.3 # prioritize BM25 (70% weight)
elif query_type == "conceptual":
return 0.7 # prioritize semantic (70% weight)
else:
return 0.5 # balanced
def smart_hybrid_search(query: str, top_k: int = 5):
alpha = choose_alpha(query)
return hybrid_search_weighted(query, alpha=alpha, top_k=top_k)
The typical benefit: dynamic routing improves things 3-5% over the optimal global α, especially in mixed corpora.
The cost: extra complexity. You have to maintain the detection heuristics and validate with an eval set segmented by type.
When weighted beats RRF
| Case | Better option | Why |
|---|---|---|
| One signal is 30%+ better than the other for your domain | Weighted with an appropriate α | RRF averages; weighted exploits the strong signal |
| A mixed corpus with very varied queries | Weighted with routing | A global RRF doesn't adapt; routing does |
| You want to explain the ranking ("this doc ranked because BM25 gave it 0.85") | Weighted | The components are explicit |
| A simple MVP, with no eval set to tune α | RRF | RRF requires no tuning |
| A team with no ML/data science background | RRF | Conceptually simpler |
| More than 2 sources (semantic + BM25 + HyDE + ...) | RRF | Weighted with N weights requires tuning each one |
The recommended pragmatic pattern:
- Start with RRF (capsule 04). It works in 80% of cases with no tuning.
- Measure over the eval set. If recall is satisfactory, stop here.
- If recall comes out low: try weighted with a grid search over α.
- If weighted improves things >3%: consider deploying it.
- If you want to squeeze more: dynamic α routing.
Traps and common mistakes
Trap 1: forgetting to normalize before blending
Covered above. Without normalization, BM25 dominates by magnitude.
Trap 2: using cosine distance directly as "similarity"
The mistake:
hybrid = α * cosine_distance + (1-α) * bm25_score
The symptom: lower distance = more similar, but you're adding it as if it were a "positive score". The ranking comes out inverted.
How to prevent it: convert distance to similarity first: similarity = 1 - distance.
Trap 3: tuning α with a small eval set
The mistake: a grid search with 10 queries.
The symptom: the "optimal α" varies a lot between runs. It isn't statistically stable.
How to prevent it: minimum 50 queries, ideally 100+. If you can't get 100, use the α=0.5 default and save yourself the tuning.
Trap 4: routing with badly calibrated heuristics
The mistake: detect_query_type has false positives. Semantic queries get categorized as "exact_match", they get α=0.3, BM25 dominates and the quality drops.
The symptom: after implementing routing, recall goes down in some categories.
How to prevent it: manually validate the categorization over 100 real queries. Adjust the regex until the categorization's precision is >90%.
Trap 5: comparing α=0 with BM25 standalone and assuming they're equivalent
The mistake: you assume weighted_hybrid(α=0) gives the same result as BM25 standalone.
Reality: not necessarily. weighted_hybrid(α=0) only considers the docs that are in EITHER of the two sources (semantic or BM25). BM25 standalone only considers the ones that are in BM25.
How to prevent it: understand that α=0 means "ignore semantic's weight", but the ranking is still built over the union of both sources.
Trap 6: weighted as a full replacement for RRF in production without an A/B test
The mistake: after tuning α, you deploy at 100% without validating against RRF in production.
The symptom: the eval set's improvement doesn't carry over to real production (real queries can have a different distribution).
How to prevent it: A/B test in production for 1-2 weeks. If weighted doesn't beat RRF by >3% on real metrics (CTR, satisfaction), revert to RRF and simplify.
Applied exercise
Scenario: you're an AI Engineer at a corporate education platform. The data:
- 100K chunked courses (a mix of code + narrative text + examples)
- 30K queries/day
- Current system: hybrid search with RRF (k=60). Recall@5 = 81%
Eval set analysis:
Query type % Current recall with RRF
─────────────────────────────────────────────────────
Conceptual 45% 88%
Code identifiers 30% 72%
Shell commands 15% 78%
Mixed (code + text) 10% 80%
Stakeholders are asking for: a global recall@5 ≥ 88%.
Your job:
- Decide whether weighted blending with routing can reach the target.
- Design the routing and the α values per type.
- Estimate the impact.
Solution
1. Yes, it can reach the target — the problem is RRF's fixed weighting
RRF assumes BM25 and semantic contribute equally. But the data shows:
- Conceptual queries: semantic >> BM25. Recall is already high (88%).
- Queries with identifiers: BM25 >> semantic. RRF is balancing them, but BM25 should weigh more → recall goes up.
- Shell commands: BM25 >> semantic, same as above.
Dynamic α routing can exploit the better signal for each case.
2. The routing design
def detect_query_type(query: str) -> str:
"""Detects the type for α routing."""
import re
has_camel = bool(re.search(r'[A-Z][a-z]+[A-Z][a-z]+', query))
has_underscore_id = bool(re.search(r'\b\w+_\w+\b', query))
has_command = bool(re.search(r'\b(kubectl|helm|docker|npm|git|aws)\b', query.lower()))
is_question = "?" in query or any(query.lower().startswith(w) for w in ["how", "why", "what", "when"])
word_count = len(query.split())
if has_command:
return "shell_command" # BM25 is strong
if has_camel or has_underscore_id:
return "code_identifier" # BM25 is strong
if is_question and word_count > 6:
return "conceptual" # semantic is strong
return "mixed"
def choose_alpha(query: str) -> float:
query_type = detect_query_type(query)
return {
"shell_command": 0.25, # BM25 dominates (75%)
"code_identifier": 0.30, # BM25 dominates (70%)
"conceptual": 0.75, # semantic dominates (75%)
"mixed": 0.50, # balanced
}[query_type]
3. Expected impact
Query type % Current RRF Weighted+routing Weighted contribution
──────────────────────────────────────────────────────────────────────────────
Conceptual 45% 88% 90% (α=0.75) +0.9 pts
Identifiers 30% 72% 87% (α=0.30) +4.5 pts
Shell commands 15% 78% 90% (α=0.25) +1.8 pts
Mixed 10% 80% 82% +0.2 pts
Total gain: +7.4 points
Global recall: 81% → ~88% ← meets the target
Validation plan:
- Build an eval set of 100 real queries with ground truth (proportional to the log's percentages).
- Measure the baseline (the current RRF): recall@5 per query type.
- Implement weighted with routing.
- Re-measure.
- If global recall ≥88% with no precision drop >2%, deploy behind a feature flag.
- A/B test in production for 2 weeks.
Risks to monitor:
- Precision on conceptual queries: with α=0.75 running very high, semantic can rank irrelevant paraphrases that BM25 would have filtered out. If precision drops >3%, lower α to 0.65.
- Classifier false positives: measure the % of miscategorized queries. If it's >15%, refine the regex.
- Latency: routing adds ~1ms (regex). Negligible.
Plan B if it doesn't reach 88%:
- Consider adding HyDE (capsule M03/06) for the more ambitious conceptual queries.
- Combine it with more aggressive re-ranking (
n_results=50before the rerank). - If it still falls short, the problem may not be weighted blending at all — investigate the chunking or the corpus coverage.
Summary and next step
What you learned:
- Weighted blending weights the scores with an
αparameter.α=0.5is balanced,α=0.7is semantic-dominant,α=0.3is BM25-dominant. - Score normalization is mandatory before blending (the ranges are incompatible).
- Min-max normalization is the reasonable default. Rank-based is more robust to outliers.
- Tune α with a grid search over the eval set. Minimum 50 queries for a stable result.
- Dynamic routing (a different α per query type) improves things 3-5% over a global α.
- Weighted beats RRF when one signal is clearly better for your domain.
- The pragmatic pattern: start with RRF, escalate to weighted only if the gain justifies it.
Checkpoint: before moving on, you should be able to:
- Implement weighted blending with correct normalization.
- Run a grid search over α and pick the optimum based on the eval set.
- Design dynamic α routing by query type.
Next capsule: 06 — Elasticsearch for hybrid search at scale.
rank_bm25 works well up to ~1M docs. Beyond that, you need Elasticsearch. Capsule 06 covers the setup, the indexing, and how to do native hybrid search in Elasticsearch (which already has RRF built in on recent versions).
Resources
- Pinecone — Score Normalization — Normalization patterns
- LangChain — EnsembleRetriever — Supports weighted blending
- Feature Scaling — Wikipedia — Min-max vs z-score vs rank
- Hybrid Search Tuning Guide (Vespa) — Empirical tuning
- BEIR Benchmark — Empirical comparison
- Anthropic — Contextual Retrieval — A complementary technique
Estimated time: 25-30 minutes Next: 06-elasticsearch-integration.md