fix(r4-28): 检索相关性下限,无关问题空 sources

_select_graph_balanced 引入相关性下限 0.25(score_gate.py 具名常量,
env QA_MIN_SOURCE_SCORE 可覆盖):低于下限的候选不入选,全部低于下限
返回空;graph 配额语义不变,audit 增 min_source_score/below_min_source_score。
Qdrant 查询(direct_flow_retriever/v16_retriever 两处)加同源 score_threshold
双保险(collection distance=Cosine,0.25 与垃圾实测带 0.19-0.20 留余量、
远低于正常命中 >0.7)。pc_retriever 为禁用后端,零改动。
新增 test_score_gate.py:30 低->0、5 高+25 低->5、graph 配额、env 覆盖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:18:56 +08:00
co-authored by Claude Fable 5
parent 00eb0bc1d0
commit 28bee31d00
5 changed files with 171 additions and 0 deletions
@@ -16,6 +16,7 @@ from src.rag.normalized_release import (
)
from .schemas import EvidenceItem, RetrievalHypothesis
from .score_gate import min_source_score
ALLOWED_QDRANT_COLLECTIONS = {
"plant_srt_direct_v1_bge_m3_20260702",
@@ -203,11 +204,14 @@ class DirectFlowRetriever:
def qdrant_search(self, query: str, limit: int) -> List[EvidenceItem]:
try:
vector = self.embed(query)
# 缺陷 #28(2026-09-11):相关性下限双保险(与 pipeline
# _select_graph_balanced 同源同值,见 score_gate.py)。
response = self.qdrant.query_points(
collection_name=self.collection,
query=vector,
limit=limit,
with_payload=True,
score_threshold=min_source_score(),
)
except Exception:
return []
+11
View File
@@ -43,6 +43,7 @@ from .question_decomposition import QuestionDecompositionPlanner
from .query_builder import QueryBuilder
from .reranker import BGEReranker
from .schemas import EvidenceItem, HypothesisEntity, RetrievalHypothesis
from .score_gate import min_source_score
from .soft_gate_policy import (
LEGACY,
doi_from_paper_id,
@@ -3317,6 +3318,14 @@ class QAV1Pipeline:
top_k_for_llm: int,
graph_min_evidence: int,
) -> tuple[List[EvidenceItem], Dict[str, Any]]:
# 缺陷 #28(2026-09-11):相关性下限。低于下限的候选不入选;
# 全部低于下限 -> 返回空(前端呈现未命中,与 Qdrant
# score_threshold 双保险)。阈值见 score_gate.py(env 可覆盖)。
_min_score = min_source_score()
_below_min = len(ranked) - sum(
1 for item in ranked if item.final_score >= _min_score
) # 被下限过滤的候选数(审计用,见 return 处)
ranked = [item for item in ranked if item.final_score >= _min_score]
graph_items = [item for item in ranked if cls._is_graph_item(item)]
other_items = [item for item in ranked if not cls._is_graph_item(item)]
selected: List[EvidenceItem] = []
@@ -3365,6 +3374,8 @@ class QAV1Pipeline:
"graph_selected": graph_selected,
"non_graph_selected": len(selected) - graph_selected,
"graph_quota_satisfied": graph_selected >= graph_min_evidence,
"min_source_score": _min_score,
"below_min_source_score": _below_min,
"note": "Graph quota is enforced after BGE rerank. If fewer graph candidates exist, all available graph candidates are retained and the deficit is reported.",
}
+53
View File
@@ -0,0 +1,53 @@
"""score_gate.py —— 检索相关性下限(缺陷 #28,2026-09-11)。
语义:候选证据的最终得分(EvidenceItem.final_score)低于下限即视为
「与问题无关」,不得进入 evidence_top30 / sources。
证据与阈值依据(2026-09-11 实测):
- 无关问题「景甜是谁」实测 30 条 final_score 全部 0.19-0.20(纯植物学
无关文献);正常命中(DISCOVERY.md §4.1 金标样本)vector_score=0.714、
rerank_score=0.989、final_score=0.982。
- BGE rerank 路径 final_score = 0.8*bge(sigmoid) + 0.2*rrf/max_rrf;
bge 是 sigmoid 概率,0.20 垃圾分对应 raw logit ≈ -1.4,0.25 对应 -1.1,
0.30 对应 -0.85,正常命中的 sigmoid 通常 >0.9。
- Qdrant collection distance=Cosine(score ∈ [-1,1];bge-m3 文本嵌入对
随机中文问题的 top-k 得分通常 <0.2)。
默认 0.25:与垃圾实测带(0.19-0.20)留 ~25% 余量,又显著低于正常命中
(>0.7),同时为 0.2~0.3 边缘问题保留再排序机会(阈值过滤器只是预筛,
被过滤者已低于下限,保留也无意义)。
env `QA_MIN_SOURCE_SCORE` 可覆盖(0-1,非法值回落默认),供复测按
acceptance-r4 §8.3 授权临时调参。
本模块无第三方依赖、无 IO,可被 v16_retriever / direct_flow_retriever /
pipeline 直接 import(编译期与运行期均安全)。
"""
from __future__ import annotations
import os
# 相关性下限(与 Qdrant score_threshold 同源同值,双保险)
DEFAULT_MIN_SOURCE_SCORE = 0.25
_ENV_KEY = "QA_MIN_SOURCE_SCORE"
def _env_override() -> float:
"""env 覆盖:QA_MIN_SOURCE_SCORE ∈ [0,1] 才生效,否则回落默认。"""
raw = os.environ.get(_ENV_KEY, "").strip()
if not raw:
return DEFAULT_MIN_SOURCE_SCORE
try:
value = float(raw)
except ValueError:
return DEFAULT_MIN_SOURCE_SCORE
if not (0.0 <= value <= 1.0):
return DEFAULT_MIN_SOURCE_SCORE
return value
def min_source_score() -> float:
"""当前生效的相关性下限(模块导入期缓存 env 值,与部署环境一致)。"""
return min_source_score.value
min_source_score.value = _env_override() # type: ignore[attr-defined]
@@ -0,0 +1,98 @@
"""test_score_gate.py —— 相关性下限选择单测(缺陷 #28,acceptance-r4 §2.4)。
运行(venv 内,源码树):
cd agent/qa/src/rag/qa_v1_1_73
python -m pytest test_score_gate.py -v
(编译期 compile_qa.sh 会把全部 test_*.py 一并编进 .so 树,部署时不冲突;
正式单测都在源码树跑。)
覆盖:
- 30 条全部低于下限 -> 选入 0(acceptance 桩)
- 5 高 + 25 低 -> 仅选入 5(遵循现有配额逻辑)
- graph 配额不受影响;低于下限的 graph 候选不入选
- env 覆盖 QA_MIN_SOURCE_SCORE 生效与非法值回落
"""
from __future__ import annotations
import sys
from pathlib import Path
# 本文件位于 agent/qa/src/rag/qa_v1_1_73/,parents[2] = agent/qa/src
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from rag.qa_v1_1_73 import score_gate # noqa: E402
from rag.qa_v1_1_73.schemas import EvidenceItem # noqa: E402
from rag.qa_v1_1_73.pipeline import QAV1Pipeline # noqa: E402
def _item(idx: int, *, final: float, graph: bool = False, key: str = "") -> EvidenceItem:
return EvidenceItem(
evidence_id=f"E{idx}",
kb="V16",
source=f"src{idx}",
relation="has",
target=f"tgt{idx}",
evidence=f"evidence body {idx}",
relation_id=(key or f"rid-{idx}"),
retrieval_methods=(["neo4j_bounded_expand"] if graph else ["qdrant_vector"]),
final_score=final,
)
def _select(items, *, top_k=30, graph_min=0):
selected, audit = QAV1Pipeline._select_graph_balanced(
items, top_k_for_llm=top_k, graph_min_evidence=graph_min
)
return selected, audit
def test_all_below_threshold_selects_zero():
"""acceptance §2.4:30 条全部低于下限 -> 选入 0。"""
items = [_item(i, final=0.20) for i in range(30)]
selected, audit = _select(items)
assert selected == []
assert audit["policy"].startswith("graph_balanced_top30")
assert audit["below_min_source_score"] == 30
assert audit["min_source_score"] == score_gate.min_source_score()
def test_five_high_twentyfive_low_selects_only_five():
"""acceptance §2.4:5 高 + 25 低 -> 仅选入 5(沿用现有配额与 rerank 序)。"""
items = [_item(i, final=0.90 - i * 0.01) for i in range(5)]
items += [_item(5 + i, final=0.20) for i in range(25)]
selected, audit = _select(items)
assert len(selected) == 5
assert {item.evidence_id for item in selected} == {f"E{i}" for i in range(5)}
# 顺序保持 rerank 序
assert [item.evidence_id for item in selected] == [f"E{i}" for i in range(5)]
assert audit["below_min_source_score"] == 25
def test_below_threshold_graph_candidates_excluded():
"""低分 graph 候选不占用 graph 配额(配额逻辑语义不变)。"""
items = [
_item(1, final=0.30, graph=True),
_item(2, final=0.21, graph=True),
_item(3, final=0.95, graph=False),
_item(4, final=0.19, graph=False),
]
selected, audit = _select(items, top_k=30, graph_min=1)
assert [item.evidence_id for item in selected] == ["E1", "E3"]
assert audit["graph_selected"] == 1
assert audit["below_min_source_score"] == 2
def test_env_override_and_invalid_fallback(monkeypatch):
"""env 解析函数行为:合法值生效;非法值/越界回落默认。"""
monkeypatch.setenv("QA_MIN_SOURCE_SCORE", "0.35")
assert score_gate._env_override() == 0.35
monkeypatch.setenv("QA_MIN_SOURCE_SCORE", "abc")
assert score_gate._env_override() == score_gate.DEFAULT_MIN_SOURCE_SCORE
monkeypatch.setenv("QA_MIN_SOURCE_SCORE", "1.5")
assert score_gate._env_override() == score_gate.DEFAULT_MIN_SOURCE_SCORE
monkeypatch.delenv("QA_MIN_SOURCE_SCORE", raising=False)
# 生效值(value 属性)驱动 _select_graph_balanced 的过滤
monkeypatch.setattr(score_gate.min_source_score, "value", 0.35)
items = [_item(i, final=0.30) for i in range(5)]
selected, _ = _select(items)
assert selected == []
@@ -9,6 +9,7 @@ from typing import Any, Dict, List
import requests
from neo4j import GraphDatabase
from qdrant_client import QdrantClient
from .score_gate import min_source_score
TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{2,}|[\u4e00-\u9fff]{2,}")
@@ -100,11 +101,15 @@ class V16IndependentRetriever:
self, question: str, limit: int, include_candidate: bool
) -> List[Dict[str, Any]]:
try:
# 缺陷 #28(2026-09-11):相关性下限双保险(与 pipeline
# _select_graph_balanced 同源同值)。collection distance=Cosine,
# 阈值语义 = 余弦相似度下限;低于下限的点直接不返回。
response = self.qdrant.query_points(
collection_name=self.collection,
query=self.embed(question),
limit=limit * 2,
with_payload=True,
score_threshold=min_source_score(),
)
except Exception:
return []