做医学临床知识图谱这件事,我从2019年前后开始断断续续折腾,头一年几乎全在返工上:本体改了四版,实体表重建两次,导入脚本重写了三遍,最后才跑通一条从「临床指南 + 药品说明书 + 病历结构化字段」到 Neo4j 可查询图谱的相对稳定流水线。医学临床知识图谱这个词听起来很唬人,但拆开看就是三件事:把临床上说得清的概念固化成节点,把概念之间的关系固化成边,再让这些节点和边能被程序高效地查出来。知识图谱构建的难点从来不在图数据库本身,而在于「临床上的一句话怎么变成机器不歧义的三元组」。
这篇东西适合三类人看:一是手里有一堆指南 PDF 和 HIS 导出表、但不知道从哪下手的工程师;二是想给自己的问诊系统或临床决策支持模块加一层结构化知识的产品同学;三是已经用 Neo4j 构建知识图谱但发现查得慢、查不准、实体对不上的同行。我会把本体设计、数据抽取、实体归一化、导入调优、质量校验这几段完整走一遍,代码和参数都给出可直接抄的版本,坑我踩过的也会标出来。全程不谈玄学,只谈能跑起来的方案。
1. 先想清楚:这张图谱到底要回答什么问题
1.1 临床知识图谱能回答的四类问题
很多人一上来就问「图谱怎么建」,其实更该先问「建完要用它回答什么」。我梳理下来,临床上真正用得上的查询就那么几类,先把它们列清楚,后面 Schema 设计基本就是倒推出来的。
第一类是关联查询:给定一个疾病,列出它的典型症状、常用检查、一线用药、并发症、所属科室。这类查询是图谱最基础的能力,也是价值最直接的——传统做法要跨好几张关系表 join,图谱里就是一跳或两跳。
第二类是反向推理:给一组症状或一组检验指标,倒推可能的疾病并按命中度排序。这就是鉴别诊断的雏形。注意我说的是「雏形」,图谱给的是候选集和排序依据,不是诊断结论,这条边界必须划清楚,否则产品化时会出大问题。
第三类是冲突与禁忌检查:给定患者正在用的几种药,找出药物相互作用;给定患者的基础病,找出用药禁忌。这类查询的价值密度最高,因为它是「人容易漏、机器不容易漏」的典型场景。
第四类是路径追溯:从疾病 A 到并发症 C,中间经过哪些机制或中间状态;或者某个药为什么被推荐,依据来自哪份指南的哪一条。这类查询拼的是证据溯源,也是医学知识图谱区别于通用知识图谱的地方。
把这四类问题列在白板上,你会发现一个规律:它们几乎全部依赖「疾病—症状—药物—检查」这四个核心实体,以及它们之间的带限定条件的边。这直接决定了后面的设计重心,别在花哨的实体类型上浪费太多时间。
1.2 用需求倒推三层结构,别一步到位做全集
我见过太多项目死在这一步:一开始就想做一个「包罗万象的医学知识图谱」,实体类型列了四十多种,结果半年过去连一个可用的查询都没有。我的建议是分三层来建,每层都能独立交付价值。
核心层是疾病、症状、药物、检查检验、手术这五类实体,加上它们之间最常用的十来种关系。这一层数据量不大,几千个疾病、两三万条关系,但能覆盖上面说的前三类查询,是必须先跑通的。
扩展层加解剖部位、病原体、人群(儿童/孕妇/老年)、科室、指南文献。这一层主要是给查询加过滤条件和证据溯源用的,等核心层稳定了再往里加。
临床实例层才是患者数据:具体的就诊记录、用药记录、检验结果。这一层涉及隐私和合规,通常不进主图谱,而是作为独立图或者挂靠节点存在,用脱敏 ID 关联。把知识层和实例层混在一张图里,是我早期踩过的最大的坑——数据量瞬间从万级跳到千万级,查询性能断崖式下跌,而且清洗逻辑完全不一样。
分层的另一个好处是版本可控。核心层的变更频率大概是一个季度一次,实例层是每天在变,混在一起做增量更新会很痛苦。
2. 图谱的骨架:本体与 Schema 设计
2.1 实体类型别自己造,从 ICD、SNOMED CT、UMLS 里抄作业
医学本体的好处是别人已经帮你标准化了几十年。疾病编码可以对齐 ICD-10 或 ICD-11,临床术语可以对齐 SNOMED CT,跨术语体系的映射可以借 UMLS 的 CUI(概念唯一标识)。哪怕你不用这些标准的全部内容,至少把它们当同义词词典和编码表用,能省掉巨量的实体对齐工作。
我实际的做法是:每个疾病节点上挂三个字段——内部 code、ICD-10 编码、UMLS CUI。内部 code 是主键,用于图谱内部关联;ICD 编码用于和院内系统对接;CUI 用于跨库映射。这三个字段撑起了整个实体对齐的基础。
实体命名上有个细节要注意:节点名用规范名,别名放数组属性。「急性心肌梗死」是规范名,「心梗」「AMI」「急性心梗」都放在 alias 属性里。查询时先过一遍别名映射表,再落到规范名上。我一开始图省事,把别名也建成了独立节点,结果图里出现了一堆指向同一概念的孤岛,后来全部推倒重来。
2.2 关系类型与限定属性:临床语义全在边上的那些字段里
如果本体设计有什么「一句话精髓」,那就是:医学知识图谱的信息量主要藏在边上,不在点上。同样一条「药物治疗疾病」的边,是「一线推荐」还是「二线备选」,是「A 级证据」还是「专家意见」,是「成人口服」还是「儿童静脉」,语义完全不同。
所以边的属性必须设计得足够细。我常用的几个字段:
- 证据等级(evidence_level):A/B/C 或强推荐/弱推荐,来源于指南或说明书。
- 推荐线数(line):一线、二线、三线。
- 适用人群(population):成人、儿童、孕妇、肝功能不全等。
- 给药途径(route):口服、静脉、外用。
- 来源(source):哪份指南、哪一版、哪一年。
- 置信度(confidence):抽取置信度,用于后续人工复核排序。
这些字段加起来,边比点还「重」。有人觉得冗余,但实际查询时你会发现,没有这些限定条件,返回的候选集根本没法用。比如查「2 型糖尿病的用药」,不带人群和线数过滤,返回几十种药,临床医生根本没法看。
还有一个容易被忽略的点:关系要有方向。(药物)-[:TREATS]->(疾病)和(疾病)-[:TREATED_BY]->(药物)只保留一个方向就够了,Neo4j 里反向查询不需要反向建边,用<-语法即可。建双向边是纯浪费存储,还会让 MERGE 逻辑变复杂。
2.3 把 Schema 写成一张可评审的表
Schema 这东西不能只存在脑子里,一定要落成文档给临床专家过一遍。我一般用两张表,一张实体表一张关系表,每次评审就改这两张表。实体的定义我通常这样整理:
| 实体类型 | 主键 | 关键属性 | 数据来源 | 预估量级 |
|---|---|---|---|---|
| Disease 疾病 | code | name, alias[], icd10, cui, dept | 指南、ICD、教科书 | 5k~2w |
| Symptom 症状 | code | name, alias[], body_part | 教科书、病历主诉 | 1k~5k |
| Drug 药品 | code | name, generic_name, alias[], atc | 药品说明书、ATC | 3k~1w |
| Exam 检查 | code | name, category, body_part | 检验科目录、指南 | 2k~8k |
| Surgery 手术 | code | name, category | 手术操作分类 | 1k~5k |
关系表我更看重「限定属性」这一列,因为它是后续抽检的重点:
| 头实体 | 关系 | 尾实体 | 限定属性 | 典型来源 |
|---|---|---|---|---|
| Disease | HAS_SYMPTOM | Symptom | frequency, typical | 教科书、指南 |
| Drug | TREATS | Disease | line, evidence, population, route | 指南、说明书 |
| Drug | CONTRAINDICATED_FOR | Disease | severity | 说明书 |
| Drug | INTERACTS_WITH | Drug | severity, mechanism | 用药审核库 |
| Disease | DIAGNOSED_BY | Exam | sensitivity, specificity | 指南 |
| Disease | COMPLICATED_BY | Disease | risk_level | 教科书 |
| Disease | LOCATED_IN | BodyPart | - | 解剖学 |
| Surgery | TREATS | Disease | indication | 指南 |
提示:Schema 评审时一定要拉上一个真正出门诊的医生。我遇到过好几次,工程师觉得天经地义的实体划分,临床医生一句话就推翻了——比如「高血压」和「高血压病」在他们看来是不是一回事,不同科室给的答案都不一样,这种分歧只能在评审桌上解决。
3. 数据从哪来,怎么把脏文本变成三元组
3.1 数据源盘点与优先级排序
数据源大概分四类,可靠性从高到低排下来是这样的:
第一类是结构化编码表,ICD、ATC、LOINC、院内检验项目字典。这类数据几乎不需要清洗,直接映射成节点,是图谱的底座。缺点是只有「是什么」,没有「有什么关系」。
第二类是临床指南与专家共识。这是关系数据的主战场,里面的「推荐 XX 药治疗 XX 病(I 类推荐,A 级证据)」就是标准的三元组加限定属性。指南一般是 PDF,章节结构规整,抽取难度中等。
第三类是教科书和药品说明书。教科书提供症状、并发症这类基础关系,说明书提供禁忌、相互作用、用法这类安全相关信息。说明书是半结构化的,禁忌和相互作用部分通常有明显的段落标题,用规则抽取就能拿到不错的效果。
第四类是病历文本和历史问诊记录。这类数据的价值在于补充真实世界的表达方式——患者怎么说「心口疼」而不是「胸痛」。但它噪声最大,而且涉及隐私,必须先去标识化。我一般用它来做别名挖掘,不直接抽关系。
优先级上,我的建议是前两类先做,做到能覆盖核心查询再动第三类。第四类只在需要提升召回率的时候才引入。
3.2 抽取:规则、序列标注模型、大模型三条路线怎么选
这是最花时间的环节,也是三条路线各有适用面的地方。
规则 + 词典匹配适合实体识别,尤其是实体集合相对封闭的场景。用 Aho-Corasick 自动机一次扫过全文匹配几万个术语,速度快、结果可控、完全可解释。缺点是泛化差,遇到词典外的表达就哑火。我的做法是把词典匹配作为兜底和校验:模型抽出来的实体,必须在词典里能找到近义项,否则标为待人工复核。
序列标注模型是过去几年 NER 的主流。BERT + BiLSTM + CRF 这一套在中文医学文本上效果稳定,实体类别设成 B-Disease、I-Disease 这样的 BIO 标注。关系抽取早期用 PCNN、后来用 CasRel 这类联合抽取模型,直接输出 (头实体, 关系, 尾实体) 三元组。训练数据从哪来?我的经验是先标 500~1000 条指南句子,这个量级配合预训练模型已经能出八十分左右的效果,剩下的靠词典和规则补。
大模型抽取是最近两年我投入比较多的方向,优点是零样本能力强、能处理长句和复杂嵌套,缺点也很明显:幻视率不可忽略,尤其是带限定属性的边,模型很容易把「二线」写成「一线」。我的组合策略是:大模型负责初抽,产出候选三元组;然后用两个「闸门」过滤——一是实体必须能在术语词典里对齐;二是限定属性值必须落在预先定义的枚举集合里(线数只能是 1/2/3,证据等级只能是 A/B/C/D)。不满足的直接进复核队列,不让它污染图谱。
实操上我会给大模型加严格的输出约束,让它只吐 JSON,并且用 JSON Schema 卡住字段类型:
from pydantic import BaseModel, Field from typing import Literal, List class Triple(BaseModel): head: str head_type: Literal["Disease", "Symptom", "Drug", "Exam", "Surgery"] relation: Literal["HAS_SYMPTOM", "TREATS", "DIAGNOSED_BY", "CONTRAINDICATED_FOR", "COMPLICATED_BY"] tail: str tail_type: Literal["Disease", "Symptom", "Drug", "Exam", "Surgery"] line: Literal["1", "2", "3", ""] = "" evidence_level: Literal["A", "B", "C", "D", ""] = "" source: str class ExtractResult(BaseModel): triples: List[Triple] = Field(default_factory=list)配合结构化输出接口,模型返回的每个字段都被枚举值卡死,后处理压力小很多。注意:枚举卡得太死会丢召回,比如指南里写「可考虑」这种模糊表述,我通常映射成 C 级证据加上一个weak=True的布尔属性,而不是直接丢掉。
3.3 实体对齐:让「心梗」和「急性心肌梗死」落到同一个节点
这一步决定了图谱的连通性,也是最需要耐心的地方。我的流程是四步:
第一步,精确匹配。把所有已知别名建成哈希表,命中就归并。这一步能解决八成以上的情况。
第二步,规则归一化。去掉修饰词再匹配,比如「急性」「慢性」「原发性」「继发性」这些前缀,以及「病」「症」「综合征」这些后缀。构造一个归一化键:strip(修饰词) + 规范化词干。这一步能捞回一批漏网的同义词。
第三步,向量相似召回。用中文医学语料微调过的句向量模型(BGE、M3E 这类都可以)把所有实体名编码成向量,建 FAISS 索引,对未匹配的实体取 Top-20 相似候选,相似度超过阈值的进候选池。阈值我一般卡在 0.92 左右,低于这个值人工抽检的准确率会明显掉。
第四步,人工审核。前三步跑完,剩下的通常是真·歧义项,比如「风湿性心脏病」和「类风湿关节炎」这种字面相似但完全无关的,必须人工过一遍。这一步的量级一般控制在全量的 3% 以内,是可以接受的成本。
注意:合并实体是不可逆操作,一定要留审计日志。我习惯在图里额外建一层
:Alias节点指向规范节点,而不是直接把别名塞进数组属性。这样做的好处是历史追溯方便,出问题能回滚;缺点是图会大一些。数据量在百万级以下时,我推荐用 Alias 节点。
4. Neo4j 落地:从 CSV 到可查询的图
4.1 建模决策:什么该做节点,什么该做属性
有个反复被问的问题:解剖部位、科室、人群这些东西,该建成节点还是建成属性?我的判断标准有两条:
如果它需要被独立查询、需要和别的实体产生关系,就建节点。比如「科室」,如果业务上要「按科室查疾病清单」,那它必须是节点。如果只是展示用,属性就够了。
如果它的取值是有限枚举且不参与关联,就做属性。比如「给药途径」,取值就口服/静脉/外用那么几个,没有别的实体指向它,做成边上的字符串属性最省事。
还有一个经验:不要为了「图看起来漂亮」而过度拆节点。我早期把「频次」也建成了节点,结果一个疾病到症状的路径变成三跳,查询性能掉了一半,维护成本翻倍。后来全部改成边属性,世界清净了。
4.2 导入方案选型与 Cypher 实操
导入方案按数据量分三档:
| 数据量 | 推荐方案 | 说明 |
|---|---|---|
| < 10 万条 | LOAD CSV + MERGE | 简单直接,支持在线增量 |
| 10 万 ~ 1000 万 | apoc.periodic.iterate | 分批提交,可控内存 |
| > 1000 万 | neo4j-admin 离线导入 | 最快,但必须停库全量重建 |
先说约束和索引。这一步必须在导入前做,不是导入后。唯一约束不仅保证数据质量,还能让 MERGE 走索引,速度差好几个数量级:
CREATE CONSTRAINT disease_code IF NOT EXISTS FOR (d:Disease) REQUIRE d.code IS UNIQUE; CREATE CONSTRAINT drug_code IF NOT EXISTS FOR (d:Drug) REQUIRE d.code IS UNIQUE; CREATE INDEX symptom_name IF NOT EXISTS FOR (s:Symptom) ON (s.name); CREATE FULLTEXT INDEX entity_name_search IF NOT EXISTS FOR (n:Disease|Symptom|Drug|Exam) ON EACH [n.name, n.alias_text];那个全文本索引是我后来加的,查询时用db.index.fulltext.queryNodes做模糊匹配,比CONTAINS快得多,也解决了别名问题——把别名数组预先拼成一个alias_text字段存进去,查询时一句话就能命中所有别名。
节点导入的标准写法:
LOAD CSV WITH HEADERS FROM 'file:///disease.csv' AS row MERGE (d:Disease {code: row.code}) SET d.name = row.name, d.icd10 = row.icd10, d.cui = row.cui, d.alias_list = split(coalesce(row.alias, ''), '|'), d.alias_text = replace(coalesce(row.alias, ''), '|', ' ')关系导入我强烈建议用apoc.periodic.iterate分批跑,尤其是几十万条以上。一次性 LOAD CSV 建关系会撑爆事务内存,报 GC overhead 或者直接 OOM:
CALL apoc.periodic.iterate( 'LOAD CSV WITH HEADERS FROM "file:///rel_drug_disease.csv" AS row RETURN row', 'MATCH (dr:Drug {code: row.drug_code}) MATCH (di:Disease {code: row.disease_code}) MERGE (dr)-[r:TREATS]->(di) SET r.line = row.line, r.evidence_level = row.evidence, r.population = row.population, r.source = row.source, r.confidence = toFloat(row.confidence)', {batchSize: 5000, parallel: false, iterateList: true} );parallel: false是刻意的。开并行虽然快,但多个线程同时 MERGE 同一对节点容易撞锁,报 DeadlockDetected,重试逻辑还得自己写,不划算。
如果数据量上千万,直接上离线导入:
neo4j-admin database import full \ --nodes=import/disease_nodes.csv \ --nodes=import/drug_nodes.csv \ --relationships=import/rel_treats.csv \ --delimiter=',' \ --array-delimiter='|' \ --quote='"' \ --skip-bad-relationships=true \ --bad-tolerance=10000 \ --id-type=STRING注意:Neo4j 5.x 的命令是
neo4j-admin database import full,4.x 是neo4j-admin import,参数名也有差异。别直接抄老教程的命令,先在测试库上跑一遍。另外离线导入要求 CSV 第一行是表头,节点文件必须包含:ID和:LABEL两列,关系文件必须包含:START_ID、:END_ID、:TYPE,这几个约定搞错了会直接报格式错误。
4.3 索引、约束与查询性能调优
导入完先别急着写业务查询,跑一下EXPLAIN看执行计划。我总结了几个高频的慢查询原因:
第一种,用属性做节点定位。MATCH (d:Disease) WHERE d.name = '2型糖尿病'如果 name 上没索引,就是全标签扫描。解决办法要么把 code 作为主键查,要么给 name 建索引。我更推荐前者,因为规范名会变,code 不会。
第二种,路径查询不加深度限制。MATCH p=(a)-[*]-(b)这种写法在小图上没事,在百万级图上会直接跑死。永远给可变长度关系加深度上限,比如[*1..3]。临床上三跳以上的关系基本没有直接解释力,加了上限反而提升结果质量。
第三种,大结果集不带 LIMIT。开发阶段图小感觉不出来,上线后一个查询返回几万行,序列化时间比查询本身还长。
一个实际调优过的鉴别诊断查询长这样:
MATCH (s:Symptom)<-[r:HAS_SYMPTOM]-(d:Disease) WHERE s.code IN $symptom_codes WITH d, count(DISTINCT s) AS hit, sum(coalesce(r.weight, 1.0)) AS score WHERE hit >= 2 RETURN d.name AS disease, hit, round(score, 2) AS score ORDER BY score DESC, hit DESC LIMIT 20WHERE hit >= 2这个过滤很关键——只命中一个症状的疾病会返回一大堆,把噪声压下去才是可用的候选集。sum(weight)让不同症状的权重能体现出来,比如「胸痛」的权重高于「乏力」。
5. 质量校验与推理补全
5.1 上线前的质量校验清单
图谱建完,我一般跑一遍下面这份清单,任何一项不达标就先别上线:
| 校验项 | 检查方式 | 参考阈值 |
|---|---|---|
| 孤立节点比例 | 统计度数为 0 的节点占比 | < 5% |
| 重复实体残留 | 按 name 分组统计 count > 1 | 0(人工确认后清零) |
| 关系方向错误 | 抽样人工核对 | 抽检准确率 > 95% |
| 矛盾关系 | 同药同病同时存在 TREATS 和 CONTRAINDICATED_FOR | 逐条人工确认 |
| 环路异常 | 查 COMPLICATED_BY 自环 | 0 |
| 来源缺失 | 统计 source 为空的边占比 | < 2% |
| 限定属性越界 | 校验枚举值合法性 | 0 |
孤立节点那块我特别说一下。孤立节点通常是两种原因:一是实体抽取出来了但关系没抽到,二是实体对齐没做好,被拆成了小连通分量。前者靠补抽,后者靠扩大相似召回阈值再跑一轮。
矛盾关系的检查我写成了一条固定查询:
MATCH (dr:Drug)-[:TREATS]->(di:Disease) MATCH (dr)-[c:CONTRAINDICATED_FOR]->(di) RETURN dr.name, di.name, c.severity, c.source ORDER BY c.severity DESC跑出来一般不会太多,但每一条都得看。多数情况是证据等级不同导致的——一线推荐用于某个人群,同时对另一个人群禁忌。这种不是错误,是限定条件没写全,加上population属性就解决了。
5.2 用规则和图算法做关系补全
图谱天然适合做传递性推理。最典型的一条:如果 A 药治疗 B 病,B 病是 C 病的并发症,那 A 药对 C 病可能有间接获益。这条推理在 Cypher 里就是两跳:
MATCH (dr:Drug)-[:TREATS]->(b:Disease)-[:COMPLICATED_BY]->(c:Disease) WHERE NOT (dr)-[:TREATS]->(c) RETURN dr.name, b.name, c.name, 'indirect' AS infer_type LIMIT 100提示:推理出来的关系必须打上标记,不能和抽取出来的关系混在一起。我习惯给推理边加一个
inferred: true属性和infer_type字段,查询时默认过滤掉,需要时才显式打开。医学场景下,把推理结果当事实展示是会出事的。
除了自写的传递规则,Neo4j 的 GDS 库也能用上。比如用gds.nodeSimilarity找症状模式相似的疾病,用来发现「疑似漏抽的 HAS_SYMPTOM 边」——两个疾病的症状集合重合度很高,但其中一个缺了某个症状,大概率是抽取漏了。这个方法我用来做补抽的候选挖掘,比人工翻指南效率高得多。
还有一类补全是反向对称补全。比如INTERACTS_WITH关系在临床语义上是对称的,但抽取时可能只抽到一个方向。这种情况不需要建双向边,查询时用无向匹配-[r:INTERACTS_WITH]-就够了,或者在导入后跑一次规范化脚本统一方向。
6. 常见问题与排查技巧实录
6.1 我反复踩到的六个坑
坑一:Schema 没定就开抽。这是最贵的错误。第一次做的时候我觉得「先抽出来再说,Schema 后面调」,结果抽了三个月,Schema 一改,所有抽取结果全部作废。正确顺序是 Schema 定稿 → 小样本试抽 → 调 Schema → 全量抽取。
坑二:把患者数据混进知识图谱。前面提过,这里再强调一次。知识层的节点是「2 型糖尿病」这个抽象概念,实例层的节点是「张三的 2023 年 5 月诊断」。混在一起会导致:MERGE 时意外合并、查询时结果集暴涨、脱敏逻辑极其难写。物理隔离是最省心的做法。
坑三:忽视时间维度。医学知识会更新,指南平均三到五年一版,药品说明书随时可能改。我建议每条关系都加上valid_from和valid_to,当前有效的valid_to留空。查询时默认加WHERE r.valid_to IS NULL。这个字段后期加的成本极高,一开始就加上几乎零成本。
坑四:过度依赖单一数据源。只从指南抽,会漏掉真实世界的高频用药;只从说明书抽,会得到一堆过时信息。我的经验是至少三个来源交叉验证,来源冲突时按「指南 > 说明书 > 教科书」的优先级取值,同时把冲突记录下来。
坑五:大模型抽取不做校验直接入库。这个坑我踩得最惨,一次批量导入把几百条错误的证据等级写进了图,因为没留审计日志,只能整批回滚重导。从那以后,所有模型抽取的结果必须经过「词典对齐 + 枚举校验」两道闸门才能进正式库,不通过的进待审表。
坑六:只做导入不做监控。图谱是活的,每周都在加新数据。没有监控的话,某次导入把实体搞重了,可能几周后才发现。我现在的做法是每次导入后自动跑一遍 5.1 的校验清单,指标超阈值就告警。
6.2 问题排查速查表
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| 查询返回空但数据明明有 | 属性名拼错 / 实体名未归一化 | 先MATCH (n:Label) RETURN n LIMIT 5看实际属性名 |
| 查询越来越慢 | 缺索引 / 关系深度无上限 | EXPLAIN看是否 AllNodesScan |
| MERGE 创建了重复节点 | 唯一约束没建 / 大小写不一致 | 检查约束,统一toLower(trim(...)) |
| 导入报内存不足 | 单事务太大 / 批次过大 | 降 batchSize 到 2000~5000,或改离线导入 |
| 别名查不到 | name 和 alias 分开存储 | 建全文本索引,走 alias_text 字段 |
| 相似实体没合并 | 相似度阈值太高 | 阈值从 0.95 降到 0.90 再抽检准确率 |
| 关系方向反了 | 抽取时头尾颠倒 | 抽样 200 条人工核对,重导关系文件 |
| 推理结果污染正式数据 | 未打 inferred 标记 | 立刻按inferred=true批量删除 |
我个人的经验是,图谱项目里 60% 的时间花在数据清洗和对齐上,20% 在 Schema 设计,只有 20% 在写查询和调优。如果发现自己在 Cypher 上花的时间超过一半,多半是前面的数据环节出了问题,回头看看比继续调优更划算。
7. 图谱怎么接到业务上:两种典型的落地接口
7.1 基于 Cypher 模板的确定性查询
这是最稳的落地方式:把业务问题固化成有限几个查询模板,用户输入经过实体识别和归一化后,填充到模板参数里执行。比如「XX 病用什么药」对应一段固定的 Cypher,「这几个药能不能一起吃」对应另一段。
这种方式的优点是结果完全可解释、可追溯、不会胡说。缺点是覆盖范围受限于模板数量。我的做法是先覆盖最高频的 20 个问题类型,看实际使用数据再决定要不要扩。在医学场景下,宁可少答,不可错答,这条原则我觉得比任何技术选型都重要。
参数化查询有个小技巧:把实体识别出来的原始文本先过一遍别名映射,映射不到就降级走全文本索引模糊匹配,而不是直接返回空。用户体验差别很大。
7.2 图谱增强检索的取舍
把图谱和检索增强生成结合起来是现在的热门做法,思路是先用图谱召回一批结构化事实,再把这些事实作为上下文喂给模型生成回答。好处是回答有据可依,模型不容易瞎编;代价是多了一层检索延迟,而且图谱的覆盖率直接决定了回答质量的上下限。
我的取舍是:事实类问题走图谱直接回答,解释类问题才走图谱加生成。比如「这个药有什么禁忌」是事实类,直接返回图谱里的禁忌列表就够了,还更准确;「为什么会推荐这个方案」是解释类,这时候把证据来源和路径一并交给模型组织语言,效果不错。
有一点必须提醒:图谱召回的事实里,confidence低于阈值或者inferred=true的边,在生成阶段要明确标注不确定性,不能当作确定结论输出。这个细节看起来小,但在医学产品里是原则问题。
这套东西我前后做了将近五年,最大的体会是:医学临床知识图谱的技术门槛其实不高,Neo4j 的导入和查询语法一周就能学会,真正难的是在「追求覆盖率」和「保证准确性」之间反复拿捏。我现在的习惯是每个季度抽出半天时间,随机抽 100 条边上的人工核对一遍,把准确率曲线记在同一个表里。这条曲线比任何技术指标都更能说明图谱的健康度——它涨得慢,但一旦掉下来,一定说明上游某个环节的清洗逻辑出了问题。