"""Fusion strategies for combining sparse (BM25) and dense (FAISS) retrieval results. Implements multiple fusion approaches for ablation comparison: - Reciprocal Rank Fusion (RRF) — rank-based, score-agnostic baseline - Linear combination — fixed α weighted sum - Entropy-weighted fusion — adaptive α per query via calibrated entropy All fusion methods produce a unified candidate list from two retriever outputs. """ from typing import Any import numpy as np from app.calibration import ( CALIBRATION_METHODS, compute_alpha, compute_entropy, ) def _merge_candidates( sparse_results: list[dict[str, Any]], dense_results: list[dict[str, Any]], ) -> dict[str, dict[str, Any]]: """Merge candidates from both retrievers, keyed by (source, chunk). Returns dict mapping key → {source, chunk, text, sparse_score, dense_score}. """ merged: dict[str, dict[str, Any]] = {} for r in sparse_results: key = f"{r['source']}:{r['chunk']}" if key not in merged: merged[key] = { "source": r["source"], "chunk": r["chunk"], "text": r["text"], "sparse_score": r.get("score", 0.0), "dense_score": 0.0, } else: merged[key]["sparse_score"] = r.get("score", 0.0) for r in dense_results: key = f"{r['source']}:{r['chunk']}" if key not in merged: merged[key] = { "source": r["source"], "chunk": r["chunk"], "text": r["text"], "sparse_score": 0.0, "dense_score": r.get("score", 0.0), } else: merged[key]["dense_score"] = r.get("score", 0.0) return merged def rrf_fuse( sparse_results: list[dict[str, Any]], dense_results: list[dict[str, Any]], k: int = 60, top_k: int | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """Reciprocal Rank Fusion. RRF(d) = Σ 1/(k + rank(d)) across retriever lists. Score-agnostic, operates on ranks only. Returns (fused_results, metadata). """ rrf_scores: dict[str, float] = {} doc_data: dict[str, dict[str, Any]] = {} # Process sparse results for rank, r in enumerate(sparse_results, start=1): key = f"{r['source']}:{r['chunk']}" rrf_scores[key] = rrf_scores.get(key, 0.0) + 1.0 / (k + rank) if key not in doc_data: doc_data[key] = { "source": r["source"], "chunk": r["chunk"], "text": r["text"], } # Process dense results for rank, r in enumerate(dense_results, start=1): key = f"{r['source']}:{r['chunk']}" rrf_scores[key] = rrf_scores.get(key, 0.0) + 1.0 / (k + rank) if key not in doc_data: doc_data[key] = { "source": r["source"], "chunk": r["chunk"], "text": r["text"], } # Sort by RRF score sorted_keys = sorted(rrf_scores, key=rrf_scores.get, reverse=True) results = [] for key in sorted_keys: r = doc_data[key] r["score"] = round(rrf_scores[key], 6) results.append(r) if top_k is not None: results = results[:top_k] metadata = {"fusion_method": "rrf", "rrf_k": k} return results, metadata def linear_fuse( sparse_results: list[dict[str, Any]], dense_results: list[dict[str, Any]], alpha: float = 0.5, calibration: str = "minmax", corpus_cdfs: tuple[np.ndarray, np.ndarray] | None = None, top_k: int | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """Linear combination with fixed alpha. fused_score = α * sparse_calibrated + (1-α) * dense_calibrated Args: alpha: Weight for sparse retriever (0=all dense, 1=all sparse). calibration: Score calibration method ('raw', 'minmax', 'zscore', 'cdf'). corpus_cdfs: (cdf_bm25, cdf_dense) required when calibration='cdf'. """ merged = _merge_candidates(sparse_results, dense_results) if not merged: return [], { "fusion_method": "linear", "alpha": alpha, "calibration": calibration, } sparse_scores = np.array([m["sparse_score"] for m in merged.values()]) dense_scores = np.array([m["dense_score"] for m in merged.values()]) effective_calibration = calibration if calibration == "cdf" and corpus_cdfs is None: effective_calibration = "minmax" cal_fn = CALIBRATION_METHODS[effective_calibration] if effective_calibration == "cdf" and corpus_cdfs is not None: cal_sparse = cal_fn(sparse_scores, corpus_cdf=corpus_cdfs[0]) cal_dense = cal_fn(dense_scores, corpus_cdf=corpus_cdfs[1]) else: cal_sparse = cal_fn(sparse_scores) cal_dense = cal_fn(dense_scores) fused_scores = alpha * cal_sparse + (1 - alpha) * cal_dense results = [] for (_key, data), score in zip(merged.items(), fused_scores, strict=True): results.append( { "source": data["source"], "chunk": data["chunk"], "text": data["text"], "score": round(float(score), 6), } ) results.sort(key=lambda x: x["score"], reverse=True) if top_k is not None: results = results[:top_k] metadata = { "fusion_method": "linear", "alpha": alpha, "calibration": effective_calibration, "requested_calibration": calibration, } return results, metadata def entropy_fuse( sparse_results: list[dict[str, Any]], dense_results: list[dict[str, Any]], calibration: str = "cdf", corpus_cdfs: tuple[np.ndarray, np.ndarray] | None = None, top_k: int | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """Entropy-weighted adaptive fusion. Computes per-query alpha from calibrated score distribution entropy: α = H_dense / (H_dense + H_sparse + ε) When dense has high entropy (low confidence), α is large → more weight on BM25. """ merged = _merge_candidates(sparse_results, dense_results) if not merged: return [], { "fusion_method": "entropy_weighted", "alpha": 0.5, "h_sparse": 0.0, "h_dense": 0.0, "calibration": calibration, } sparse_scores = np.array([m["sparse_score"] for m in merged.values()]) dense_scores = np.array([m["dense_score"] for m in merged.values()]) effective_calibration = calibration if calibration == "cdf" and corpus_cdfs is None: effective_calibration = "minmax" cal_fn = CALIBRATION_METHODS[effective_calibration] if effective_calibration == "cdf" and corpus_cdfs is not None: cal_sparse = cal_fn(sparse_scores, corpus_cdf=corpus_cdfs[0]) cal_dense = cal_fn(dense_scores, corpus_cdf=corpus_cdfs[1]) else: cal_sparse = cal_fn(sparse_scores) cal_dense = cal_fn(dense_scores) # Compute entropy of each retriever's calibrated scores h_sparse = compute_entropy(cal_sparse) h_dense = compute_entropy(cal_dense) # Adaptive alpha alpha = compute_alpha(h_dense, h_sparse) # Fuse fused_scores = alpha * cal_sparse + (1 - alpha) * cal_dense results = [] for (_key, data), score in zip(merged.items(), fused_scores, strict=True): results.append( { "source": data["source"], "chunk": data["chunk"], "text": data["text"], "score": round(float(score), 6), } ) results.sort(key=lambda x: x["score"], reverse=True) if top_k is not None: results = results[:top_k] metadata = { "fusion_method": "entropy_weighted", "alpha": round(alpha, 6), "h_sparse": round(h_sparse, 6), "h_dense": round(h_dense, 6), "calibration": effective_calibration, "requested_calibration": calibration, } return results, metadata