# Issue #137: Facts Score 语义错误修复

- **GitHub**: https://github.com/nicepkg/index1/issues/137
- **状态**: ✅ 完成
- **分支**: dev

## 问题描述

`unified_search.py:335` 中 `FactItem.score` 使用了 `confidence`（静态质量指标）而非 RRF 相关性分数（动态查询相关性）。

```python
# 当前代码 (unified_search.py:335)
score=round(f.get("confidence", 0.5), 2),  # ← BUG: 用了 confidence 不是 relevance
```

**根因**: `search_cog.py:_rrf_merge()` 计算了 RRF 分数但在返回时丢弃了：

```python
# search_cog.py:136-137
sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
return [facts_map[fid] for fid in sorted_ids]  # ← scores 在这里丢失
```

**影响**:
- **排序正确**（`_rrf_merge` 内部排序是对的）
- **暴露的分数值语义错误**（score 实际是 confidence，不是 relevance）
- AI 消费者看到的 score=0.7 以为是"70% 相关"，实际是"70% 置信度"

## 深度调研

### 行业共识（11 个系统 + 1 篇原始论文）

| 系统 | RRF 分数处理 | 归一化方式 |
|------|-------------|-----------|
| **Cormack 2009 原始论文** | 排序用，不暴露 | 无 |
| **Elasticsearch** | 暴露为 `_score`，后推出 Linear Retriever 替代方案 | Linear: MinMax 到 [0,1] |
| **OpenSearch** | 暴露原始 RRF 分数 | 替代方案: normalization processor |
| **Weaviate** | 默认已切换到 RelativeScoreFusion | MinMax: `(s-min)/(max-min)` |
| **Qdrant** | RRF 暴露原始分数，推 DBSF 替代 | DBSF: `mean±3σ` 统计归一化 |
| **Azure AI Search** | 暴露 `@search.score` + debug 子分数 | 无归一化，但文档化理论上限 |
| **MongoDB Atlas** | `$rankFusion`(排序) vs `$scoreFusion`(分数) | RSF: 归一化后加权求和 |
| **Milvus** | WeightedRanker vs RRFRanker 明确分离 | WeightedRanker: MinMax |
| **LangChain** | 不暴露 RRF 分数，仅用于排序 | 无 |
| **Vespa** | 可组合: `normalize_linear()` + `reciprocal_rank()` | 按需组合 |
| **Haystack** | 原始分数被社区认为"not very useful"，后修复 | PR #5704: 加权+归一化 |

**关键发现**: Haystack 的教训最直接 — 他们发布了原始 RRF 分数，社区反馈"不实用"，最终修补了归一化。

### Confidence vs Relevance（学术文献 + 生产系统）

| 系统 | Confidence 角色 | Relevance 角色 | 组合方式 |
|------|----------------|---------------|---------|
| **CRAG** | 三态过滤器(Correct/Ambiguous/Incorrect) | 文档排序 | 分离(过滤+排序) |
| **Generative Agents** | 加权求和组件("importance") | 加权求和组件 | `α*R + α*I + α*Rel` |
| **Mem0** | 独立基础设施(衰减/冲突解决) | 主检索信号 | 两阶段: 先检索再重排 |
| **Zep/Graphiti** | 时间有效性过滤 | RRF 融合 3 种搜索 | 过滤 + 多阶段重排 |
| **Google** | 静态 Q* 分数(加性提升) | 动态 Needs Met | 加性(质量提升相关性) |
| **Elasticsearch** | 加性提升 via function_score | BM25/vector 主排序 | `BM25 + quality/(k+quality)` |

**行业共识**: confidence 应该是**阈值过滤器**，不是排序信号。relevance (RRF score) 驱动排序。

### index1 当前 Corpus 分数流（正确实现，可参考）

```
BM25 结果 ─┐
            ├─→ _rrf_fusion_multi() ─→ raw RRF [0, ~0.05]
Vec 结果  ──┘                               │
                                   可选 cross-encoder rerank
                                            │
                                   可选 graph boost
                                            │
                                   score / max_score ─→ [0, 1]
                                            │
                                   SearchResult.score ─→ CodeItem.score
```

## 修复方案

### 方案选择

对比三种归一化方案：

| 方案 | 公式 | 优点 | 缺点 |
|------|------|------|------|
| **A. MaxScore 归一化** (与 corpus 一致) | `score / max_score` | 与 corpus 行为一致；top=1.0 直觉 | 只有一个结果时 top=1.0 |
| B. MinMax 归一化 | `(s-min)/(max-min)` | 更好的分布 | 最低也是 0.0，信息量略低 |
| C. 理论上限归一化 | `score / (N_branches / (k+1))` | 跨运行可比 | 分数偏小，不直觉 |

**选择方案 A**: MaxScore 归一化 — 与 corpus search.py:328 行的逻辑一致，保持两个搜索模块的分数语义统一。

### 改动清单

#### 1. `search_cog.py:_rrf_merge()` — 传播 RRF 分数

```python
def _rrf_merge(bm25, vec, k=60):
    scores: dict[int, float] = {}
    facts_map: dict[int, dict] = {}
    for rank, f in enumerate(bm25):
        fid = f["id"]
        scores[fid] = scores.get(fid, 0) + 1.0 / (k + rank + 1)
        facts_map.setdefault(fid, f)  # 保留先到版本（防御性编程）
    for rank, f in enumerate(vec):
        fid = f["id"]
        scores[fid] = scores.get(fid, 0) + 1.0 / (k + rank + 1)
        facts_map.setdefault(fid, f)
    sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
    # 浅拷贝 + 附加 _rrf_score，不污染上游 dict
    return [{**facts_map[fid], "_rrf_score": scores[fid]} for fid in sorted_ids]
```

