diff --git a/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py b/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py index ca793a4..196704a 100644 --- a/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py +++ b/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py @@ -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 [] diff --git a/agent/qa/src/rag/qa_v1_1_73/pipeline.py b/agent/qa/src/rag/qa_v1_1_73/pipeline.py index 1dbdf48..5f3f590 100644 --- a/agent/qa/src/rag/qa_v1_1_73/pipeline.py +++ b/agent/qa/src/rag/qa_v1_1_73/pipeline.py @@ -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.", } diff --git a/agent/qa/src/rag/qa_v1_1_73/score_gate.py b/agent/qa/src/rag/qa_v1_1_73/score_gate.py new file mode 100644 index 0000000..2b1c65a --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/score_gate.py @@ -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] diff --git a/agent/qa/src/rag/qa_v1_1_73/test_score_gate.py b/agent/qa/src/rag/qa_v1_1_73/test_score_gate.py new file mode 100644 index 0000000..a46ea4e --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_score_gate.py @@ -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 == [] diff --git a/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py b/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py index 1e92d3f..a03b50d 100644 --- a/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py +++ b/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py @@ -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 []