Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

152 lines
5.6 KiB
Python

"""Result fusion for hybrid retrieval.
This module implements the *fusion* stage of the hybrid retrieval pipeline —
the step that merges the separately-ranked dense and sparse candidate lists into
a single, unified candidate pool before neural reranking.
Two production-grade fusion strategies are provided, matching the two approaches
discussed in the book (第3章「混合检索流水线」):
1. Reciprocal Rank Fusion (RRF)
score(d) = Σ_r 1 / (k + rank_r(d))
Only ranks are used, original scores are discarded. Robust and scale-free,
because it never has to compare a cosine similarity against a BM25 score.
2. Weighted score fusion (min-max normalized)
score(d) = Σ_r w_r * normalize_r(score_r(d))
Keeps the original relevance signal, at the cost of having to align the two
score scales via per-list min-max normalization.
Both functions take ranked lists of ``(doc_id, score)`` tuples (sorted by score
descending) and return a fused list of ``(doc_id, fused_score)`` tuples, also
sorted descending. A document that appears in only one list is still fused —
its contribution from the missing list is simply zero.
"""
from typing import Dict, List, Optional, Sequence, Tuple
RankedList = Sequence[Tuple[str, float]]
# Default smoothing constant for RRF. k=60 is the value from the original
# Cormack et al. paper and the most common choice in practice; it compresses the
# score gap between the very top ranks.
DEFAULT_RRF_K = 60
def _ranked_to_score_map(ranked: RankedList) -> Dict[str, float]:
scores: Dict[str, float] = {}
for doc_id, score in ranked:
if doc_id not in scores:
scores[doc_id] = score
return scores
def min_max_normalize(scores: Dict[str, float]) -> Dict[str, float]:
"""Min-max normalize a mapping of doc_id -> score into the [0, 1] range.
Args:
scores: Mapping from document id to raw score.
Returns:
Mapping from document id to normalized score. If every score is equal
(or there is a single document), all documents receive 1.0.
"""
if not scores:
return {}
values = list(scores.values())
lo, hi = min(values), max(values)
span = hi - lo
if span <= 0:
# Degenerate case: all scores identical -> treat as equally relevant.
return {doc_id: 1.0 for doc_id in scores}
return {doc_id: (score - lo) / span for doc_id, score in scores.items()}
def reciprocal_rank_fusion(
ranked_lists: Dict[str, RankedList],
k: int = DEFAULT_RRF_K,
weights: Optional[Dict[str, float]] = None,
) -> List[Tuple[str, float]]:
"""Fuse multiple ranked lists with Reciprocal Rank Fusion (RRF).
Args:
ranked_lists: Mapping from source name (e.g. "dense", "sparse") to a
list of ``(doc_id, score)`` tuples sorted by score descending. Only
the *order* of each list matters; the scores are ignored.
k: RRF smoothing constant (default 60).
weights: Optional per-source weights. Defaults to 1.0 for every source.
Returns:
Fused list of ``(doc_id, fused_score)`` tuples sorted descending.
"""
weights = weights or {}
fused: Dict[str, float] = {}
for source, ranked in ranked_lists.items():
weight = weights.get(source, 1.0)
for rank, (doc_id, _score) in enumerate(ranked, start=1):
fused[doc_id] = fused.get(doc_id, 0.0) + weight * (1.0 / (k + rank))
return sorted(fused.items(), key=lambda kv: kv[1], reverse=True)
def weighted_score_fusion(
ranked_lists: Dict[str, RankedList],
weights: Optional[Dict[str, float]] = None,
) -> List[Tuple[str, float]]:
"""Fuse multiple ranked lists with weighted, min-max normalized scores.
Each source list is min-max normalized to [0, 1] independently, then the
normalized scores are combined with a weighted sum. A document missing from
a source contributes 0 for that source.
Args:
ranked_lists: Mapping from source name to ``(doc_id, score)`` tuples.
weights: Optional per-source weights. Defaults to 1.0 for every source.
Returns:
Fused list of ``(doc_id, fused_score)`` tuples sorted descending.
"""
weights = weights or {}
normalized_by_source = {
source: min_max_normalize(_ranked_to_score_map(ranked))
for source, ranked in ranked_lists.items()
}
fused: Dict[str, float] = {}
for source, normalized in normalized_by_source.items():
weight = weights.get(source, 1.0)
for doc_id, norm_score in normalized.items():
fused[doc_id] = fused.get(doc_id, 0.0) + weight * norm_score
return sorted(fused.items(), key=lambda kv: kv[1], reverse=True)
def fuse(
ranked_lists: Dict[str, RankedList],
method: str = "rrf",
k: int = DEFAULT_RRF_K,
weights: Optional[Dict[str, float]] = None,
) -> List[Tuple[str, float]]:
"""Dispatch helper: fuse ranked lists with the named method.
Args:
ranked_lists: Mapping from source name to ``(doc_id, score)`` tuples.
method: "rrf" for Reciprocal Rank Fusion, "weighted" for weighted
min-max normalized score fusion.
k: RRF smoothing constant (only used when method="rrf").
weights: Optional per-source weights.
Returns:
Fused list of ``(doc_id, fused_score)`` tuples sorted descending.
Raises:
ValueError: If ``method`` is not recognized.
"""
if method == "rrf":
return reciprocal_rank_fusion(ranked_lists, k=k, weights=weights)
if method == "weighted":
return weighted_score_fusion(ranked_lists, weights=weights)
raise ValueError(f"Unknown fusion method: {method!r} (expected 'rrf' or 'weighted')")