"""RRF 融合 + 权重 + 新鲜度的数学正确性(T-P2-04 验收 3):构造已知名次断言最终序.""" from __future__ import annotations import itertools from datetime import UTC, datetime, timedelta import pytest from packages.retrieval.rerank import ( DEFAULT_RERANK_WEIGHTS, FRESHNESS_HALF_LIFE_DAYS, RRF_K, RecallRow, freshness_score, rerank, rrf_component, rrf_score, ) NOW = datetime(2026, 8, 14, 12, 0, 0, tzinfo=UTC) def _row( chunk_id: int, *, vec_rank: int | None = None, kw_rank: int | None = None, updated_at: datetime = NOW, entity_id: int = 1, ) -> RecallRow: return RecallRow( chunk_id=chunk_id, entity_id=entity_id, chunk_type="text", text=f"chunk-{chunk_id}", updated_at=updated_at, vec_rank=vec_rank, kw_rank=kw_rank, ) # --- 单路 RRF 分量 ------------------------------------------------------------ def test_rrf_component_is_max_normalized() -> None: assert rrf_component(1) == pytest.approx(1.0) # (60+1)/(60+1) assert rrf_component(2) == pytest.approx(61 / 62) assert rrf_component(RRF_K + 2) == pytest.approx(0.5) # 61/122:名次再排后一半 assert rrf_component(None) == 0.0 assert rrf_component(0) == 0.0 assert rrf_component(-3) == 0.0 def test_rrf_component_monotonic_non_increasing() -> None: values = [rrf_component(r) for r in range(1, 20)] assert all(a >= b for a, b in itertools.pairwise(values)) def test_rrf_score_is_sum_of_raw_reciprocals() -> None: assert rrf_score(1, 2) == pytest.approx(1 / 61 + 1 / 62) assert rrf_score(None, 1) == pytest.approx(1 / 61) assert rrf_score(1, None) == pytest.approx(1 / 61) assert rrf_score(None, None) == 0.0 # --- 新鲜度 ------------------------------------------------------------------ def test_freshness_half_life_decay() -> None: assert freshness_score(NOW, now=NOW) == pytest.approx(1.0) old = NOW - timedelta(days=FRESHNESS_HALF_LIFE_DAYS) assert freshness_score(old, now=NOW) == pytest.approx(0.5) older = NOW - timedelta(days=2 * FRESHNESS_HALF_LIFE_DAYS) assert freshness_score(older, now=NOW) == pytest.approx(0.25) # 时钟漂移产生未来时间戳 → 按 0 天处理,不放大分数 future = NOW + timedelta(hours=1) assert freshness_score(future, now=NOW) == pytest.approx(1.0) # --- 加权公式 ---------------------------------------------------------------- def test_final_score_follows_architecture_formula() -> None: """final = 0.45×vec + 0.2×kw + 0.25×rule + 0.1×fresh,逐项核对.""" row = _row(1, vec_rank=1, kw_rank=3) boost = {1: 0.5} scored = rerank([row], rule_boost=boost, now=NOW) expected = 0.45 * 1.0 + 0.2 * (61 / 63) + 0.25 * 0.5 + 0.1 * 1.0 assert scored[0].final_score == pytest.approx(expected) assert scored[0].vec_score == pytest.approx(1.0) assert scored[0].kw_score == pytest.approx(61 / 63) assert scored[0].rule_score == pytest.approx(0.5) assert scored[0].freshness_score == pytest.approx(1.0) def test_default_weights_match_architecture_section_8_3() -> None: assert DEFAULT_RERANK_WEIGHTS == { "vector": 0.45, "keyword": 0.2, "rule": 0.25, "freshness": 0.1, } def test_dual_recall_beats_single_recall() -> None: """两路都命中的 chunk 应压过只有向量路命中、名次更高的 chunk.""" both = _row(1, vec_rank=5, kw_rank=2) vec_only = _row(2, vec_rank=1) scored = rerank([both, vec_only], now=NOW) by_id = {c.chunk_id: c for c in scored} assert by_id[1].final_score > by_id[2].final_score def test_rule_boost_lifts_lower_recall_chunk() -> None: """规则权重 0.25×boost 可以把召回名次落后的 chunk 抬到最前(自建知识库的意义).""" rank5 = _row(1, vec_rank=5) rank1 = _row(2, vec_rank=1) scored = rerank([rank5, rank1], rule_boost={1: 1.0}, now=NOW) assert scored[0].chunk_id == 1 assert scored[0].rule_score == pytest.approx(1.0) def test_keyword_only_chunk_gets_zero_vector_score() -> None: scored = rerank([_row(7, kw_rank=1)], now=NOW) assert scored[0].vec_score == 0.0 assert scored[0].kw_score == pytest.approx(1.0) def test_freshness_breaks_ties_toward_newer_chunk() -> None: """名次相同(现实中少见,但公式上可能)时,更新鲜者胜.""" fresh = _row(1, vec_rank=2, updated_at=NOW) stale = _row(2, vec_rank=2, updated_at=NOW - timedelta(days=90)) scored = rerank([fresh, stale], now=NOW) assert scored[0].chunk_id == 1 delta = scored[0].final_score - scored[1].final_score assert delta == pytest.approx(0.1 * (1.0 - 0.5**3)) def test_zero_weight_dimension_ignores_that_signal() -> None: old = _row(1, vec_rank=1, updated_at=NOW - timedelta(days=365)) new = _row(2, vec_rank=2, updated_at=NOW) scored = rerank([old, new], weights={"vector": 1.0}, now=NOW) assert scored[0].chunk_id == 1 # 新鲜度权重为 0,旧的反而名次靠前 def test_stable_ordering_and_top_k_truncation() -> None: rows = [_row(i, vec_rank=i) for i in (30, 10, 20, 40)] scored = rerank(rows, now=NOW, top_k=2) assert [c.chunk_id for c in scored] == [10, 20] def test_equal_final_scores_break_tie_by_chunk_id() -> None: rows = [_row(90, kw_rank=1), _row(10, kw_rank=1)] scored = rerank(rows, now=NOW) assert [c.chunk_id for c in scored] == [10, 90] def test_empty_input_returns_empty() -> None: assert rerank([], now=NOW) == []