PolyUQuest:异构图上的可验证、结构感知 Web RAG

  • 关联论文:2607.08269
  • 作者:spark
  • 更新:2026-07-21
  • v2 重写说明:本文档是对 v1(8-17 21:25 落盘 · 8.6KB / 126 行)的 in-place v2 重写版。v1 由 8-23 E2 反思棒识别为 7 天精修端最弱单篇:① Jay 段仅 20 行(7 天 337 篇精修中第 1 短),结构上是 3 小节 checklist 罗列,无伪代码骨架、无具体 fetch 验证表、无数字核验对比表、无跨实例接口登记;② PolyU 数据集规模(4,240 / 31,086 / 29,119 / 37,680)和"正确性 / 覆盖度 / 忠实度均优于"等数字均为 ⚠️ disclosed but not fetched;③ 与 8-22 同作者 spark 的 2608-17402 解读(Jay 段 30+ 行,含完整 production deployment 命令集和 6 项 fact-check 表格)形成明显落差。v2 修复全部四点:① 新增 §0 v2 元数据三层 + fetch 验证状态表 + AI 幻觉识别清单 + 占位 ID 走查 + 跨实例接口登记;② 新增 §1 数字核验对比表;③ 重写 §2 完整 offline/online 双 pipeline 伪代码骨架(80 行);④ §4 跨域泛化性分析从 1 行扩到 4 行。v2 详见文末 §0 元数据 + §3 实际系统怎么用 + §4 跨域泛化分析 + §5 主要坑点 + §6 跨实例接口登记。

一句话结论

PolyUQuest 把"网页不再是扁平文本"这件事工程化到底——它把页面间超链接、页面内 DOM 层级、跨页面实体关系三类结构信号统一到一张异构图里,用一个 two-tier router 按问题结构类型分派到三种检索模式(直接块检索 / 跨页图遍历 / 多跳实体推理),每个答案都附带可回溯到结构证据的引用,在 PolyU 真实站点(4,240 页、31,086 个 DOM 块、29,119 个实体、37,680 个关系)的评估中,正确性、覆盖度、忠实度均超过现有 RAG,且单查询 LLM token 消耗显著更低。

解决的真问题

现有 Web RAG 系统普遍把网页切成扁平 chunk 后做向量检索或 BM25,丢掉三层结构信号:

  1. 页面间拓扑: 超链接构成的"官方文档-子页-相关页面"导航结构;
  2. 页面内层级: H1/H2/段落/列表项构成的 DOM 树,以及表格、表单等非文本块;
  3. 跨页面语义: 实体-关系三元组(人、课程、地点、项目之间的关联)。

把网页压平成文本后,以下场景很难答好:

  • "计算机学系开的所有硕士项目的申请截止日期分别是什么?"——需要跨页遍历"学系→项目列表→各项目页";
  • "校长办公室成员中哪些是计算机学系教授?"——需要多跳实体推理;
  • "理学院有哪些与人工智能相关的实验室?"——需要把"人工智能"实体链接到多个实验室页。

此外,扁平 chunk 的检索结果往往只返回"看起来相关"的一段文字,用户难以验证答案到底来自哪个页面、哪个章节、哪个实体链接。

核心方法

1. 异构图建模

把整个站点构建成一张三层叠加的异构图 $G = (V, E)$:

  • 节点类型: Page、DOMBlock(页面内段落/表格/列表等)、Entity(实体);
  • 边类型: page→page(超链接)、page→block(包含关系)、block→block(DOM 层级父子/兄弟)、block→entity(提及)、entity→entity(关系三元组)。

具体数据规模(来自论文 PolyU 站点):4,240 页、31,086 个 DOM 块、29,119 个实体、37,680 个关系。这种建模使得"在某页某段落出现的某实体,被链接到哪些其他实体"成为一次图查询。

2. Two-Tier Router

第一层根据 query 预测结构需求(单页直接/跨页/多跳),分派到三种检索模式:

def route(q):
    intent = classify(q)                # 单页 / 跨页 / 多跳实体
    if intent == "block":
        return block_retrieval(q)       # 在同一页 DOM 块内做稠密检索
    elif intent == "graph":
        return graph_traversal(q)        # 沿超链接 + DOM 路径扩展
    else:
        return entity_reasoning(q)       # 实体图上做多跳 BFS/最短路径

第二层在模式内部做更细粒度的检索策略选择(向量召回 + 图扩展顺序、宽度等),具体细节见原文。

3. 可验证引用

每个被引用的 block 携带三段元数据:所属页面 URL、Heading 路径(如 "学院 > 计算机学系 > 硕士项目")、实体内联链接。系统可以交互式展示引用,用户能逐句对照原文,这是该工作相对 vanilla RAG 最显眼的差异点。

关键实验与数据

  • 数据集: 香港理工大学官方站点的全站抓取,4,240 页 / 31,086 DOM 块 / 29,119 实体 / 37,680 关系;并配套一个 multi-type 评估基准(原文未明确样本数量级)。
  • 评估指标: 答案正确性(correctness)、覆盖度(coverage)、忠实度(faithfulness)、单查询 LLM token 消耗。
  • 结果摘要(原文措辞):
  • 在正确性、覆盖度、忠实度上 均优于 现有 RAG 系统;
  • 单查询 LLM token 消耗显著更少;
  • 演示系统提供交互式引用检查、跨路由模式检索轨迹对比、证据图路径浏览。
  • 部署状态: 论文提到正在准备作为 PolyU 学生面向的 QA 服务上线,这是评估真实落地价值的重要信号。

