fix(r4-28v4): chat 候选腐烂根治——Qdrant 条目 paper_id 派生 DOI/PMCID 回填引用

根因(TL 实测 + 只读探针确认):
175 归一化库 Neo4j ASSERTS 关系 13040841 条全无 evidence 属性
(_ONE_HOP Cypher 也不返回 evidence);而 175 无 paper metadata registry,
Qdrant payload 无 pmid/pmcid/doi 键 -> chat 模式 public_citation 过滤
把有正文的 qdrant 条目(120 条)全滤掉,只留无 evidence 的 neo4j 图断言
(136 条)-> BGE 复刻 _document 模板输入 evidence 空 -> logit=-3.07,
sigmoid=0.044 与审计 bge 0.020-0.047 完全吻合 -> 0.25 final 门全拒
sources=[]。176 正常是因为配了 registry 回填 pmid。
数字链:dedup 244 -> public_citation kept 136(全图断言)-> pre_rerank 160
-> cluster 136 代表 -> rerank 全垃圾分。

修复:item_from_payload 引用字段回填优先级 payload 显式 > registry >
paper_id 派生(doi_from_paper_id/pmcid_from_paper_id + doi: 前缀形态)。
r4 v1/v2/v3 的阈值语义不变;search 免 rerank 路径不受影响。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 17:17:25 +08:00
co-authored by Claude Fable 5
parent 0252d52425
commit d54419f040
2 changed files with 54 additions and 2 deletions
@@ -3,6 +3,7 @@ from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import json import json
import os import os
import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
@@ -16,6 +17,7 @@ from src.rag.normalized_release import (
) )
from .schemas import EvidenceItem, RetrievalHypothesis from .schemas import EvidenceItem, RetrievalHypothesis
from .soft_gate_policy import doi_from_paper_id, pmcid_from_paper_id
ALLOWED_QDRANT_COLLECTIONS = { ALLOWED_QDRANT_COLLECTIONS = {
"plant_srt_direct_v1_bge_m3_20260702", "plant_srt_direct_v1_bge_m3_20260702",
@@ -36,6 +38,12 @@ def _norm(value: Any) -> str:
return " ".join(text.split()) return " ".join(text.split())
# doi: 前缀形态 paper_id 的 DOI 全文校验(回改 v4,2026-09-11)
_DOI_LITERAL = re.compile(
r"10\.\d{4,9}/[A-Za-z0-9][A-Za-z0-9._;()/:+\-]*"
)
def load_paper_metadata_registry(path: Path) -> Dict[str, Dict[str, Any]]: def load_paper_metadata_registry(path: Path) -> Dict[str, Dict[str, Any]]:
records: Dict[str, Dict[str, Any]] = {} records: Dict[str, Dict[str, Any]] = {}
if not path.is_file(): if not path.is_file():
@@ -342,6 +350,20 @@ class DirectFlowRetriever:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
paper_id = _text(payload.get("paper_id") or payload.get("best_paper_id")) paper_id = _text(payload.get("paper_id") or payload.get("best_paper_id"))
registry_metadata = getattr(self, "paper_metadata", {}).get(paper_id, {}) registry_metadata = getattr(self, "paper_metadata", {}).get(paper_id, {})
# 缺陷 #28 回改 v4(2026-09-11):175 无 paper metadata registry,
# Qdrant payload 又无 pmid/pmcid/doi 键——chat 的 public_citation
# 过滤会把有正文的 qdrant 条目全滤掉,只剩无 evidence 的 neo4j
# 归一化图断言(bge 全 0.02-0.047),final 门 0.25 全拒 sources=[]。
# 从 paper_id 派生 DOI/PMCID 回填引用字段(payload 显式字段 >
# registry > paper_id 派生)。
_derived_doi = doi_from_paper_id(paper_id)
_derived_pmcid = pmcid_from_paper_id(paper_id)
# doi: 前缀形态(如 doi:10.1002/ps.6526)直接剥前缀(DOI 已带斜杠,
# doi_from_paper_id 不覆盖此形态)。
if not _derived_doi and paper_id.lower().startswith("doi:"):
_candidate = paper_id[4:].strip()
if _DOI_LITERAL.fullmatch(_candidate):
_derived_doi = _candidate
return EvidenceItem( return EvidenceItem(
evidence_id="", evidence_id="",
kb="V16/V15", kb="V16/V15",
@@ -352,8 +374,8 @@ class DirectFlowRetriever:
paper_id=paper_id, paper_id=paper_id,
title=_text(payload.get("title") or payload.get("paper_title") or metadata.get("title") or registry_metadata.get("title")), title=_text(payload.get("title") or payload.get("paper_title") or metadata.get("title") or registry_metadata.get("title")),
pmid=_text(payload.get("pmid") or payload.get("pubmed_id") or metadata.get("pmid") or registry_metadata.get("pmid")), pmid=_text(payload.get("pmid") or payload.get("pubmed_id") or metadata.get("pmid") or registry_metadata.get("pmid")),
pmcid=_text(payload.get("pmcid") or metadata.get("pmcid") or registry_metadata.get("pmcid")), pmcid=_text(payload.get("pmcid") or metadata.get("pmcid") or registry_metadata.get("pmcid") or _derived_pmcid),
doi=_text(payload.get("doi") or metadata.get("doi") or registry_metadata.get("doi")), doi=_text(payload.get("doi") or metadata.get("doi") or registry_metadata.get("doi") or _derived_doi),
species=_text(payload.get("species") or metadata.get("species")), species=_text(payload.get("species") or metadata.get("species")),
section=_text(payload.get("section") or metadata.get("section")), section=_text(payload.get("section") or metadata.get("section")),
output_layer=_text(payload.get("output_layer") or "main_kg"), output_layer=_text(payload.get("output_layer") or "main_kg"),
@@ -294,6 +294,36 @@ def test_direct_flow_retriever_threshold_passthrough():
assert r.qdrant.calls[-1]["score_threshold"] is None assert r.qdrant.calls[-1]["score_threshold"] is None
def test_item_from_payload_derives_citation_from_paper_id():
"""回改 v4 根因单测:Qdrant payload 无 pmid/pmcid/doi 时从 paper_id
派生 DOI——chat 模式 public_citation 过滤不得把有正文的 qdrant 条目
全滤掉(此前 rerank 输入全是无 evidence 的 neo4j 图断言,bge 全
0.02-0.047,final 门 0.25 全拒 sources=[])。"""
import rag.qa_v1_1_73.direct_flow_retriever as dfr_mod
r = dfr_mod.DirectFlowRetriever.__new__(dfr_mod.DirectFlowRetriever)
r.paper_metadata = {}
# 内部 DOI 形态
it = r.item_from_payload(
{"paper_id": "10.3390_f12050650", "source": "s", "relation": "r",
"target": "t", "evidence": "e"},
"qdrant_vector", "q",
)
assert it.doi == "10.3390/f12050650"
assert it.retrieval_methods == ["qdrant_vector"]
# doi: 前缀形态
it2 = r.item_from_payload(
{"paper_id": "doi:10.1002/ps.6526", "source": "s", "relation": "r",
"target": "t", "evidence": "e"},
"qdrant_vector", "q",
)
assert it2.doi == "10.1002/ps.6526"
# 派生出的 DOI 能通过 public_citation 校验(过滤不再误杀)
from rag.qa_v1_1_73.citation_contract import public_citation
cite = public_citation(evidence_id="", pmid="", pmcid="", doi=it.doi)
assert cite == "[DOI: 10.3390/f12050650]"
def test_env_overrides(monkeypatch): def test_env_overrides(monkeypatch):
monkeypatch.setenv("QA_MIN_VECTOR_SCORE", "0.5") monkeypatch.setenv("QA_MIN_VECTOR_SCORE", "0.5")
assert score_gate._env_value("QA_MIN_VECTOR_SCORE", 0.45) == 0.5 assert score_gate._env_value("QA_MIN_VECTOR_SCORE", 0.45) == 0.5