Files
meida_backend/tests/unit/test_rerank.py
T
xsl aa1287d83d feat(retrieval): hybrid search over pgvector with hard filters and gap tracking
DeterministicEmbedder(可换 BGE-M3 的接口位)+ kb_chunk 双索引写入
+ 硬过滤→向量→关键词→RRF 重排管线 + kb_gap 缺口埋点(T-P2-03/04/06,TD-08)。

实现:
- embedding.py:EmbeddingProvider Protocol + DeterministicEmbedder
  (sha256 种子→归一化 1024 维假向量,同文本同向量/异文本近似正交)
  + build_embedding_provider 工厂(未知模型名抛 CONFIG_ERROR)。
- repo.py:upsert_chunks(整实体先删后插,逐行带 embedding_model/version,
  可重跑判据 T-P2-03);hybrid_search(§8.3 CTE:硬过滤 published/
  entity_types/occasion/avoid_body_shapes → <=> 余弦 topN → ts_rank topN
  → RRF);record_gap(咨询锁串行化同 query+filter_ctx 的 hit_count 递增)。
- rerank.py:RRF/权重/新鲜度纯数学(独立于 SQL 以便单测逐项断言)。
- service.py:RetrievalService.search 编排 embed→检索→(空结果)gap 埋点,
  SearchOutcome.degraded 标记降级。

测试:单测 +30(test_embedding 确定性/维度/归一化/工厂拒绝、test_rerank
公式逐项断言、test_retrieval_service 编排打桩);集成 +5
(test_hybrid_retrieval:禁忌实体同文最近向量仍零出现、draft 零出现、
关键词路径、换模型整组替换、kb_gap hit_count=2、partial 索引 EXPLAIN 验证)。
全绿:ruff / mypy(retrieval+shared+domain) / lint-imports / 268 单测 /
21 集成。

偏差说明:
1) 新增 rerank.py(任务文档只点名 embedding/repo/service,但重排数学必须
   不连库可单测,独立模块是最低成本实现,仍属 packages/retrieval/**)。
2) upsert_chunks 的 upsert 粒度是实体(先删后插):kb_chunk 无业务自然键,
   行级 ON CONFLICT 不可行;换模型名=新版本整组替换,不留旧版本残向量。
3) 禁忌过滤用 COALESCE(attrs->avoid_body_shapes, '[]')兜底:jsonb NULL
   的 ? 运算结果是 NULL,直接 NOT 会把未声明禁忌的好实体一并排除。
4) 关键词路过滤用 tsv @@ tsquery 而非 ts_rank>0:ts_rank 对不匹配文本返回
   ~1e-20 正 epsilon,阈值过滤会把无关 chunk 全放进关键词路。
5) RRF 分量按 1/(k+1) 归一到 (0,1],使 0.45/0.2/0.25/0.1 权重量纲可比
   (原始倒数 0.45×1/61 与规则项 0.25×1 完全不可比)。
2026-08-14 20:12:27 +08:00

159 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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) == []