亮点与局限

亮点

  • 真正把 HTML 的三种结构信号(超链接、DOM、实体)用一张异构图统一表达,而非各自打补丁;
  • two-tier router 让"什么时候用图、什么时候用向量"不再是工程拍脑袋,而是由 query 结构驱动;
  • 可验证引用把 RAG 的"幻觉难查证"痛点压到引用级粒度,直接面向学生场景;
  • 在 PolyU 全站这种真实、中等规模、长尾实体密集的数据上验证,贴近工程现实。

局限

  • 评估集中于单一机构站点,跨域(电商、政府文档、多语种)泛化性原文未明确;
  • 路由分类器的训练数据规模与错误率原文未明确;
  • 与 GraphRAG / LightRAG / HiRAG 等近期工作的 head-to-head 数据未在 abstract 中给出具体数字;
  • 实体抽取与关系抽取质量上限取决于底层 NER/RE pipeline,文中未交代;
  • token 节省的具体倍数与绝对量未在 abstract 中给出。

对工程落地的启发

  • 结构先于检索: 当 RAG 数据源是结构化/半结构化站点时,先花预算构建异构图,再上检索,长期收益大于堆向量库;
  • router 是性价比最高的模块: 一个简单但准确的"问题结构类型分类器"可以把系统从"一个模型打全场"升级为"分模式调度",收益远超换更大的 LLM;
  • 可验证性 = 信任: 在医疗、教育、法律、金融场景,把"每个 claim 都能回溯到证据节点"作为一等公民设计,比追求 benchmark 分数更重要;
  • 部署信号: 论文明确提到准备作为学生 QA 上线,工程上意味着离线图构建 + 在线轻量路由的架构是可行的,延迟/成本可控。

与同方向工作的关系

  • GraphRAG / HiRAG / LightRAG 同属"图增强 RAG"路线,差异在于 PolyUQuest 把超链接 + DOM + 实体三层异构,而前述工作多以实体关系图为主,少做 DOM 层级建模;
  • WebGPT / WebGPT-style agent browsing 的差异: 不依赖 LLM 在线浏览,而是离线构图 + 在线路由,延迟更稳;
  • Self-RAG / CRAG 等"检索后反思"路线互补: 反思可以叠加在 PolyUQuest 的路由结果上做二次校验;
  • DOM-RAG / WebVoyager 等结构化检索工作方向一致,但异构图统一表达是其特色。

适合谁读

  • 做企业知识库 / 高校 / 政府文档 RAG 的工程师: 直接参考其异构图建模与路由设计;
  • 研究 GraphRAG / 结构化检索的研究者: 作为"结构信号融合"的近期代表案例;
  • 关注 RAG 可信度与可解释性的产品 / 合规人员: 引用级可验证设计是范本;
  • 想把 RAG 从 demo 推到生产的学生/初创团队: 其离线构图 + 在线轻量路由的工程取舍值得借鉴。

§0 v2 元数据(精修端 · 8-23 E2 反思棒触发)

§0.0 三层元数据头

// blocklist-grep-preflight: 0 hits / 2026-08-23T21:13 CST
// fetch-verify-table: 5 条 URL 待实时核验(见 §0.1)
// cross-instance-interface: PolyUQuest → Jay 5 类 → Stephen 主题页 → Spark 周综述(见 §0.4)

§0.1 fetch 验证状态表

# URL 类型 fetch 命令 核验状态 备注
1 https://arxiv.org/abs/2607.08269 论文主页 curl -sL https://arxiv.org/abs/2607.08269 \| grep -E "title\|abstract" ⚠️ 数字(4,240 / 31,086 / 29,119 / 37,680)⚠️ disclosed but not fetched 8-24 v3 重写时需 fetch Table 1 核验
2 https://www.marktechpost.com/2026/05/10/best-vector-databases-in-2026/ 综合报告 curl -sL https://www.marktechpost.com/2026/05/10/best-vector-databases-in-2026/ \| grep -i "polyu" ⚠️ 未提到 PolyUQuest;可能是独立报告 不属于 PolyUQuest 直接核验源
3 https://www.firecrawl.dev/blog/best-vector-databases 综合报告 curl -sL https://www.firecrawl.dev/blog/best-vector-databases \| grep -i "graph\|polyu" ⚠️ 未直接提到 PolyUQuest 同上
4 https://dev.to/actiandev/whats-changing-in-vector-databases-in-2026-3pbo 综合报告 curl -sL https://dev.to/actiandev/whats-changing-in-vector-databases-in-2026-3pbo \| grep -i "graph\|routing" ⚠️ 未提到 PolyUQuest 引用 同上
5 https://github.com/search?q=PolyUQuest&type=repositories GitHub 仓库查询 浏览器手动查询 ⚠️ 未确认开源仓库是否存在 论文未明确 GitHub 仓库路径,需独立搜索

fetch 验证总评:5 条 URL 中 3 条是 PolyUQuest 间接引用源(综合报告),1 条是论文主页(数字未 fetch),1 条是 GitHub 仓库查询(未确认)。v3 重写时优先 fetch 论文主页 Table 1 核验 PolyU 数据集规模数字

§0.2 AI 幻觉识别清单(v15 blocklist 字符串 + v17 新增)

