assets/2026-10-28-医院质管知识图谱_01_知识图谱与节点溯源.png

[!ABSTRACT] 核心摘要
项目:医院医疗质量管理知识图谱(65 份院内制度原文 → 可溯源、可对比、可问答的知识底座)
领域:医疗质量管理 · 知识图谱 · GraphRAG · 制度版本治理
核心指标:21455 个实体 / 91595 条关系 / 4699 个原文块 / 64 种受控谓词 / 10 个制度 16 组版本对比 / 问答 1.2 秒 4 路检索
三条主线:

  1. 构建篇——从 PDF 解析到建图,6 道流水线,每道都有让人想骂街的坑(表格被 OCR 吃掉、中文关系类型被拒、谓词判错一次就吃掉 2646 条关系)
  2. 建模篇——制度”两个版本”的差别不会自动出现在图上,必须单独建模;而且配对失败的条款只能叫”未配对”,不能叫”废止”
  3. 落地篇——网站只看、构建留本地:后端把 13 条管理接口物理不注册,敲 URL 也是 404

引子:一个临床科室的问题,和半小时的翻找

质管办最常接到的一类电话,大概是这样的:

“入院记录超过 24 小时没完成,医院到底怎么罚?”

我的回答通常是:你等等,我查一下。

然后去翻那 65 份制度文件。

65 份 PDF、303MB:2018 版和 2024 版《病历书写规范》、2021 版和 2024 版《临床路径实施方案》、五份不同年份的《国家医疗质量安全目标实施方案》、29 份核心制度、《不良事件管理办法》三个版本……

要回答”入院记录超时怎么罚”,我得先想清楚:

  • 是找病历书写规范(里面有时限要求),还是找病历管理制度(里面有考核条款)?
  • 2024 年 9 月刚刚修订过,那 2018 版的还算不算数?
  • 处罚是”扣绩效”还是”扣款”?通报里是写了 25 块,但那是通报里的事,制度里的条款是另一套话。

半小时后答案出来了,内容是对的,但心里不太舒服。

不是因为答错了,是因为”答对”这件事本身太贵了。

我算过一笔账:质管办一年要回答多少次这种问题?每一次都是半小时,每一次都要在 300MB 的 PDF 里”大海捞针”——而且捞针的人必须已经知道针在哪一页,否则连捞的方向都没有。

于是我开始想一个有点”疯狂”的问题:

能不能让机器把这 65 份制度读成一张图?
不是让人去搜,而是让机器能回答”制度怎么说”,并且每一个答案都能点回原文那一页?

这一期《器数维新》,记录的就是这个”疯狂”决定的完整建造记。

从 2026 年夏天到 10 月,一整套官方语义库(semantica 0.7.0)流水线,从 PDF 解析到版本差异建模,从 GraphRAG 问答到内网服务器部署。

先看成果。


Part 1:成果长什么样

1.1 一张能点的图

assets/2026-10-28-医院质管知识图谱_01_知识图谱与节点溯源.png

上图是部署在内网服务器上的实机界面。搜索”入院记录”,图谱自动聚焦到它的 52 个邻居节点;右侧 Inspector 面板告诉你:

  • 这个实体在病案与文书类目下,全院被提及 50 次
  • 它有 3 个来源文件(2025 年 3 月的质量通报、2018 年病历管理制度、2018 年病历书写规范)
  • 它连着 75 条关系(出 48 入 27):谁属于它、它包含谁、它和谁相关

右下角那三个蓝色的文件名,是可点击的。 点进去就是原件。

1.2 一个能溯源的答案

assets/2026-10-28-医院质管知识图谱_05_四路检索与带引用的答案.png

同一个问题,交给知识图谱:

1
入院记录超过24小时未完成,医院有什么规定?

1.2 秒,四路检索同时跑(语义 20 条、关键词 6 条、图路径 16 条、RDF 20 条),RRF 融合后交给大模型生成,答案长这样:

入院记录24小时未完成的时限要求

  • 入院记录必须在患者入院后24小时内完成,适用于入院记录、再次或多次入院记录 [引用:chunk_id=深盐医〔2024〕98号…病历书写规范【2024】98号#0032]
  • 入院记录可由实习医师、试用期医务人员或进修医师书写,但须由本院注册执业医师审核签名;若24小时内转科,首诊科室须在转出前完成入院记录(紧急情况可6小时内补记并说明) [引用:…#0034]

超时未完成的系统管控措施

  • 入院超过24小时未完成入院记录,系统将自动锁定该医师开具长期医嘱的权限 [引用:…2026”病历内涵质量专项提升月”工作方案#0012]

质量通报中的实际扣分情形

  • 通报中明确将”入院记录未在24小时内完成”列为质量问题,并统一扣25.00元 [引用:2025年03月医疗质量安全管理通报#0022.1]

注意每一个结论后面的方括号。这是整个项目的灵魂:不是让模型”知道”,是让它”说得出来源”。质管办的答案如果不能点回原文,那它就是一个不能用的答案。


Part 2:为什么”翻制度”这件事,本来就该被机器接管

在做之前,我想先把问题定义清楚,否则很容易做成一个”更快的 PDF 全文检索”,那玩意儿满地都是,不值得做。

2.1 制度检索的三个死结

死结一:同一件事,散在不同文件里。

“入院记录超时”这件事:

  • 《病历书写规范》里写的是时限要求(必须 24 小时内完成)
  • 《病历管理制度》里写的是考核与扣罚
  • 《医疗质量(安全)不良事件管理办法》里写的是如何上报
  • 质量通报里写的是实际扣了多少钱

你问”医院怎么罚”,正确的答案是把这几处拼起来。全文检索给你的是四段孤立的文字,拼这一步仍然要人做。

死结二:文件有版本,而且版本之间体例完全不同。

这是最麻烦的一条。2018 版《病历书写规范》是”7 章 50 条”的传统公文;2024 版是”7 篇 53 节”的新体例,章节名全变了。

更要命的是:同名章节在两版里含义都变了。比如”(一)”这种编号,2018 版里是”款”,2024 版里是”节下面的子项”。用同一套规则切两版文件,切出来的东西根本不是一回事。

死结三:指标藏在表格里,而表格是 PDF 最难读的部分。

国家医疗质量安全目标方案里,每个目标都有一张表:目标简述、核心策略、改进措施、监测指标。全是表格。

而 PDF 里的表格,是所有解析器的噩梦。

我做过一个对照实验:同一份 263 页全是表格的文件,

官方 Docling 换成表格专用解析
提取字符数 24,430 223,503(9.1 倍)
表格单元格数 0 27,618
断行率 92% 0%

9.1 倍的差距,不是调参调出来的,是”根本没读进去”和”读进去了”的区别。

2.2 所以目标不是”搜索”,是”回答 + 溯源”

我把目标写成了四条硬指标,后面每一步都拿它验收:

  1. 答案必须带原文出处,精确到哪个文件的哪一块(不是”第几页”,是 chunk_id)
  2. 版本差异必须能看见,并且只能叫”未配对”的地方绝不能叫”废止”
  3. 问错了要老实说不知道,不许编
  4. 能溯源到原件,点一下就看到那份 PDF 的那一页

第 2 条我会专门展开,因为它是整个项目里最容易骗到自己的地方。


Part 3:完整构建——六道流水线,每道都有坑

整个系统用官方 semantica 0.7.0 的流水线搭起来:解析 → 抽取 → 建图 → 推理 → 溯源 → 决策。

先说原则:能力一律用官方 API,不自己造轮子。官方确实没有的(比如版本差异比对、中间空格归一),自己写,并且在代码里显式标注”自研补充”,在文档里写清楚为什么官方没有。这条纪律听着像洁癖,实际上救命——下面会讲。

3.1 解析层:让机器先”看得懂”表格

我原本直接用官方 DoclingParser,很快发现两个必须解决的问题:

问题一:官方 Docling 遇到大文件会 OOM,而且永不返回。

表现是 std::bad_alloc 之后它反复重试同一批页,既不抛异常也不退出,还和主进程同生共死。我实测过一次一键重建卡死在同一批页上,只能强杀。

修法:把 Docling 调用隔离到子进程 + 超时(240 秒),崩了或超时直接放弃转 OCR 兜底。

页数阈值只能防大文件,防不住小文件偶发 OOM——所以隔离是必须的,阈值只是锦上添花。

问题二:官方 enable_ocr=True 对乱码 PDF 无效。

Docling 见文本层就不 OCR。而医院的扫描件 PDF 恰恰是”有文本层但是乱码”的。

于是我做了一件事:把 OCR 换成表格专用的解析器(MinerU),做三级降级:MinerU(主)→ 官方 Docling(兜底1)→ RapidOCR(兜底2)。

这里有三个实测出来的硬约束:

  • 只能用 basic 档(纯 ONNX)。standard 档含本地 VLM,本机 2GB 显存跑不动,实测在文本层文件上直接段错误(退出码 3221225477,Layout Predict 40% 处崩)。
  • ocr-mode 要按文件选:有表格或扫描件用 ocr,纯文本用 txt(标点更准,但慢 2.6 倍)。
  • 云端补强:basic 档的表格模型在”≥5 列 + 跨行 + 长段落”时会列错位,实测有一页的★否决项列内容直接丢失。这种页我丢给云端表格解析重跑(¥0.003/页),实测补强后 6 列对齐、否决项 5 条完整。

最后一步是表格必须用代码块围栏包起来。这是整条流水线里最反直觉但最关键的一个细节,下一节展开。

3.2 分块层:官方分块器的”真实机制”,和它教我的事

我用官方 StructuralChunker 切块。后来我读了一遍它的源码,发现它的工作方式和我想象的完全不一样:

结构元素 源码怎么拼 后果
paragraph " ".join(para_lines) 行被空格压平
code_block "\n".join(code_lines) 行结构保留
heading / list 单独成块 正常

而且它绝不切开单个元素——超了就形成一个超限块。

两条推论,直接决定了我后面两个设计:

推论一:输入文本必须有结构。

我用 OCR 出的”每行一句”碎片喂进去,结果 5496 个字被压成一个巨块。条款埋在块尾,向量化时按前 2000 字截断——那条条款根本没进向量库。于是问答就是一句”证据不足”,而且我查了很久都不知道为什么。

换成带标题层级的 markdown 之后,同样的文件切出 934 块、平均 401 字、只有 18 个超限。

推论二:表格必须走代码块通道。

管道表格的每一行都不匹配任何结构规则 → 全被当 paragraph 压成一行 → 列关系直接丢失,实测出现一个 17312 字的表格块。

解法很土但有效:在解析阶段用 ``` 围栏把表格包起来。这是用官方 markdown 语法告诉分块器”这是结构化数据,别重排”。

这两个坑让我明白了一件事:用官方 API 不等于不用理解它。你得知道它的切分逻辑,才知道怎么喂它。

3.3 抽取层:大块喂模型,细块喂向量

抽取用的是官方 NERExtractor + RelationExtractor(LLM 模式)。

这里有个绕不过去的矛盾:

  • 只用大块(分块器合并后的原始块):OCR 文本没有标题层级 → 巨块 → embedding 截前 2000 字 → 条款进不去向量库 → 问答召回不到
  • 只用细块(≤1500 字切片):LLM 对每片独立抽取 → 跨片关系丢失。我实测过:原始谓词 exhibits_quality_issue 直接消失了,依赖它的官方推理规则”患者质量问题责任人”整条失效,决策页是空的

所以必须分两层:

大块喂 LLM 抽取,细块喂 :Chunk 节点 / 向量 / 引用。
并且检索粒度、向量粒度、引用粒度必须一致——source_chunks 和关系的 chunk_id 都指向细块。

还有个缓存上的教训:抽取缓存我最初按 chunk_id 命名,结果一改切分规则,块序号全局错位,缓存全失效,149 个块重抽了 25 分钟。改成两层:模板层按内容哈希存(与 id 无关),每块一个文件存 slices。

★ 顺带说一句:LLM 抽出来的原始谓词名不稳定。同样一段文本两次抽取,exhibits_quality_issue 一次有、一次没有(qwen-plus 有温度)。所以我不把推理规则绑死在原始谓词名上,否则整套决策链路就是靠运气在跑。

3.4 谓词治理:一次判错,吃掉 2646 条关系

抽取出来的原始谓词是英文的自由短语(is_part_of、issued_by、violates……)。要变成图,得先映射成一套受控词表——我用 LLM 做这件事:输入 (原始谓词, 主实体类型, 客体类型) 三元组,输出 54 类受控中文谓词。

看着简单,实则是全项目最危险的一环。

因为有一条特殊规则:有些谓词是”属性式”的——它描述的是节点自己的属性,不是节点之间的关系。比如”入院时间是 2025-02-25”,这不该是一条边,应该是节点上的一个属性。

规则是这样运作的:

  • 建图阶段(run_04):把属性式谓词的边转成节点属性 → 这些边不会进 Neo4j
  • 谓词治理阶段(run_06):从 Neo4j 收三元组 → 所以它收不到属性式谓词

两条规则必须用同一份代码判断。否则:

我有一次把 is_part_of 误判成了属性式。
结果:2646 条关系只剩下 25 条。整张图基本空了。

所以我把判定规则抽成一个独立文件,两个脚本都 import 它,任何时候只改一处。

★ 顺带记一个更隐蔽的坑:判定式谓词绝不能用 _of 后缀兜底。is_part_of / is_member_of / at_risk_of / increases_risk_of / associated_with_risk_of 全都是真关系,误判成属性 = 关系被吃掉,而且永远拿不到受控谓词。

3.5 建图层:官方库三个限制,每个都让我停工半天

图谱存 Neo4j(社区版 5.26.0)。踩了三个官方 API 的限制:

限制一:官方 create_relationship 拒绝中文关系类型。

1
2
Invalid relationship type: '隶属'. Must start with a letter or underscore
and contain only alphanumeric characters and underscores.

我的受控谓词全是中文 → 凡是建边都必须走 Cypher 字符串插值(配白名单校验防注入)。这不是 workaround,这是官方 API 的硬边界。

限制二:官方 get_relationships 返回的对象里没有 target 字段。

它返回 {id, type, start_node_id, end_node_id}。我一开始用 r.get("target") 取另一端,永远是 None → 迁边的时候一条都迁不过去(别名节点被删后,连 :Chunk 的入边一起丢,合并后变成孤点,检索再也命中不了)。必须按 start_node_id / end_node_id 判方向。

限制三:官方 create_nodes 是 CREATE 语义,不按 name 去重。

平时没事,但我磁盘满过一次,官方批量创建超时重试——第一遍已经建成功,重试又建一遍——

结果:41932 个节点,20966 个名字各两份。

后来我在建图前后都加了”删同名 + 查重名清影子”,才算稳住。

还有一个不算限制但很坑的:官方 get_nodes 有缓存。改完图再查同一个条件,可能返回旧数据(我 merge 完一个实体去查,别名还显示”还在”;换个名字查,0 条)。验证图状态前得换个条件查,或者直接用原生 driver 复核。

3.6 版本差异建模:整个项目里最容易骗到自己的一块

这块单独拎出来讲,因为它承担了质管办最核心的一项职能:新旧制度到底改了什么。

而且这一块,我犯了一个特别危险的错。

(1) 先说结论:版本信息不会自动出现在图上

我以为把两版制度都抽成实体、建成图,两版的差异自然就显出来了。

天真了。

主图里只有实体和原文块。两版制度里同名同义的条款会被实体归一合并成一个节点——比如”入院记录应在 24 小时内完成”,2018 版和 2024 版各有一句,归一之后它们变成同一个节点的两条出处。

**版本信息就这么丢了。**而且丢得很隐蔽:图看着还挺完整,你不去数条款根本发现不了。

所以必须单独建一层::PolicyVersion(制度版本)和 :Clause(条款),把版本当成一等公民建模。

(2)「替代」关系不能编

新版通知里通常有一句”原〈文号〉…即日作废”。这是替代关系的唯一依据。

我用正则去认,认出来才建”替代”边。认不出来就不建。

实测:临床路径 2024 版通知只写了”做出修订,予以印发”,没有”原…号…即日作废” → 不建替代边,旧版状态写”已被2024年版修订”,绝不写”已废止”。

这不是谨慎过度。质管办拿着”已废止”三个字去回复临床科室,是要出事的。

(3) 两版体例不同 → 先摊平到”款”级

前面说过:2018 版是”7 章 50 条”,2024 版是”7 篇 53 节”。章节名全变了,编号含义也变了。

直接条款级一对一配对,必然错。

我的做法是摊平:

  • 2018 版:50 条 → 摊平成 454 个”款”
  • 2024 版:53 节 → 摊平成 486 个”款”
  • 每个款还要再拆成 (小标题句, 正文) —— 因为 2018 把小标题和正文写在一行,2024 拆成”节 + 款”,不拆这层配不上对

(4) 关键一层:章节级对齐(标题优先)

配对失败的条款,不能直接判”新增”或”废止”。要先看它的父章节在新版里还在不在:

  • 父章节还在 → 说明只是被重排/拆并了 → 判**”改写”**
  • 父章节没了 → 才判”废止/新增”

**没这一层就是错误结论。**实测:2018 版「住院病案首页书写规范及要求」下面的 83 个款,被误判成”废止”——

实际情况是:2024 版把它挪去了《住院病案首页填写规范指南》,一个字都没删。

质管办看到这个”83 条废止”,第一反应会是”我们哪年废过 83 条规定”。

(5) 算不动的问题

454 × 486 ≈ 22 万对。官方自带的 Jaro-Winkler 相似度是纯 Python 的 O(n·m),全量跑不动。

解法是便宜的召回 + 贵的打分:

  • 字符 3-gram 倒排索引 + Jaccard,召回 top-8 候选(这一步是自研补充,已在代码里标注)
  • 对候选调官方 calculate_similarity 打分(这一步用官方 API)

(6) 三个”不许编”的细节

① 指标变更必须单独检测。

「病种 250 种 → 336 种」,字面相似度只有 0.83,按常规阈值就是”微调”。但这恰恰是质管办最该盯的那条。

所以我单独做了数值集合比对 → 单独标”指标变更”。而且只认有指标味的数字:带单位的、带小数的;裸整数不算(列表编号 “1.”、年份 “2022”、条款号全是噪声)。另外 ICD 编码要排除(“剖宫产手术 ICD-9-CM-3 编码:74.0,74.1…”里的数字不是指标)。

② 配对失败只能叫”未配对”,不能叫”废止”。

配对失败有两种可能:① 真的删了 ② 改写后没配上。直接标”废止”是编造结论。

实测这一项一度虚报到 418 条,改称”未配对”后才诚实。

③ 配对参数不能随便放宽。

我试过把召回放宽(top_k 8→20、接受阈值 0.55→0.40):

放宽前 放宽后
配对总数 110 128
其中”未变”(相似度 ≥0.97)这种高质量配对 55 55

放宽只增加低质量配对,一条高质量的都没多——只会造出假的”修改”结论。保持 top_k=8 / accept=0.55。

(7) 有些组根本不可比,要敢拆

比对我坚持另一条:不可比的组,宁可不做,也不能硬配。

实测两例:

  • 手术分级 2022→2024(2024 那份其实是”相关制度汇编”66 页)→ 款级配对率 1% → 整组去掉
  • 临床用血 2020”流程” vs 2023”审核制度” → 章节级对齐 0 对 → 整条链拆掉

硬配的结果是”全是新增”——这种结论拿去汇报,是会误导决策的。

同理,整份都是管道表格的文件做不了条款级对比(切分器靠”一、/(一)/第X条”这类层级标记还原章节,纯表格切出 0 章节)。这类文件我显式跳过,只作为独立文件入库,供问答使用(“××目标归谁负责”这类问题照样能答)。

(8) 成果

assets/2026-10-28-医院质管知识图谱_04_制度版本差异对比.png

上图是《临床路径实施方案》2021 版 vs 2024 版的对比。左边 2024 版(现行有效,15 章节 / 49 条款),右边 2021 版(已被 2024 年版修订,14 章节 / 50 条款),下面是条款级差异清单:

  • 每一行都有标签:修改 / 微调 / 未变 / 未配对 / 指标变更
  • 每行都有相似度百分比
  • 每行都能点”原文”跳到那一块

我特意把”未配对”这个标签做得很显眼(红色),而不是藏在”废止”这个词后面。 因为对一个质管办来说,”我不知道这两版对不上”是一个可以接受且诚实的信息,”这 52 条废止了”是一个会误导决策的信息。

现在总共做了 10 个制度、16 组对比:病历书写规范、临床路径、单病种、病历管理制度、手术分级、新技术准入、手术安全核查、抗菌药物、国家医疗质量安全目标(2021→2026 五版演进)、不良事件管理办法。

3.7 检索层:四路检索 + RRF 融合

最后是问答。质管办的问题形态很杂:有的问概念(“入院记录是什么”),有的问时限(“多久完成”),有的问责任(“谁负责”),有的问数值(“扣多少钱”)。

单一检索路覆盖不了,所以做了四路并行,再用 RRF(k=60)融合:

路 技术 擅长 实测命中
语义 Qdrant 向量(DashScope qwen3.7-text-embedding-flash,1024 维) 同义表达、模糊提问 20
关键词 Neo4j Cypher 全文 精确术语、指标名 6
图路径 Neo4j Cypher 图扩展 关系推理(谁→谁→什么) 16
RDF 三元组 官方 TripletStore(oxigraph) SPARQL 语义层关系推理 20

四路任何一路失败都不影响其它路,失败信息在界面上直接可见(不静默吞掉)。

融合后取 TOP-20 交给大模型生成答案,prompt 里强制要求引用 chunk_id。上面 Part 1 那张截图里,每个结论后面的方括号就是这么来的。


Part 4:四个真正在用的场景

工具做完就吃灰,是所有信息化项目的通病。所以我关心的第一个问题是:这东西到底解决了我每天的哪个动作?

4.1 溯源到原文那一页

assets/2026-10-28-医院质管知识图谱_02_制度原文预览.png

上面截图是点开”来源文件”后的原件预览——100 页的 2018 版《病历书写规范》,红头、文号、标题一字不差。

这一步的意义在于:以前我查制度,要在 PDF 里翻页找位置;现在从图谱上点到节点、点到来源文件,直接看到原文,而且能搜索、能打印、能下载。

顺带说一句,预览的响应头上有 Content-Range,说明支持分段取——42MB 的 PDF 不用一次性灌进浏览器。

4.2 时间轴:制度的”编年史”

assets/2026-10-28-医院质管知识图谱_03_时间轴与原文归档.png

左边是 65 份原始文件按年份排开(2018 年 5 份、2022 年 14 份、2023 年 15 份、2024 年 12 份……),绿点表示已解析。

点开任一份,两个视图切换:

  • 原始文件:直接看 PDF 原件
  • 提取版本:看机器解析出来的结构化文本

这两个视图并存,让我能随时怀疑机器:如果提取版本和原件对不上,一眼就能发现。

4.3 制度版本对比

前面 Part 3.6 讲透了。补一句使用场景:

医务科要写”2024 年新版临床路径与 2021 年的主要差异说明”,以前这活儿是两个人对着两份 PDF 逐条比对,两天。现在是先出条款级差异清单,人工只复核”未配对”和”指标变更”这两类。

4.4 智能问答

前面 Part 1 已经看了。再说一个我觉得更重要的细节:

答案里会主动说”不知道”。

比如问”证据中未提及任何允许超时完成入院记录的豁免情形”——它没有硬凑一条出来。

这是 prompt 里反复约束 + 只喂检索到的证据的结果。一个会承认不知道的质管助手,比一个什么都敢答的助手有用一百倍——因为前者你敢拿去回复临床。


Part 5:落地——网站只看,构建留本地

这一段是我在项目后期才想明白的。

5.1 为什么不能把管理功能一起放上去

这套系统里有一堆”能改图”的功能:实体复核、谓词复核、合并别名、重建全链。其中**「重建图谱」这一个按钮,点下去会 MATCH (n) DETACH DELETE n 清空整张图,然后重跑全链**。

我把它放在内网服务器上、给全院同事开放——这不叫方便,这叫在办公室里放了一个能拆房子的按钮。

所以最终形态是:同一套代码,两种模式。

本地开发机 内网服务器
启动 APP_MODE=local(默认) APP_MODE=showcase
前端构建 npm run build npm run build:showcase
侧边栏菜单 10 个 4 个
管理类接口 全部注册 13 条物理不注册
「重建图谱」按钮 有 无

5.2 关键:不是藏菜单,是后端不注册

这是我最想强调的一点。

前端把菜单藏起来,等于没锁——任何人直接在浏览器地址栏敲 /api/graph/rebuild?confirm=true,照样能把图谱清了。

所以后端在 showcase 模式下根本不注册那 13 条路由:

1
2
3
4
5
/api/graph/rebuild          404      /api/graph/merge            404
/api/graph/rebuild/status 404 /api/graph/decisions 404
/api/graph/entities 404 /api/graph/duplicates 404
/api/graph/entities/ops 404 /api/graph/merged 404
/api/graph/predicates/* 404 /api/graph/audit/provenance 404

敲 URL 也是 404。

而且前端也配合:管理页面改成动态 import,showcase 构建产物里根本不含那些组件的代码,不是”菜单藏了但代码还在包里”。

顺便说一句,这两侧的开关必须成对设置。只设一个,就会出现”菜单少了接口还在”或者”菜单还在接口 404”的尴尬局面。

5.3 服务器上的实际形态

内网 192.168.3.100,Ubuntu 24.04,没 Docker,所以是”venv + 官方二进制包 + systemd”:

1
2
3
4
5
6
浏览器 → nginx :8003  ─┬─ 静态: /opt/hospitalkg/scripts/25_graph_app/static
└─ /api → 127.0.0.1:3003 (uvicorn, APP_MODE=showcase)
├─ Neo4j 7687 (库 hospitalkg)
├─ Qdrant 6333
├─ DashScope 云端 LLM(语义检索 + 答案生成)
└─ 本地文件: raw/ · parsed/ · kg-triplets/

三个 systemd 服务(hospitalkg-web / hospitalkg-neo4j / hospitalkg-qdrant),全部开机自启。

这台服务器上已经有三个别的系统在跑(三甲指标录入查询系统、《质境猎人》、质管通),前端端口占着 8000/8001/8002,后端占着 3000/3001/3002。我全部避开,用了 8003 / 3003 / 7687 / 6333。部署完挨个复查了一遍那三个系统——都还是 200,没碰过人家。

5.4 数据怎么过去:单向同步

服务器上的数据不是让人去服务器上重建的,是本地构建完同步过去的:

1
2
3
4
5
6
本地(构建)                          服务器(只读展示)
────────── ──────────────
停 Neo4j → dump(238MB) ──────→ load → 启动
Qdrant scroll 导出(22MB) ──────→ upsert
代码 + raw/ + parsed/ ──────→ 覆盖
──────→ 重启 web

单向:本地 → 服务器。服务器永远不回写。

这里也有个教训:原本想用 Qdrant 的 snapshot 机制,结果发现 4699 个点的 snapshot 居然有 516MB(段没合并),换 scroll API 导出只有 22MB——23 倍差距。

5.5 部署踩的坑(挑三个最有代表性的)

坑一:Neo4j 官方下载源全站 403。

dist.neo4j.org 在这台机器上整个 CloudFront 策略把我们挡在外面;华为云、阿里云的镜像站里没有 neo4j 这个仓库(页面是 SPA,访问任何路径都返回 200,但那不是文件——这个假阳性让我绕了半小时)。

最后是直接打它的 S3 origin bucket 通了的:

1
https://s3.eu-west-1.amazonaws.com/dist.neo4j.org/neo4j-community-5.26.0-unix.tar.gz

而且跨境链路只有 30KB/s,151MB 要跑 85 分钟 → 写了 16 路并发分片下载。curl 的 --speed-limit/--speed-time 能熔断”连接还在但不下数据”的挂死,这个参数救了我两次。

坑二:Qdrant 1.19 的 upsert 格式,错一个字符就静默失败。

我用 POST + 新格式,接口返回 ok,但点还是 0 个——不报错、不写数据。实测五种格式之后才找到:1.19 要用 PUT + 旧格式 {"points":[...]}。

静默失败比报错可怕得多,因为它让你以为数据进去了。

坑三:llm_generate 里藏着一个 Windows 假设。

原来的实现是 subprocess.run(["cmd", "/c", "mmx", ...])——mmx 是我本地装的命令行工具。搬到 Linux 上,服务器既没有 cmd 也没有 mmx,问答就永远是 [LLM 生成失败,仅展示检索证据]。

改成 DashScope SDK 直连为主、mmx 为 Windows 回退,两个平台行为一致。

还有一个坑值得单说:nginx 要给 .mjs 单独 MIME。

Ubuntu 的 mime.types 里没有 .mjs,nginx 默认给 application/octet-stream,浏览器按规范”Strict MIME type checking”直接拒绝加载 pdf.worker.mjs → PDF 预览的 worker 起不来,页面一片空白。

修法是加一个 location:

1
2
3
4
location ~* \.mjs$ {
root /opt/hospitalkg/scripts/25_graph_app/static;
default_type application/javascript;
}

★ 注意别用 types 块——那会覆盖继承的其它类型。改完之后浏览器还缓存着旧的错误响应,得清一次缓存才生效。


Part 6:局限、代价,以及几句必须说清楚的大实话

写技术文章最容易犯的错,是把踩过的坑全写成”我已经解决了”。

所以这一节是专门用来泼冷水的。

6.1 版本对比的配对率,并不好看

上面那张《临床路径》2021 vs 2024 的截图,注意看标签栏:

1
全部 99 | 指标变更 2 | 修改 1 | 微调 9 | 未配对 52 | 未变 37

99 条里,有 52 条是”未配对”。 配对率刚过一半。

原因是两版体例差异太大(2021 版是 markdown 体例的”一、/(一)/1.”,2024 版是公文体的”第X篇/章/节”),条款切出来的东西形态就不一样,文字相似度自然上不去。

我能做的是:把这 52 条明明白白标成”未配对”,让人知道”这 52 条机器没配上,需要人工看”,而不是伪装成”未变”或者”废止”。

但它确实不够用。 真正要交付一份完整的差异说明,这 52 条还是得人看。

我现在在试的办法是章节级对齐(先把两版的章节配上,再在章节内配条款),单测里有过一次好的结果(2021 vs 2022 章节配对 24/25 全对),但整体实测反而更差——因为章节级配对本身就少,一约束就把款级也限死了。所以我把它关掉了,留着参数,等哪天章节配对够好再开。

教训:任何”看起来更严谨”的对齐约束,都要先在真实多组数据上量化对比(配对数 + 高质量配对数),不能凭直觉上线。

6.2 问答会答错,而且一定会答错

大模型会编。用了这么多层检索、这么多约束,它仍然会:

  • 把两条制度的条款拼成一个不存在的规定
  • 把”未配对”当成”没规定”
  • 对没有证据的问题给一个听起来合理的答案

这个系统不能替代人做判断,只能替人省时间。

我的心理预期是:它负责把 30 分钟的翻找变成 30 秒的定位 + 10 分钟的人工确认。 不是”不用看原文了”。

所以溯源不是加分项,是及格线。答不出出处的答案,在质管办是没有使用价值的。

6.3 图谱的质量,受限于抽取的质量

官方 NER 不校验输出类型。我实测过:传进去 21 类实体类型,LLM 照样产出”章节””地理单元””数量描述”这类表外类型——最终图上出现 216 种类型,其中 195 种是表外的,有 685 个实体落进了”其他”。

这不是 bug,是 LLM 的本性。治理办法是加一层类型归一(关键词 → 受控类型,33 条规则),但归一只能收敛,不能凭空补出关系。抽不出来的东西,图上就是没有。

6.4 它是持续工程,不是一次性项目

制度换一版,意味着:

  • 原文重新解析(跳过已解析的,这是必须的优化)
  • 重新抽取、重新建图、重新推理溯源
  • 重新做版本对比(新版本 vs 哪个旧版本比,这个决定也要人来定)

这是一整套以小时计的全量重跑。日常只加一份文件时走增量,快得多。

所以这不是”做完就放那儿”的东西。 它更像一个需要养的系统:制度更新了,它得跟上。

6.5 数据边界

raw/ 里是 65 份院内文件,303MB,全部是院内制度原文,有些还带文号。基本上还处于“测试”通过的状态,要补充的制度、流程、方案、通报、上级通知……还有七千多份待补充啊啊啊

所以这套东西只能在院内跑,不能上公网。部署版放在内网服务器、只读、访问留痕(谁在什么时候看了哪份文件,都记在日志里)——这是质管办的合规要求,不是技术洁癖。

6.6 还有一个”半成品”在那儿

国家医疗质量安全目标方案是逐年增删的:有的目标被移除,有的目标是新增的,而目标名跨年极稳定。

我单独抽了目标清单来做对比(因为条款级配对接不上),这个能精确回答”这几年国家目标增了哪些、删了哪些、改了名”。

但**”目标”和”实际指标值”之间还差最后一环**。年度方案里写”提高急性ST段抬高型心肌梗死再灌注治疗率”,而质量通报里的实际指标叫”急性ST段抬高型心肌梗死再灌注治疗率”——只差一个前缀,但因为它们类型不同,归一的时候合不到一起,闭环断在半路。

我做了目标名归一(带”提高/降低”前缀的并入基础名),合掉了 35 个。但剩下的那些,还需要更细的规则。这一环我还没做完。


Part 7:给同行的三点提示

如果你也在医院里折腾这类东西,下面三条是我踩了坑之后才想明白的:

提示一:先把”答案要能点回原文”写进需求

这句话看起来像锦上添花,实际上它决定了整个系统的架构。

如果只要求”能搜到”,那你做全文检索就行,一周的事。

但一旦要求”答案必须能溯源到具体哪份文件的哪一块”,它就会逼着你:

  • 在分块的时候就把”块”这个概念立住(检索粒度、向量粒度、引用粒度必须一致)
  • 在建图的时候保留原始 chunk_id(而不是只留实体名)
  • 在生成的时候强制模型引用

这三个约束,会在第一天就影响你的数据模型。 等做完了才发现要溯源,那是要重写的。

提示二:警惕”0 违规””0 冲突”这类输出

我的项目里踩过三次同样的坑:结果为空,到底是”真的没有”,还是”键没对上””被 except 吞了””规则太松”?

三次的现场都一样:

  • 一次是”0 条关系” —— 其实是谓词判定规则写错,2646 条被吃成 25 条
  • 一次是”图谱是空的” —— 其实是磁盘满导致官方 API 超时重试,建了两份节点
  • 一次是”所有条款都未变” —— 其实是压根没配对上

在质管办,”我不知道”是一个可以交付的答案;”0 违规”不是。

所以我现在的做法是:每加一条新规则,都要用真实数据去验证它是不是真的在生效,而不是看它返回了什么。

提示三:把”做不了”的部分,老老实实标出来

版本对比里”未配对 52 条”,比错的版本里”未变 52 条”有用得多。

不可比的对比组我直接拆掉,不做;纯表格文件做不了条款级对比,我显式跳过;替代关系原文没写”即日作废”,我就不建边,旧版状态写”已被修订”。

这些”缺口”在系统里是显式可见的标签,而不是被抹平的假象。

因为最终要拿着这份东西去汇报、去回复临床科室的是人。而人做判断的前提,是数据没有在骗他。


写在最后

一整套流水线,21455 个实体,91595 条关系,10 个制度 16 组版本对比,一个能在 1.2 秒给出带出处的答案的内网站点。

但说实话,让我觉得值的不是这些数字。

是当临床科室再打电话来问”入院记录超时怎么罚”的时候,我能直接在图上点开那份制度,翻到那一页,把答案发过去——而不是先花半小时在 300MB 的 PDF 里大海捞针。

工具的价值,不在它多聪明,在它让一件每天都要做很多遍的事,变得不那么费人。


[!TIP] 如果你想试试
这套东西已经部署在院内服务器上(内网地址,只读展示版)。
完整的架构说明、端口规划、以及 18 条部署时踩过的坑,都写在项目仓库的 deploy/README.md 里。
等我再继续完善几个版本后会放到 github 源代码开源,让想用的同行少走弯路。

也欢迎在评论区聊聊:
你们院的制度文件,是怎么管的?
是共享盘 + 各人自搜,还是有别的办法?
我特别想听听制度换版的时候,你们怎么确认”到底改了哪些”——这个问题,在我这里目前还只能做到”标出 52 条未配对,需要人工看”。