Files
PlantAI-rag/src/rag/qa_v1_1_73/agentic_graph_retriever.py
T
potaser 4fef3a65ac feat(qa): 断言锚定检索 + robustness 加固
- assertion_anchored_graph_retriever 断言锚定候选与 top30 合并
- pipeline/agentic_controller/llm_client 重试与 Q0 软回退
- question_decomposition JSON repair、citation_contract grouped 归一化
- api endpoint_path 全参数、retrieval 禁用 PC/SQLite、canonical-only 校验
- bounded_canonical/direct_flow 白名单、subquestion_orchestrator required_review
- scripts/qa_v1_1_73_server.py 新服务入口(backend_guard + gate_policy)
- 旧 start_qa_v1_1_73_*.sh 移入 docs/legacy_startup_scripts/
2026-08-29 12:39:25 +08:00

362 lines
15 KiB
Python

from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Tuple
from neo4j import GraphDatabase
from src.rag.dual_kb_taxonomy_rag.entity_alias import EntityAliasNormalizer
from .schemas import EvidenceItem
def _text(value: Any) -> str:
return str(value or "").strip()
def _norm(value: Any) -> str:
return " ".join(_text(value).lower().replace("_", " ").split())
def _type(value: Any) -> str:
return _norm(value).replace(" ", "_")
def _pmcid(value: Any) -> str:
match = re.search(r"\bPMC\d+", _text(value), re.IGNORECASE)
return match.group(0).upper() if match else ""
class AgenticGraphRetriever:
def __init__(
self,
driver: Any | None = None,
*,
graph_schema: str | None = None,
alias_path: str | Path | None = None,
):
self._owns_driver = driver is None
self.graph_schema = (
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical")
).strip().lower()
resolved_alias_path = alias_path or (
Path(__file__).resolve().parents[3] / "configs/rag/entity_alias.yaml"
)
self.aliaser = EntityAliasNormalizer(resolved_alias_path)
self.driver = driver or GraphDatabase.driver(
os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788"),
auth=(
os.environ.get("NEO4J_USER", "neo4j"),
os.environ.get("NEO4J_PASSWORD", ""),
),
)
def close(self) -> None:
if self._owns_driver and self.driver is not None:
self.driver.close()
def retrieve(
self, plan: Dict[str, Any]
) -> Tuple[List[EvidenceItem], List[Dict[str, Any]], Dict[str, Any]]:
anchors = [item for item in plan.get("anchors") or [] if isinstance(item, dict)]
anchor_ids = [
_text(item.get("canonical_id"))
for item in anchors
if _text(item.get("canonical_id")).startswith("cent_")
]
anchor_names = []
for item in anchors:
for value in (item.get("canonical_name"), item.get("mention")):
normalized = _norm(value)
if normalized and normalized not in anchor_names:
anchor_names.append(normalized)
canonical_name = _text(item.get("canonical_name"))
alias_entry = self.aliaser.entries.get(canonical_name) or {}
for value in alias_entry.get("aliases") or []:
normalized = _norm(value)
if normalized and normalized not in anchor_names:
anchor_names.append(normalized)
relation_plan = plan.get("relation_plan") if isinstance(plan.get("relation_plan"), dict) else {}
graph_plan = plan.get("graph_plan") if isinstance(plan.get("graph_plan"), dict) else {}
allow_incoming = bool(relation_plan.get("allow_incoming", True))
allow_outgoing = bool(relation_plan.get("allow_outgoing", True))
terminal_types = {
_type(value)
for value in (
graph_plan.get("required_terminal_types")
or [
entity_type
for target in plan.get("answer_targets") or []
for entity_type in target.get("allowed_entity_types") or []
]
)
if _type(value)
}
terminal_type_wildcard = "unknown" in terminal_types
# Planner uses "unknown" to mean that no terminal type is constrained.
terminal_types.discard("unknown")
relation_categories = {
_norm(value)
for value in relation_plan.get("allowed_relation_categories") or []
if _norm(value)
}
audit: Dict[str, Any] = {
"enabled": bool(graph_plan.get("enabled", True)),
"anchor_ids": anchor_ids,
"anchor_names": anchor_names,
"allow_incoming": allow_incoming,
"allow_outgoing": allow_outgoing,
"required_terminal_types": sorted(terminal_types),
"terminal_type_wildcard": terminal_type_wildcard,
"max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)),
"query_mode": (
"legacy_anchor_neighborhood"
if self.graph_schema == "legacy"
else "canonical_anchor_neighborhood"
),
"preserve_assertion_direction": True,
"filtered_reasons": {},
}
if not audit["enabled"]:
audit["status"] = "disabled_by_plan"
return [], [], audit
if not anchor_ids and not anchor_names:
audit["status"] = "no_verified_anchor"
return [], [], audit
if not allow_incoming and not allow_outgoing:
audit["status"] = "no_direction_enabled"
return [], [], audit
rows = self.driver.execute_query(
self._statement(),
parameters_={
"anchor_ids": anchor_ids,
"anchor_names": anchor_names,
"limit": 100 if self.graph_schema == "legacy" else 300,
"layers": [
"main_kg",
"background_relations",
"method_relations",
"methodology_relations",
"dataset_metadata",
],
},
result_transformer_=lambda result: [dict(record) for record in result],
)
rows = list(rows or [])
evidence: List[EvidenceItem] = []
chains: List[Dict[str, Any]] = []
seen_assertions = set()
def filtered(reason: str) -> None:
counts = audit["filtered_reasons"]
counts[reason] = int(counts.get(reason) or 0) + 1
for row in rows:
assertion_id = _text(row.get("assertion_id"))
if not assertion_id:
filtered("missing_assertion_id")
continue
if assertion_id in seen_assertions:
filtered("duplicate_assertion")
continue
direction = _norm(row.get("direction"))
if (direction == "incoming" and not allow_incoming) or (
direction == "outgoing" and not allow_outgoing
):
filtered("direction_disabled")
continue
if direction not in {"incoming", "outgoing"}:
filtered("unknown_direction")
continue
answer_entity = row.get("source") if direction == "incoming" else row.get("target")
answer_type = row.get("source_type") if direction == "incoming" else row.get("target_type")
if terminal_types and _type(answer_type) not in terminal_types:
filtered("terminal_type_mismatch")
continue
category = _norm(row.get("relation_category"))
if relation_categories and category not in relation_categories:
filtered("relation_category_mismatch")
continue
seen_assertions.add(assertion_id)
anchor_name = _text(row.get("anchor_name"))
paper_id = _text(row.get("paper_id"))
pmcid = _text(row.get("pmcid")) or _pmcid(paper_id)
raw = {
"assertion_id": assertion_id,
"source_type": _text(row.get("source_type")),
"target_type": _text(row.get("target_type")),
"source_canonical_id": _text(row.get("source_canonical_id")),
"target_canonical_id": _text(row.get("target_canonical_id")),
"relation_category": _text(row.get("relation_category")),
"navigation_direction": direction,
"anchor_id": _text(row.get("anchor_id")),
"anchor_name": anchor_name,
"answer_entity": _text(answer_entity),
"answer_entity_type": _type(answer_type),
"preserved_assertion_direction": True,
}
item = EvidenceItem(
evidence_id="",
kb="V16/V15",
source=_text(row.get("source")),
relation=_text(row.get("relation")),
target=_text(row.get("target")),
evidence=_text(row.get("evidence")),
paper_id=paper_id,
title=_text(row.get("title")),
pmid=_text(row.get("pmid")),
pmcid=pmcid,
doi=_text(row.get("doi")),
species=_text(row.get("species")),
output_layer="canonical_graph",
relation_id=assertion_id,
retrieval_methods=["agentic_anchor_graph", direction],
final_score=float(row.get("best_score") or 0.0),
raw=raw,
)
evidence.append(item)
hop = {
"hop_index": 1,
"from_id": _text(row.get("source_canonical_id")),
"from_name": _text(row.get("source")),
"to_id": _text(row.get("target_canonical_id")),
"to_name": _text(row.get("target")),
"source": _text(row.get("source")),
"source_type": _text(row.get("source_type")),
"source_canonical_id": _text(row.get("source_canonical_id")),
"relation": _text(row.get("relation")),
"relation_category": _text(row.get("relation_category")),
"target": _text(row.get("target")),
"target_type": _text(row.get("target_type")),
"target_canonical_id": _text(row.get("target_canonical_id")),
"assertion_id": assertion_id,
"paper_id": item.paper_id,
"title": item.title,
"pmid": item.pmid,
"pmcid": item.pmcid,
"doi": item.doi,
"species": item.species,
"evidence": item.evidence,
"support_count": int(row.get("support_count") or 0),
"paper_count": int(row.get("paper_count") or 0),
"best_score": float(row.get("best_score") or 0.0),
"navigation_direction": direction,
"preserved_assertion_direction": True,
}
chains.append(
{
"chain_id": f"AG{len(chains) + 1}",
"chain_source": "agentic_anchor_graph",
"hop_count": 1,
"path_score": float(row.get("best_score") or 0.0),
"start": anchor_name,
"end": _text(answer_entity),
"anchor_id": _text(row.get("anchor_id")),
"anchor_name": anchor_name,
"answer_entity": _text(answer_entity),
"answer_entity_type": _type(answer_type),
"navigation_direction": direction,
"preserve_assertion_direction": True,
"paper_id": item.paper_id,
"pmid": item.pmid,
"pmcid": item.pmcid,
"doi": item.doi,
"hops": [hop],
"score_breakdown": {
"anchor_entity_match": 1.0,
"answer_type_match": 1.0,
"direction_match": 1.0,
"final_score": float(row.get("best_score") or 0.0),
},
}
)
audit["raw_assertion_count"] = len(rows)
audit["returned_assertion_count"] = len(evidence)
audit["returned_chain_count"] = len(chains)
audit["status"] = "ok" if evidence else "no_matching_assertions"
return evidence, chains, audit
def _statement(self) -> str:
if self.graph_schema == "legacy":
return """
MATCH (anchor:Entity)
WHERE toLower(anchor.name) IN $anchor_names
OR toLower(coalesce(anchor.name_norm, anchor.name)) IN $anchor_names
WITH anchor LIMIT 24
MATCH (anchor)-[r:RELATION]-(neighbor:Entity)
WHERE r.output_layer IN $layers
WITH anchor, r, startNode(r) AS source_node, endNode(r) AS target_node,
CASE
WHEN endNode(r) = anchor THEN 'incoming'
ELSE 'outgoing'
END AS direction
RETURN coalesce(anchor.entity_id, anchor.id, '') AS anchor_id,
anchor.name AS anchor_name,
direction,
r.relation_id AS assertion_id,
source_node.name AS source,
source_node.entity_type AS source_type,
coalesce(source_node.entity_id, source_node.id, '') AS source_canonical_id,
coalesce(r.relation_norm, r.relation) AS relation,
'' AS relation_category,
target_node.name AS target,
target_node.entity_type AS target_type,
coalesce(target_node.entity_id, target_node.id, '') AS target_canonical_id,
r.evidence AS evidence,
coalesce(r.canonical_paper_id, r.paper_id, '') AS paper_id,
'' AS title,
coalesce(r.pubmed_id, '') AS pmid,
'' AS pmcid,
'' AS doi,
coalesce(r.species, '') AS species,
1 AS support_count,
1 AS paper_count,
coalesce(r.confidence, 0.0) AS best_score
ORDER BY CASE WHEN coalesce(r.evidence, '') = '' THEN 1 ELSE 0 END,
coalesce(r.confidence, 0.0) DESC
LIMIT $limit
"""
return """
MATCH (a:SRT2CanonicalAssertion)
WHERE a.source_canonical_id IN $anchor_ids
OR a.target_canonical_id IN $anchor_ids
OR toLower(a.source) IN $anchor_names
OR toLower(a.target) IN $anchor_names
WITH a,
CASE
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
THEN 'incoming'
ELSE 'outgoing'
END AS direction,
CASE
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
THEN a.target_canonical_id
ELSE a.source_canonical_id
END AS anchor_id,
CASE
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
THEN a.target
ELSE a.source
END AS anchor_name
RETURN anchor_id, anchor_name, direction,
a.assertion_id AS assertion_id,
a.source AS source, a.source_type AS source_type,
a.source_canonical_id AS source_canonical_id,
a.relation_norm AS relation, a.relation_category AS relation_category,
a.target AS target, a.target_type AS target_type,
a.target_canonical_id AS target_canonical_id,
a.best_evidence AS evidence, a.best_paper_id AS paper_id,
a.title AS title, a.pmid AS pmid, a.pmcid AS pmcid, a.doi AS doi,
'' AS species, a.support_count AS support_count,
a.paper_count AS paper_count, a.best_score AS best_score
ORDER BY a.paper_count DESC, a.support_count DESC, a.best_score DESC
LIMIT $limit
"""