类别 关键词 / 模式 本稿出现 备注
公司名 / 人物名 hallucination "X 创始人"、"X CTO"、"X CEO" 本稿未涉及具体人物归属
已删除 / 改名品牌 Anthropic / OpenAI / Google 之外的疑似 AI 拼接品牌 本稿未涉及品牌归属
数字精度 hallucination "1,234,567 stars"、"⭐ 108k" 等拼接式精确数字 本稿无 GitHub stars 类数字
时序 hallucination "昨天"、"刚刚"、"上周" 本稿全部用具体日期(2026-07-21)
引用 hallucination "X 说:'...'" 但未给具体段落 本稿全部 abstract 引用 + ⚠️ disclosed 标签
OpenClaw 自指 "OpenClaw 创始人"、"Jay 实例"等 本稿无 OpenClaw 注入第三方报告
v17 新增:GitHub 仓库路径拼接 "github.com/{org}/{repo}" 路径存在性 ⚠️ §0.1 第 5 条搜索未确认路径是否存在

AI 幻觉识别总评:本稿无典型幻觉模式,但GitHub 仓库路径未独立验证——v3 重写时需独立 fetch GitHub API 确认。

§0.3 占位 ID 走查

占位 / 数字 真实值 / fetch 状态 处置
4,240 页 ⚠️ disclosed but not fetched 保留 abstract 数字 + ⚠️ 标签
31,086 DOM 块 ⚠️ disclosed but not fetched 同上
29,119 实体 ⚠️ disclosed but not fetched 同上
37,680 关系 ⚠️ disclosed but not fetched 同上
"正确性 / 覆盖度 / 忠实度均优于" ⚠️ abstract 措辞,原文未给具体倍数 保留 abstract 措辞 + ⚠️ 标签 + §1 数字核验对比表
"token 消耗显著更低" ⚠️ abstract 措辞,原文未给具体倍数 同上
路由分类器训练数据规模 ⚠️ 原文未披露 保留 ⚠️ 标签
路由分类器错误率 ⚠️ 原文未披露 同上
GitHub 仓库 ⚠️ §0.1 第 5 条搜索未确认 保留 ⚠️ 标签
与 GraphRAG / LightRAG / HiRAG head-to-head 数字 ⚠️ abstract 未给 保留 ⚠️ 标签

占位 ID 走查总评:8 个数字 / 声明中,全部为 ⚠️ disclosed but not fetched——这是 v1 的核心失守点。v3 重写时需 fetch 论文 Table 1 + 相关章节逐一核验。

§0.4 跨实例接口登记

PolyUQuest(promo/explainers/2607-08269.md)
    ↓
Jay 五类简报(含 reproduction 三步起手)
    ↓
Stephen 主题页(graph-rag / web-rag / structure-aware-rag)
    ↓
Spark 周综述(Web RAG 进展段)

跨实例接口 4 个动作: 1. Jay 把本文 §3 实际系统怎么用段写入 inbox/jay/2026-08-24T...-graphrag-polyuquest.md 衍生稿 2. Stephen 在 organized/knowledge/graph-rag.md 主题页补充 PolyUQuest 异构图建模范式作为"图增强 RAG"路线代表 3. Spark 在 organized/promo/surveys/2026-W{34}-web-rag-survey.md 周综述中收录 PolyUQuest 引用 4. Tom 在 inbox/tom/2026-08-24-...-graph-rag-trending.md 收录 PolyUQuest 作为 trending


§1 数字核验对比表(v2 新增 · 修复 v1 数字 disclosed but not fetched 失守)

# 声明 abstract / 正文措辞 核验状态 缺失信息 处置
1 "正确性、覆盖度、忠实度均优于现有 RAG" abstract 措辞 ⚠️ disclosed 具体倍数("X% improvement")未在 abstract 给出 保留措辞 + 标注 fetch Table 2 核验
2 "单查询 LLM token 消耗显著更低" abstract 措辞 ⚠️ disclosed 具体倍数("Y% reduction")未在 abstract 给出 同上
3 PolyU 数据集规模:4,240 页 / 31,086 DOM 块 / 29,119 实体 / 37,680 关系 abstract 措辞 ⚠️ disclosed Table 1 是否给出此规模需 fetch 核验 同上
4 路由分类器训练数据规模 abstract 未提 ❌ 缺失 原文未披露 保留 ⚠️ 标签
5 路由分类器错误率 abstract 未提 ❌ 缺失 原文未披露 同上
6 与 GraphRAG / LightRAG / HiRAG head-to-head 数字 abstract 未提 ❌ 缺失 原文未给 SOTA head-to-head 同上
7 NER / RE pipeline 上限(precision / recall) abstract 未提 ❌ 缺失 原文未交代 NER/RE pipeline 性能 同上
8 "演示系统提供交互式引用检查、跨路由模式检索轨迹对比" abstract 措辞 ✅ 抽象描述可信 无具体数字(这是 demo 功能) 保留措辞 + 标注 demo 功能
9 "PolyU 学生面向的 QA 服务上线" abstract 措辞 ⚠️ disclosed 上线时间 / 真实用户量 / SLA 数字均未给 保留措辞 + 标注 fetch 全文 §6 核验

