"""RRF 融合重排的纯数学部分(架构 §8.3 唯一口径). 刻意与 SQL 分离:重排公式必须能被单测逐项断言(T-P2-04 验收 3), 混进 repo 就会把数学正确性测试拖进数据库。repo 只负责召回与名次, 这里把名次变成最终分。 """ from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime #: RRF 常数。60 是业界默认值:名次差异对分数的影响被压成平滑倒数, #: 单路召回头部的偏差不至于淹没另一路。 RRF_K = 60 #: 新鲜度半衰期(天):30 天前的内容得 0.5,60 天前 0.25…… #: 知识库是慢更新的,半衰期必须显著大于内容评审周期,否则新版本刚 #: 发布就会压制尚未重审的旧版本。 FRESHNESS_HALF_LIFE_DAYS = 30.0 #: 架构 §8.3 唯一口径:0.45 向量 + 0.2 关键词 + 0.25 规则 + 0.1 新鲜度。 #: 与 Settings.retrieval_rerank_weights 的默认值保持一致(可被 env 覆盖)。 DEFAULT_RERANK_WEIGHTS: dict[str, float] = { "vector": 0.45, "keyword": 0.2, "rule": 0.25, "freshness": 0.1, } @dataclass(frozen=True, slots=True) class RecallRow: """混合检索的一行召回结果:两路召回各自的名次,未被召回的一路为 ``None``.""" chunk_id: int entity_id: int chunk_type: str text: str updated_at: datetime vec_rank: int | None = None # 向量路名次(1 起) vec_distance: float | None = None # 向量余弦距离,观测用 kw_rank: int | None = None # 关键词路名次(1 起) kw_tsrank: float | None = None # ts_rank 原始分,观测用 @dataclass(frozen=True, slots=True) class ScoredChunk: """重排后的最终结果。各分量都归一到 [0, 1],final_score 是加权和。""" chunk_id: int entity_id: int chunk_type: str text: str vec_score: float kw_score: float rule_score: float freshness_score: float final_score: float def rrf_component(rank: int | None, k: int = RRF_K) -> float: """单路 RRF 分量 ``1/(k+rank)``,再除以 ``1/(k+1)`` 归一到 (0, 1]. rank=1 → 1.0,rank=k+1 → 0.5。若直接用原始倒数,0.45×(1/61) ≈ 0.007 与规则项 0.25×1.0 完全不可比,架构 §8.3 的权重就失去"相对重要性"的 含义 —— 归一化只是线性缩放,不改变单路内的排序。 """ if rank is None or rank <= 0: return 0.0 return (k + 1) / (k + rank) def rrf_score(vec_rank: int | None, kw_rank: int | None, k: int = RRF_K) -> float: """经典 RRF 原始分:``1/(k+rank_vec) + 1/(k+rank_kw)``(观测/调试用). 未被某路召回的 chunk 只累计被召回那一路的倒数。 """ total = 0.0 for rank in (vec_rank, kw_rank): if rank is not None and rank > 0: total += 1.0 / (k + rank) return total def freshness_score(updated_at: datetime, now: datetime | None = None) -> float: """新鲜度:``0.5 ** (age_days / 半衰期)``,越新越接近 1. ``now`` 缺省取当前 UTC 时刻;显式传入是为了让单测能构造已知年龄。 未来时间戳(时钟漂移)按 0 天处理,得 1.0。 """ current = now if now is not None else datetime.now(UTC) age_seconds = (current - updated_at).total_seconds() age_days = max(age_seconds, 0.0) / 86400.0 # float ** float 在 typeshed 里是 Any(负底数分数幂会出 complex),显式收窄。 return float(0.5 ** (age_days / FRESHNESS_HALF_LIFE_DAYS)) def rerank( rows: Sequence[RecallRow], *, weights: Mapping[str, float] | None = None, rule_boost: Mapping[int, float] | None = None, now: datetime | None = None, top_k: int | None = None, ) -> list[ScoredChunk]: """融合重排:``final = w_v×vec + w_k×kw + w_r×rule + w_f×fresh``(架构 §8.3). - ``vec``/``kw`` 是归一化的 RRF 分量(见 :func:`rrf_component`); - ``rule`` 直接取 ``rule_boost[chunk_id]``(调用方传入的规则权重, 规则引擎联动属后续任务,管线只认接口); - ``fresh`` 是半衰期衰减分。 排序稳定:final_score 降序,同分按 chunk_id 升序 —— 同一数据集重跑 结果可复现。``top_k`` 为 ``None`` 时不截断。 """ resolved_weights = weights if weights is not None else DEFAULT_RERANK_WEIGHTS w_vec = float(resolved_weights.get("vector", 0.0)) w_kw = float(resolved_weights.get("keyword", 0.0)) w_rule = float(resolved_weights.get("rule", 0.0)) w_fresh = float(resolved_weights.get("freshness", 0.0)) boosts = rule_boost or {} out = [] for row in rows: vec = rrf_component(row.vec_rank) kw = rrf_component(row.kw_rank) rule = float(boosts.get(row.chunk_id, 0.0)) fresh = freshness_score(row.updated_at, now) out.append( ScoredChunk( chunk_id=row.chunk_id, entity_id=row.entity_id, chunk_type=row.chunk_type, text=row.text, vec_score=vec, kw_score=kw, rule_score=rule, freshness_score=fresh, final_score=w_vec * vec + w_kw * kw + w_rule * rule + w_fresh * fresh, ) ) out.sort(key=lambda c: (-c.final_score, c.chunk_id)) return out[:top_k] if top_k is not None else out