Where we left off

Lesson 6 showed dense and sparse retrieval failing in different places, on the same corpus, at the same overall accuracy. The obvious next move: combine their scores into one ranking. This lesson does that the most direct way possible, a weighted sum, and shows why picking the weight is harder than it looks.

The code, piece by piece

def min_max_normalize(scores: dict[str, float]) -> dict[str, float]:
low, high = min(values), max(values)
return {name: (score - low) / spread for name, score in scores.items()}

Dense scores already live in [0, 1] (cosine similarity), but BM25 scores are unbounded, could be 0.0, could be 12.4. Min-max squashes both onto the same [0, 1] range, per query, before they get combined, otherwise whichever retriever happens to produce larger raw numbers would dominate the sum regardless of which is actually more relevant.

combined = {name: alpha * dense_norm[name] + (1 - alpha) * sparse_norm[name] for name in names}

alpha controls the blend: 1.0 is pure dense, 0.0 is pure sparse, 0.5 is an even split. This lesson sweeps alpha across the full range and checks accuracy on all ten of Lesson 6's questions at each value.

Checkpoint

  • Min-max normalization: squash unbounded sparse scores and bounded dense scores onto the same [0, 1] range before combining them.
  • alpha: the blend weight between dense and sparse, tuned by sweeping values against a labeled question set, the same fragile pattern flagged for hyperparameter tuning generally.
  • The weight that works depends on the query mix you tuned it against, which you don't know in advance and can't fully anticipate. Lesson 8 introduces a fusion method that doesn't require picking a weight at all.

If anything here still feels unclear, ask before moving to Lesson 8.