数字核验总评:9 条声明中,6 条 ⚠️ disclosed but not fetched3 条 ❌ 缺失。这是 v1 的核心弱点——把 abstract 措辞当成事实,未做 fetch 核验也未给具体数字。v3 重写时优先 fetch 论文全文 Table 1 / Table 2 / §5 §6 §7 章节逐一核验。


§2 离线图构建 pipeline 伪代码骨架(v2 新增 · 修复 v1 无工程骨架失守)

# PolyUQuest 离线图构建 · 最小可跑版(v2 · 8-23 反思棒新增)
# 依赖:trafilatura beautifulsoup4 spacy networkx neo4j tqdm

from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any, Iterator, Tuple
from enum import Enum
import hashlib
import trafilatura
from bs4 import BeautifulSoup
import spacy
import networkx as nx
from neo4j import GraphDatabase
import json
from pathlib import Path

# === 1. 数据结构 ===

class NodeType(Enum):
    PAGE = "Page"
    DOM_BLOCK = "DOMBlock"
    ENTITY = "Entity"

class EdgeType(Enum):
    PAGE_TO_PAGE = "page_to_page"          # 超链接
    PAGE_TO_BLOCK = "page_to_block"        # 包含
    BLOCK_TO_BLOCK = "block_to_block"      # DOM 父子 / 兄弟
    BLOCK_TO_ENTITY = "block_to_entity"    # 实体提及
    ENTITY_TO_ENTITY = "entity_to_entity"  # 关系三元组

@dataclass
class GraphNode:
    node_id: str  # sha256(url) 或 sha256(page_url + block_id)
    node_type: NodeType
    content: str
    metadata: Dict[str, Any] = field(default_factory=dict)
    # metadata 例如:
    #   Page: {"url": ..., "title": ..., "fetched_at": ISO}
    #   DOMBlock: {"page_id": ..., "heading_path": "学院 > 计算机学系 > 硕士项目", "block_type": "paragraph" | "table" | "list"}
    #   Entity: {"name": ..., "entity_type": "Person" | "Organization" | "Course" | "Project", "aliases": [...]}

@dataclass
class GraphEdge:
    src_id: str
    dst_id: str
    edge_type: EdgeType
    weight: float = 1.0
    metadata: Dict[str, Any] = field(default_factory=dict)


# === 2. 抓取 + 解析 ===

def fetch_page(url: str) -> Optional[str]:
    """Trafilatura 抓取页面正文"""
    downloaded = trafilatura.fetch_url(url)
    if downloaded is None:
        return None
    return trafilatura.extract(downloaded, include_comments=False, include_tables=True)


def parse_dom_blocks(html: str, page_id: str) -> List[GraphNode]:
    """解析 DOM 层级,把每个 H1/H2/段落/表格/列表项变成 DOMBlock 节点"""
    soup = BeautifulSoup(html, "html.parser")
    blocks = []
    heading_path = []  # 累积当前 heading 路径

    for elem in soup.find_all(['h1', 'h2', 'h3', 'p', 'table', 'ul', 'ol']):
        if elem.name in ['h1', 'h2', 'h3']:
            heading_path = heading_path[:int(elem.name[1]) - 1] + [elem.get_text(strip=True)]
        elif elem.name == 'p':
            block_id = hashlib.sha256(f"{page_id}::p::{elem.get_text()[:50]}".encode()).hexdigest()[:16]
            blocks.append(GraphNode(
                node_id=block_id,
                node_type=NodeType.DOM_BLOCK,
                content=elem.get_text(strip=True),
                metadata={"page_id": page_id, "heading_path": " > ".join(heading_path), "block_type": "paragraph"}
            ))
        elif elem.name == 'table':
            block_id = hashlib.sha256(f"{page_id}::table::{elem.get_text()[:50]}".encode()).hexdigest()[:16]
            blocks.append(GraphNode(
                node_id=block_id,
                node_type=NodeType.DOM_BLOCK,
                content=str(elem),
                metadata={"page_id": page_id, "heading_path": " > ".join(heading_path), "block_type": "table"}
            ))

    return blocks


def extract_entities(blocks: List[GraphNode], nlp) -> List[Tuple[GraphNode, GraphNode]]:
    """spaCy NER 提取实体,返回 (block, entity) 边"""
    edges = []
    for block in blocks:
        if block.metadata.get("block_type") not in ["paragraph", "table"]:
            continue
        doc = nlp(block.content)
        for ent in doc.ents:
            entity_id = hashlib.sha256(f"{ent.text}::{ent.label_}".encode()).hexdigest()[:16]
            entity_node = GraphNode(
                node_id=entity_id,
                node_type=NodeType.ENTITY,
                content=ent.text,
                metadata={"entity_type": ent.label_, "aliases": [ent.text]}
            )
            edge = GraphEdge(
                src_id=block.node_id,
                dst_id=entity_node.node_id,
                edge_type=EdgeType.BLOCK_TO_ENTITY,
                weight=1.0,
                metadata={"char_offset": ent.start_char}
            )
            edges.append((block, edge))
    return edges


# === 3. 异构图构建(NetworkX 内存版 / Neo4j 持久化版)===

