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

CaseBetter optionWhy
One signal is 30%+ better than the other for your domainWeighted with an appropriate αRRF averages; weighted exploits the strong signal
A mixed corpus with very varied queriesWeighted with routingA global RRF doesn't adapt; routing does
You want to explain the ranking ("this doc ranked because BM25 gave it 0.85")WeightedThe components are explicit
A simple MVP, with no eval set to tune αRRFRRF requires no tuning
A team with no ML/data science backgroundRRFConceptually simpler
More than 2 sources (semantic + BM25 + HyDE + ...)RRFWeighted with N weights requires tuning each one

The recommended pragmatic pattern:

  1. Start with RRF (capsule 04). It works in 80% of cases with no tuning.
  2. Measure over the eval set. If recall is satisfactory, stop here.
  3. If recall comes out low: try weighted with a grid search over α.
  4. If weighted improves things >3%: consider deploying it.
  5. 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:

  1. Decide whether weighted blending with routing can reach the target.
  2. Design the routing and the α values per type.
  3. 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:

  1. Build an eval set of 100 real queries with ground truth (proportional to the log's percentages).
  2. Measure the baseline (the current RRF): recall@5 per query type.
  3. Implement weighted with routing.
  4. Re-measure.
  5. If global recall ≥88% with no precision drop >2%, deploy behind a feature flag.
  6. 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=50 before 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.5 is balanced, α=0.7 is semantic-dominant, α=0.3 is 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

  1. Pinecone — Score Normalization — Normalization patterns
  2. LangChain — EnsembleRetriever — Supports weighted blending
  3. Feature Scaling — Wikipedia — Min-max vs z-score vs rank
  4. Hybrid Search Tuning Guide (Vespa) — Empirical tuning
  5. BEIR Benchmark — Empirical comparison
  6. Anthropic — Contextual Retrieval — A complementary technique

Estimated time: 25-30 minutes Next: 06-elasticsearch-integration.md