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:
@@ -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 []
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user