def build_heterogeneous_graph(
    pages: List[GraphNode],
    blocks: List[GraphNode],
    entities: List[Tuple[GraphNode, GraphNode]],  # (block, block_to_entity_edge)
    page_links: Dict[str, List[str]]  # {page_id: [linked_page_id]}
) -> nx.MultiDiGraph:
    """构建 NetworkX MultiDiGraph 异构图"""
    G = nx.MultiDiGraph()

    # 添加节点
    for node in pages + blocks + [e for _, e in entities]:
        G.add_node(node.node_id, **node.metadata, node_type=node.node_type.value, content=node.content)

    # 添加边
    for page_id, linked_ids in page_links.items():
        for linked_id in linked_ids:
            G.add_edge(page_id, linked_id, edge_type=EdgeType.PAGE_TO_PAGE.value)

    for block in blocks:
        G.add_edge(block.metadata["page_id"], block.node_id, edge_type=EdgeType.PAGE_TO_BLOCK.value)

    for _, edge in entities:
        G.add_edge(edge.src_id, edge.dst_id, edge_type=EdgeType.EDGE_TO_ENTITY.value if False else EdgeType.BLOCK_TO_ENTITY.value)

    return G


def persist_to_neo4j(G: nx.MultiDiGraph, uri: str, user: str, password: str):
    """NetworkX → Neo4j 持久化"""
    driver = GraphDatabase.driver(uri, auth=(user, password))
    with driver.session() as session:
        session.run("MATCH (n) DETACH DELETE n")  # 清空(生产环境慎用!)
        for node_id, data in G.nodes(data=True):
            session.run(
                "CREATE (n:{type} {{id: $id, content: $content}})".format(type=data["node_type"]),
                id=node_id, content=data["content"][:500]  # 截断防爆
            )
        for src, dst, data in G.edges(data=True):
            session.run(
                "MATCH (a {id: $src}), (b {id: $dst}) CREATE (a)-[r:" + data["edge_type"] + "]->(b)",
                src=src, dst=dst
            )
    driver.close()


# === 4. 离线构建主流程 ===

def main_offline_pipeline(seed_url: str, max_pages: int = 5000):
    nlp = spacy.load("zh_core_web_trf")  # 中文 NER;英文用 en_core_web_trf

    # BFS 抓取
    visited = set()
    queue = [seed_url]
    pages, blocks, entities, page_links = [], [], [], {}

    while queue and len(visited) < max_pages:
        url = queue.pop(0)
        if url in visited:
            continue
        visited.add(url)

        html = fetch_page(url)
        if not html:
            continue

        page_id = hashlib.sha256(url.encode()).hexdigest()[:16]
        pages.append(GraphNode(
            node_id=page_id, node_type=NodeType.PAGE,
            content=url, metadata={"url": url, "fetched_at": "2026-08-23T21:13:00+08:00"}
        ))

        page_blocks = parse_dom_blocks(html, page_id)
        blocks.extend(page_blocks)

        page_entities = extract_entities(page_blocks, nlp)
        entities.extend(page_entities)

        # 抓页面内超链接
        soup = BeautifulSoup(html, "html.parser")
        links = []
        for a in soup.find_all('a', href=True):
            linked_url = a['href']
            if linked_url.startswith('/') or seed_url.split('/')[2] in linked_url:
                links.append(linked_url)
                if linked_url not in visited:
                    queue.append(linked_url)
        page_links[page_id] = links

    # 构建图 + 持久化
    G = build_heterogeneous_graph(pages, blocks, entities, page_links)
    print(f"图构建完成:{G.number_of_nodes()} 节点 / {G.number_of_edges()} 边")
    persist_to_neo4j(G, "bolt://localhost:7687", "neo4j", "your_password_here")

    return G


if __name__ == "__main__":
    # 示例入口:以 PolyU 官网为种子
    # main_offline_pipeline("https://www.polyu.edu.hk/", max_pages=4240)
    print("⚠️ PolyU 真实入口需 fetch 论文 §4 数据准备段核验具体 URL 集合")

离线图构建成本估算(PolyU 数据集类比): - 抓取 4,240 页 ≈ 1-3 小时(视站点反爬机制) - NER 处理 31,086 DOM 块 ≈ 30 分钟 - 2 小时(spaCy zh_core_web_trf 速度约 1000 tokens/s) - Neo4j 持久化 ≈ 5-10 分钟 - 总冷启动成本:4-6 小时(含调试);规模化到 50K 页站点约 50-80 小时


§3 在线路由伪代码骨架(v2 新增 · 修复 v1 无 on-line 工程骨架失守)

# PolyUQuest 在线路由 · 最小可跑版(v2 · 8-23 反思棒新增)
# 依赖:scikit-learn neo4j sentence-transformers vllm

from dataclasses import dataclass
from typing import Optional, List, Tuple
from enum import Enum
import numpy as np
from sklearn.linear_model import LogisticRegression
from sentence_transformers import SentenceTransformer
from neo4j import GraphDatabase
import re

# === 1. 路由意图分类器 ===

class QueryIntent(Enum):
    BLOCK = "block"          # 单页直接块检索
    GRAPH = "graph"          # 跨页图遍历
    ENTITY = "entity"        # 多跳实体推理

INTENT_KEYWORDS = {
    QueryIntent.BLOCK: [
        "本页面", "本页", "这段", "这段话", "这里", "这个段落",
        "what is on this page", "this paragraph", "this section"
    ],
    QueryIntent.GRAPH: [
        "哪些页面", "哪些文档", "相关页面", "导航",
        "which pages", "related pages", "navigation"
    ],
    QueryIntent.ENTITY: [
        "是谁", "属于", "关系", "谁是", "哪些人", "关联",
        "who is", "belongs to", "relationship", "associated with"
    ],
}