**要点**:
- `setdefault` 保留先到版本，避免 Vec 覆盖 BM25 的路径特有字段
- 浅拷贝 `{**dict}` 避免污染上游缓存的 fact dict

#### 2. `search_cog.py:recall()` — 非融合路径附加 rank-based score

```python
# NOTE: 单路分数上限 1/(k+1) ≈ 0.0164，融合路径上限 2/(k+1) ≈ 0.0328，
# MaxScore 归一化会抹平差异，但跨路径比较时需注意这个 trade-off。
if merged and "_rrf_score" not in merged[0]:
    merged = [
        {**f, "_rrf_score": 1.0 / (60 + rank + 1)}
        for rank, f in enumerate(merged)
    ]
```

#### 3. `search_cog.py:recall()` — MaxScore 归一化

```python
# MaxScore 归一化（与 corpus search.py:328 一致）
# 已知行为: 单结果时 score=1.0，即使匹配较弱
if filtered:
    max_rrf = filtered[0].get("_rrf_score", 0)
    if max_rrf > 0:
        for f in filtered:
            f["_rrf_score"] = round(f.get("_rrf_score", 0) / max_rrf, 4)
```

#### 4. `unified_search.py:_search_cognition()` — 使用 RRF 分数

```python
# score = RRF relevance（由 search_cog._rrf_merge 计算 + MaxScore 归一化）
# confidence = 事实质量（静态属性，仅用于阈值过滤，不参与排序）
# 依赖: recall() 返回的 fact dict 上的 _rrf_score 内部字段
items.append(FactItem(
    id=f["id"],
    score=round(f.get("_rrf_score", 0.5), 2),
    assertion=f.get("assertion", ""),
    category=f.get("category", "code"),
    confidence=f.get("confidence", 0.5),
))
```

#### 5. 内部字段处理

`_rrf_score` 保留在 fact dict 中（`_` 前缀已表明是内部字段）。
`_search_cognition` 依赖此字段（隐式耦合，注释已标注）。
MCP recall 工具返回时只取显式字段，不会泄漏。

### 不改动的部分

- **confidence 阈值过滤** (`>= 0.3`) — 正确，保持不变
- **FactItem.confidence 字段** — 保留，让消费者可以看到质量信号
- **排序逻辑** — 已经正确，本次只修复暴露的分数值
- **RRF k 值** — 保持 k=60，与行业标准一致（k 可配置化留给后续 issue）

### 已知行为（不修复）

1. **单结果满分**: MaxScore 归一化下，唯一结果 score=1.0。AI 消费者应从结果数量判断匹配强度
2. **跨路径分数不一致**: 非融合路径分数上限是融合路径的一半，但 MaxScore 归一化抹平了差异

## 任务清单

| # | 任务 | 状态 | 预估 |
|---|------|------|------|
| 1 | `_rrf_merge()` 浅拷贝+setdefault+附加 `_rrf_score` | ✅ | 5 min |
| 2 | `recall()` 非融合路径浅拷贝+附加 score + 注释 trade-off | ✅ | 5 min |
| 3 | `recall()` MaxScore 归一化 | ✅ | 5 min |
| 4 | `_search_cognition()` 读 `_rrf_score` + 注释依赖关系 | ✅ | 2 min |
| 5 | 补测试: RRF 分数传播 + 归一化 (9 个新测试) | ✅ | 15 min |
| 6 | 运行全量测试 (1127 passed) | ✅ | 5 min |
| 7 | MCP recall tool description 补充 score/confidence 说明 | ✅ | 5 min |

## 设计决策

| 决策 | 选择 | 原因 |
|------|------|------|
| 归一化方式 | MaxScore(`score/max_score`) | 与 corpus search.py:328 一致 |
| 分数传递方式 | fact dict 附加 `_rrf_score` 字段 | 最小改动，不改 RecallResult 接口 |
| confidence 处理 | 保持阈值过滤，不参与排序 | CRAG/Zep/Elasticsearch 行业共识 |
| 内部字段清理 | 不清理，`_` 前缀表示内部 | MCP 返回时已只取显式字段 |

## 参考文献

- [Cormack et al. 2009 — RRF 原始论文](https://dl.acm.org/doi/10.1145/1571941.1572114)
- [Elasticsearch — Weighted RRF + Linear Retriever](https://www.elastic.co/search-labs/blog/weighted-reciprocal-rank-fusion-rrf)
- [Weaviate — RelativeScoreFusion 替代 RRF](https://weaviate.io/blog/hybrid-search-fusion-algorithms)
- [Haystack — RRF 归一化 Issue #5551](https://github.com/deepset-ai/haystack/issues/5551)
- [CRAG — Confidence 三态过滤器](https://arxiv.org/abs/2401.15884)
- [Generative Agents — 加权求和记忆检索](https://ar5iv.labs.arxiv.org/html/2304.03442)
- [Mem0 — 两阶段检索: relevance + criteria](https://docs.mem0.ai/platform/features/criteria-retrieval)
- [Zep/Graphiti — 多阶段 RRF 重排](https://arxiv.org/abs/2501.13956)
- [Azure AI Search — RRF 分数范围文档化](https://learn.microsoft.com/en-us/azure/search/hybrid-search-ranking)
- [MongoDB Atlas — $rankFusion vs $scoreFusion](https://www.mongodb.com/docs/atlas/atlas-vector-search/hybrid-search/)
- [Qdrant — DBSF 统计归一化](https://qdrant.tech/documentation/concepts/hybrid-queries/)