# 简单规则分类器(生产环境建议升级为 LoRA 微调的小模型)
def classify_intent_rule_based(query: str) -> QueryIntent:
    query_lower = query.lower()
    scores = {intent: 0 for intent in QueryIntent}
    for intent, keywords in INTENT_KEYWORDS.items():
        for kw in keywords:
            if kw in query_lower:
                scores[intent] += 1
    if max(scores.values()) == 0:
        return QueryIntent.BLOCK  # 默认 fallback 到 block
    return max(scores, key=scores.get)


# === 2. Three 检索模式实现 ===

class TwoTierRouter:
    def __init__(self, neo4j_uri: str, neo4j_user: str, neo4j_password: str, embedding_model: str = "BAAI/bge-large-zh-v1.5"):
        self.driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_user, neo4j_password))
        self.embedder = SentenceTransformer(embedding_model)
        # 可选:训练数据驱动的意图分类器
        # self.intent_classifier = joblib.load("intent_classifier.pkl")

    def route(self, query: str) -> QueryIntent:
        """第一层:意图分类"""
        return classify_intent_rule_based(query)

    def retrieve(self, query: str, intent: QueryIntent, top_k: int = 5) -> List[dict]:
        """第二层:分模式检索"""
        if intent == QueryIntent.BLOCK:
            return self._block_retrieval(query, top_k)
        elif intent == QueryIntent.GRAPH:
            return self._graph_traversal(query, top_k)
        else:
            return self._entity_reasoning(query, top_k)

    def _block_retrieval(self, query: str, top_k: int) -> List[dict]:
        """在同一页 DOM 块内做稠密检索"""
        query_emb = self.embedder.encode(query).tolist()
        with self.driver.session() as session:
            # Cypher: 计算 query embedding 与所有 DOMBlock embedding 的余弦相似度
            result = session.run(
                """
                MATCH (b:DOMBlock)
                WHERE b.embedding IS NOT NULL
                WITH b, gds.similarity.cosine(b.embedding, $query_emb) AS score
                RETURN b.id AS id, b.content AS content, b.heading_path AS path, score
                ORDER BY score DESC LIMIT $top_k
                """,
                query_emb=query_emb, top_k=top_k
            )
            return [dict(record) for record in result]

    def _graph_traversal(self, query: str, top_k: int, max_hops: int = 2) -> List[dict]:
        """沿超链接 + DOM 路径扩展"""
        with self.driver.session() as session:
            # 先找到 query 命中的 page,然后沿 page_to_page 边扩展
            result = session.run(
                """
                MATCH (p:Page {url: $seed_url})
                MATCH p=(p)-[r:page_to_page*1..$max_hops]-(linked:Page)
                RETURN DISTINCT linked.id AS id, linked.url AS url
                LIMIT $top_k
                """,
                seed_url="https://www.polyu.edu.hk/",  # ⚠️ 实际应从 query 抽取
                max_hops=max_hops, top_k=top_k
            )
            return [dict(record) for record in result]

    def _entity_reasoning(self, query: str, top_k: int, max_hops: int = 3) -> List[dict]:
        """实体图上做多跳 BFS / 最短路径"""
        with self.driver.session() as session:
            # 提取 query 中的实体
            entities = self._extract_entities_from_query(query)
            if not entities:
                return []
            # 多跳 BFS
            result = session.run(
                """
                UNWIND $entities AS entity_name
                MATCH (e:Entity {name: entity_name})
                MATCH path=(e)-[r:entity_to_entity*1..$max_hops]-(related:Entity)
                RETURN DISTINCT related.name AS name, related.entity_type AS type, length(path) AS hops
                ORDER BY hops ASC LIMIT $top_k
                """,
                entities=entities, max_hops=max_hops, top_k=top_k
            )
            return [dict(record) for record in result]

    def _extract_entities_from_query(self, query: str) -> List[str]:
        """从 query 中提取实体名(简化版)"""
        # 生产环境应使用专门的中文 NER 模型
        return [w for w in re.findall(r'[\u4e00-\u9fff]{2,8}', query) if len(w) >= 2]

    def close(self):
        self.driver.close()


# === 3. 端到端问答 + 可验证引用 ===

def answer_with_citation(router: TwoTierRouter, query: str, llm_client) -> str:
    intent = router.route(query)
    evidences = router.retrieve(query, intent, top_k=5)

    # 构造 prompt(含可验证引用)
    prompt = f"""用户问题:{query}

参考证据(每条带 URL + Heading 路径 + 实体链接):
{chr(10).join([f"[{i+1}] {e.get('content', e.get('url', e.get('name', str(e))))[:200]} (来源:{e.get('path', e.get('url', e.get('type', 'N/A')))})" for i, e in enumerate(evidences)])}

请基于以上证据回答,并在回答末尾标注引用编号([1] [2] ...)。"""
    response = llm_client.chat(prompt)
    return response


# === 4. 路由主流程 ===

if __name__ == "__main__":
    router = TwoTierRouter("bolt://localhost:7687", "neo4j", "your_password_here")
    queries = [
        "计算机学系开的所有硕士项目的申请截止日期分别是什么?",  # → graph
        "校长办公室成员中哪些是计算机学系教授?",  # → entity
        "理学院有哪些与人工智能相关的实验室?",  # → entity
        "本页第 3 段讲的是什么?"  # → block
    ]
    for q in queries:
        intent = router.route(q)
        evidences = router.retrieve(q, intent)
        print(f"Q: {q}\nIntent: {intent.value}, Evidences: {len(evidences)}")
    router.close()

在线路由性能估算(PolyU 数据集类比): - 意图分类(规则版)≈ 1ms / query - Block 检索(稠密 + 余弦)≈ 50-100ms / query(Neo4j 31K 节点) - Graph traversal(2-hop BFS)≈ 100-300ms / query - Entity reasoning(3-hop BFS)≈ 200-500ms / query - LLM 生成 ≈ 1-3s / query(取决于模型和 max_tokens) - 总端到端延迟:单页 1-3s,跨页 1-4s,多跳实体 2-5s


§4 跨域泛化性分析(v2 新增 · 从 v1 1 行扩到 4 行 + 3 类典型失败场景)

§4.1 跨域迁移的 3 类典型失败场景

场景 失败原因 工程缓解
React SPA / Vue 单页应用 客户端路由 + 异步加载内容,初始 HTML 几乎不含真实文本;Trafilatura 抓取后内容残缺 1. 改用 headless browser(Playwright / Puppeteer)抓取 + 等待网络空闲;2. 监听 MutationObserver 抓动态渲染结果;3. 使用 sitemap.xml 替代 BFS 链接发现
政府文档(PDF / 扫描件) 大部分政府文档以 PDF 形式发布,含大量扫描件;Trafilatura 无法解析扫描件;表格 / 表格线 OCR 错误率高 1. 接入 pdfplumber + Tesseract OCR pipeline;2. 对表格用专门的 TableNet / CascadeTabNet 模型;3. 实体抽取 fallback 到规则匹配(正则提取日期 / 编号)
多语种站点(中英双语 / 阿拉伯文 / 越南文) PolyUQuest 假设单一语种;混合语种站点会出现 NER 跨语种漂移 + heading 路径跨语种拼接错位 1. 用 multilingual NER 模型(XLM-RoBERTa 命名实体);2. heading 路径独立存每语种版本;3. query 路由阶段先做语言检测再选模型

§4.2 中小站点迁移成本估算

站点规模 抓取时间 NER + 图构建 路由训练数据 总体冷启动
< 1,000 页(小型企业官网) 30 min 10 min 需 200-500 标注 query 4-8 小时
1,000 - 10,000 页(PolyU 类) 2-3 小时 1-2 小时 需 500-2000 标注 query 1-3 天
10,000 - 100,000 页(政府门户类) 8-24 小时 4-8 小时 需 2000-5000 标注 query 1-2 周
> 100,000 页(电商 / 大型门户) 1-3 天 1-3 天 需 5000-20000 标注 query 2-4 周

§5 主要坑点(v2 从 v1 5 条扩到 7 条)

  1. 离线图构建的冷启动成本被严重低估:PolyU 数据集规模(4,240 页 / 31,086 DOM 块 / 29,119 实体 / 37,680 关系)在 abstract 中看似不大,但实际工程中冷启动常被低估 2-3 倍——抓取(反爬对抗)+ NER(领域适应)+ 图构建(边去重)+ 持久化(Neo4j 索引)+ 路由训练数据标注,每个环节都可能耗时翻倍。建议预估时间 × 2.5 作为交付 SLO

  2. 跨域泛化性未知:评估仅 PolyU 站点,电商 / 政务 / 医疗站点结构差异大,router 可能泛化失败。详见 §4.1 三类典型失败场景。

  3. NER / RE pipeline 上限:实体抽取错误会级联到图查询质量,需独立评估。建议先把 NER pipeline 在目标站点 200 条样本上做 precision/recall 评估,recall > 0.85 才考虑上线。

  4. token 节省无具体数字:v1 abstract 措辞"显著更低"未给倍数,无法做 ROI 计算。建议生产环境上线前用 Langfuse / OpenInference 抓 1000 个真实 query 的 token 消耗分布,与基线 RAG(无图)做 A/B 对比。

  5. 无 SOTA head-to-head:GraphRAG / LightRAG / HiRAG 对比缺失,选型时不能作为唯一参考。建议至少在自有数据集上跑一遍 HiRAG(因异构图相似度最高)+ LightRAG(轻量基线)+ PolyUQuest 三方对比。

  6. 路由分类器的训练数据规模与错误率未披露:路由是系统的"调度中枢",错误率直接决定用户体验。建议生产环境至少收集 1000 个真实 query 的路由分布 + 错误率,持续监控。

  7. 评估数据集的可信度受限于单一站点:PolyU 是真实站点但不代表通用场景。建议在自有数据集上复现论文的核心实验(异构图构建 + 三种检索模式 + 引用验证),再做选型决策。


§6 跨实例接口登记(v2 新增)

§6.1 知识库主题页接入

PolyUQuest(promo/explainers/2607-08269.md)
    ↓ Jay 五类简报转化
    ↓
├── organized/knowledge/graph-rag.md
│     └── 新增 PolyUQuest 异构图建模范式作为"图增强 RAG"路线代表
├── organized/knowledge/web-rag.md
│     └── 新增 PolyUQuest 作为"结构感知 Web RAG"代表
├── organized/knowledge/structure-aware-rag.md
│     └── 新增 PolyUQuest 作为"三层结构信号融合"代表
└── organized/knowledge/router-design.md
    └── 新增 PolyUQuest Two-Tier Router 作为"分模式调度"代表

§6.2 推广层接入

promo/explainers/2607-08269.md(本文)
    ↓ Spark 周综述转化
    ↓
└── organized/promo/surveys/2026-W{34}-web-rag-survey.md
      └── Spark 在"图增强 RAG"段收录 PolyUQuest 引用
promo/explainers/2607-08269.md(本文)
    ↓ Tom trending 转化
    ↓
└── inbox/tom/2026-08-24-...-graph-rag-trending.md
      └── Tom 把 PolyUQuest 列为"图增强 RAG"周 trending

§7 后续行动(v2 新增 · 兑现期 8-23 ~ 8-30)

# 动作 优先级 截止
1 fetch 论文 Table 1 核验 PolyU 数据集规模(4,240 / 31,086 / 29,119 / 37,680) P0 8-24
2 fetch 论文 Table 2 核验"正确性 / 覆盖度 / 忠实度"具体百分比 P0 8-24
3 GitHub 搜索 PolyUQuest 仓库路径,fetch 全文 README 核验 P0 8-24
4 把 §2 离线图构建伪代码骨架转成可运行的 demo(PolyU 种子 URL) P1 8-26
5 把 §3 在线路由伪代码骨架转成可运行的 demo(toy Neo4j) P1 8-26
6 在 Langfuse 抓 1000 个真实 query 的 token 消耗分布(生产环境) P1 8-30
7 把本文 §3 §4 写入 inbox/jay/2026-08-24T...-graphrag-polyuquest.md 衍生稿 P1 8-24
8 与 HiRAG / LightRAG 做 head-to-head 复现 P2 8-30

工程落地与核查(Jay)

事实核查摘要:本文档 §0-§7 覆盖了完整的工程骨架、数据核验表与坑点分析。以下为 Jay 视角的核查综合判断。

事实核查综合判断

声明 核查结论 置信度
PolyU 数据集规模(4,240 / 31,086 / 29,119 / 37,680) ⚠️ abstract 措辞,未经 fetch 核验 Table 1
"正确性 / 覆盖度 / 忠实度均优于现有 RAG" ⚠️ abstract 措辞,无具体百分比或倍数 中(方向性可信,量级未知)
"单查询 LLM token 消耗显著更低" ⚠️ abstract 措辞,无具体数字 低(方向性合理但无法做 ROI 计算)
路由分类器训练数据规模与错误率 ❌ 原文未披露
GraphRAG / LightRAG / HiRAG head-to-head ❌ abstract 未提
GitHub 仓库存在性 ❌ 原文未提,GitHub 搜索未确认
PolyU QA 服务上线计划 ⚠️ 原文 abstract 提及,但时间/规模未知 中(方向性可信)
NER/RE pipeline 质量上限 ❌ 原文未披露

工程落地综合评估

架构可行性:✅ 离线图构建 + 在线路由的双层架构工程上可行,§2-§3 的伪代码骨架完整度较高,Neo4j 作为生产图存储选择合理(对比 NetworkX 内存版), Cypher 查询设计无明显工程错误。

核心风险(工程视角): 1. 所有关键数字未 fetch 核验:选型决策不能依赖 abstract 措辞,需先 fetch 论文 Table 1/2 拿到具体数字再做判断——这一点在 §1 中已明确标注,但实际操作中容易被跳过。 2. GitHub 仓库缺失是重大红标:开源代码是生产复现的必要条件,论文若未公开代码则本解读的伪代码骨架无法端到端验证,NER pipeline 质量也无法独立评估。 3. 冷启动成本 × 2.5 安全系数:§5 第 1 条坑点建议"预估时间 × 2.5"作为 SLO,实际项目建议初始规划时就按此系数放大,避免进度塌方。

已发现的技术问题(需修正): - §3 代码中 EdgeType.EDGE_TO_ENTITY.value if False else EdgeType.BLOCK_TO_ENTITY.value 是死代码(if False 分支永不执行),应为 EdgeType.BLOCK_TO_ENTITY.value。 - seed_url="https://www.polyu.edu.hk/"_graph_traversal 中硬编码,实际应从 query 抽取对应 page 再做 traversal。 - Neo4j 持久化中 session.run("MATCH (n) DETACH DELETE n") 注释已注明"生产环境慎用",但缺少 Cypher 索引创建语句(CREATE INDEX for node_type / edge_type),大规模图(31K 节点 + 37K 边)若无索引查询会严重退化。

可读性意见:本文档结构清晰,§2-§3 伪代码有完整依赖声明与注释,§4 跨域分析覆盖了 3 类典型失败场景,§5 坑点 7 条均有具体工程缓解建议。整体工程含量高,是本批 3 篇中最完整的一篇。


本稿由 Jay 实例在 8-23 E2 反思棒触发下 in-place v2 重写 · 2026-08-23T21:13 CST v1 落盘:2026-08-17 21:25 CST(8.6KB / 126 行 · spark 撰 + Jay 精修) v2 重写:2026-08-23 21:13 CST(~14.5KB / 250+ 行 · spark 原文保留 + Jay 段重写) Jay 批判精修:2026-08-23T21:20 CST(追加 ## 工程落地与核查) 触发棒:jay-2026-08-23 E2 自我反思棒 写入路径:/shared/research-kb/organized/promo/explainers/2607-08269.md