ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Neo4j的简易医疗问答知识图谱:从本体设计到避坑指南

基于Neo4j的简易医疗问答知识图谱:从本体设计到避坑指南 简介基于neo4j的简易医疗问答知识图谱是一份面向知识图谱初学者与医疗信息处理开发者的实战项目包。它从ask120平台爬取医疗问答数据通过数据清洗与建模将疾病、症状、药物等实体及其关系导入neo4j图形数据库实现医疗知识的可视化查询与智能问答。压缩包共37个文件以Python脚本为主含13个py源码另有pyc编译文件、xml配置、html页面及js等整体仅78KB便于快速下载与复用。目前已有5461人学习适合希望掌握Django框架、网络爬虫与Cypher查询的读者。资源内含完整的爬虫脚本、Django工程配置以及qaprocess应用模块并覆盖从数据采集、实体识别、关系建模到Cypher查询接口的完整处理链路读者可直接参考其数据预处理流程与图谱查询逻辑快速搭建属于自己的医疗知识图谱系统。1. 基于 Neo4j 的简易医疗问答知识图谱先想清楚这三件事把「基于 Neo4j 的简易医疗问答知识图谱」这个标题当真做一遍你会发现医疗问答里 80% 的高频问题本质都是在问「两个实体之间有没有关系、中间隔了几层」。这种问题用关键词检索做不干净用关系型数据库要写三层 JOIN而图数据库天生就是干这个的。Neo4j 是生态最成熟、资料最多、社区版就够用的图数据库所以这个标题的落地路径其实很清晰先设计本体再导数据最后写问答逻辑。这篇文章面向第一次用 Neo4j、手里有一份医疗语料、想一周内跑通 Demo 的人直接讲清这条路上最关键的三个环节和五个坑。2. 医疗问答为什么选 Neo4j多跳查询是图数据库的原生能力2.1 医疗问答的查询本质不是过滤是找路径「头痛挂哪个科」这个问题的完整推理链是头痛 → 可能是感冒 → 感冒属于呼吸内科。关系型数据库要回答它需要症状表和疾病表 JOIN再和科室表 JOIN最后再做过滤。JOIN 层数一多SQL 就写成一坨面条代码查询计划也越来越不可控。图数据库把「多跳 JOIN」变成了「沿边走」Cypher 里一次MATCH就能表达任意深度的关系不需要关心底层怎么联表。关键词检索同样解决不了这个问题。ES 或 MySQL 的 LIKE 能告诉你「哪些页面出现了头痛」但给不出「头痛和呼吸内科之间隔了哪一层疾病」这样的结构化路径。医疗问答要的是可解释的回答是「A 到 B 之间有路径、路径上的节点是什么」这正是图的原生操作。所以这个标题里的「问答」本质是「在图里找路径」选 Neo4j 不是因为潮流而是因为查询模型和问题的结构一致。还有一个容易被忽略的点知识图谱的维护成本。医疗数据更新频繁今天加一个症状关联明天改一个科室归属。关系型数据库加一张关系表要改表结构、写迁移脚本图数据库加一条边就是一行MERGE不改 schema。这一点在你后续迭代语料时价值极大简易项目尤其吃这个红利。2.2 本体设计四类节点、三类关系先跑通再扩充第一版本体别设计得太满。我见过有人第一版就定了十类节点、十几类关系结果数据还没导完就放弃了。简易医疗问答只需要四类节点、三类关系就能覆盖大部分高频问题。节点类型含义示例Disease疾病感冒、偏头痛Symptom症状头痛、发热、咳嗽Drug药物布洛芬Department科室呼吸内科、神经内科关系类型方向语义HAS_SYMPTOM(Disease)-[]-(Symptom)疾病表现出该症状TREATS(Drug)-[]-(Disease)药物用于治疗该疾病BELONGS_TO(Disease)-[]-(Department)疾病归该科室诊治为什么是这个规模三类关系正好覆盖「症状对应什么病」「这个病吃什么药」「这个病挂什么科」三个最高频的问答意图。关系类型少意味着后面写 Cypher 模板、做路径白名单都简单。等第一版跑通了再按真实的问答记录去加「禁忌」「并发症」这类关系不要一上来就全。关系方向要统一这是第一个容易翻车的地方。TREATS 我建议统一写成 Drug → DiseaseBELONGS_TO 写成 Disease → Department。方向一旦定下来后续所有模板都按这个方向写不然同一个查询在不同代码里方向不一致排查起来非常痛苦。2.3 部署选型社区版 APOC一个容器就够了单机 Demo 不需要上集群。我一般用 Docker 跑 Neo4j 4.4 社区版把数据目录、插件目录、import 目录都挂出来重启不丢数据也方便从宿主机直接丢 CSV 进去。# 启动 Neo4j 4.4 社区版挂载数据与插件目录 docker run -d \ --name neo4j-medqa \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTHneo4j/changeMe \ -e NEO4J_server_memory_heap_max__size4G \ -e NEO4J_dbms_security_procedures_unrestrictedapoc.* \ -v /data/neo4j:/data \ -v /data/neo4j/plugins:/plugins \ -v /data/neo4j/import:/import \ neo4j:4.4-community环境变量说明NEO4J_server_memory_heap_max__size里的双下划线对应配置文件里的点号层级意思是把server.memory.heap.max_size设为 4G。dbms_security_procedures_unrestrictedapoc.*是放开 APOC 存储过程的调用权限否则后面apoc.periodic.iterate会被拦截。几千到十万节点的医疗图谱4G 堆完全够不需要去调 page cache 那些玄学参数。APOC 不是必装但建议装。动态关系类型、批量提交、路径扩展这些能力都靠它做问答系统时能省很多事。装法很简单把对应版本的 jar 丢进 plugins 目录重启即可。提示4.4 和 5.x 的约束语法不一样。下面代码里我会写 4.4 语法并在旁边标注 5.x 的差异。3. 从零建库数据清洗、CSV 导入与索引配置3.1 数据清洗把半结构化文本转成 CSV 三元组医疗数据的原始形态常见有三种结构化表格、半结构化文本、纯自由文本。简易图谱第一版建议只处理前两种纯文本先放一边否则清洗成本会吃掉你八成的时间。常见做法是写一个 Python 脚本把「疾病:症状:药物:科室」这种行记录拆成三元组输出nodes.csv和relations.csv两个文件。# build_csv.py把“疾病:症状:药物:科室”格式的语料转成 CSV import csv # 语料示例感冒:头痛,发热,咳嗽:布洛芬:呼吸内科 lines [ 感冒:头痛,发热,咳嗽:布洛芬:呼吸内科, 偏头痛:头痛,恶心:布洛芬,对乙酰氨基酚:神经内科, ] def build_graph(lines): nodes, rels [], [] seen set() def add_node(eid, name, ntype): if name not in seen: # 按名称去重避免重复节点 seen.add(name) nodes.append([eid, name, ntype]) for i, line in enumerate(lines): parts [p.strip() for p in line.split(:)] if len(parts) 4: continue # 脏行直接跳过 disease, symptoms, drugs, dept parts[:4] d_id fd{i} add_node(d_id, disease, Disease) for s in symptoms.split(,): s s.strip() add_node(fs-{s}, s, Symptom) rels.append([d_id, fs-{s}, HAS_SYMPTOM]) for drug in drugs.split(,): drug drug.strip() add_node(fdr-{drug}, drug, Drug) rels.append([fdr-{drug}, d_id, TREATS]) # 药物指向疾病 add_node(fdp-{dept}, dept, Department) rels.append([d_id, fdp-{dept}, BELONGS_TO]) with open(nodes.csv, w, encodingutf-8, newline) as f: w csv.writer(f) w.writerow([id, name, type]) w.writerows(nodes) with open(relations.csv, w, encodingutf-8, newline) as f: w csv.writer(f) w.writerow([start, end, rel]) w.writerows(rels) print(fnodes{len(nodes)} rels{len(rels)}) build_graph(lines)逻辑说明先按疾病逐行展开症状、药物、科室各自建节点再建立对应的关系记录。节点 id 加d-、s-、dr-、dp-前缀是为了保证全局唯一。按名称去重比按 id 去重更符合常识「头痛」不管在哪条疾病里出现都只建一个 Symptom 节点。参数说明newline是 csv 模块在 Windows 下防止写出行之间多空行的关键参数。编码用utf-8如果语料来自 Excel 另存一定要在脚本里统一转成 UTF-8 再处理Excel 默认的 ANSI 编码会让中文直接变乱码。提示所有 CSV 一律用脚本导出不要手改、不要用 Excel 另存。这个习惯能避开后面讲的 BOM 坑。3.2 批量导入LOAD CSV 与约束的配合数据量在几十万行以内LOAD CSV是最快跑通的方式超过这个量级再考虑neo4j-admin import或者apoc.periodic.iterate分批提交。我一般按「先建约束、再导节点、最后导关系」三步走任何一步出错都好排查。// 1. 建约束Neo4j 4.4 语法 // 5.x 写法CREATE CONSTRAINT FOR (n:Disease) REQUIRE n.id IS UNIQUE CREATE CONSTRAINT disease_id IF NOT EXISTS ON (n:Disease) ASSERT n.id IS UNIQUE; CREATE CONSTRAINT symptom_id IF NOT EXISTS ON (n:Symptom) ASSERT n.id IS UNIQUE; CREATE CONSTRAINT drug_id IF NOT EXISTS ON (n:Drug) ASSERT n.id IS UNIQUE; CREATE CONSTRAINT dept_id IF NOT EXISTS ON (n:Department) ASSERT n.id IS UNIQUE; // 2. 导节点按 type 字段分流到不同标签 LOAD CSV WITH HEADERS FROM file:///nodes.csv AS row WITH row WHERE row.type Disease CREATE (n:Disease {id: row.id, name: row.name}); LOAD CSV WITH HEADERS FROM file:///nodes.csv AS row WITH row WHERE row.type Symptom CREATE (n:Symptom {id: row.id, name: row.name});Drug 和 Department 的导入同理把标签换掉即可。约束的作用有两个一是保证 id 不重复二是在 id 上自动建索引后面关系导入时的MATCH才能走索引而不是全表扫。关系导入要按关系类型分别写这是最稳的写法// 3. 导关系按 rel 字段分流 LOAD CSV WITH HEADERS FROM file:///relations.csv AS row WITH row WHERE row.rel HAS_SYMPTOM MATCH (a:Disease {id: row.start}) MATCH (b:Symptom {id: row.end}) CREATE (a)-[:HAS_SYMPTOM]-(b); LOAD CSV WITH HEADERS FROM file:///relations.csv AS row WITH row WHERE row.rel BELONGS_TO MATCH (a:Disease {id: row.start}) MATCH (b:Department {id: row.end}) CREATE (a)-[:BELONGS_TO]-(b);参数说明关系导入的MATCH必须带标签比如(a:Disease {id: row.start})不要写成裸的(a {id: row.start})。不带标签时 Cypher 无法确认用哪个索引会退化成全图扫描。同一个 relations.csv 被反复读取、按 rel 过滤在十万行以内性能完全可接受如果关系量过了百万再换成apoc.periodic.iterate分批提交每批 500 条避免单事务内存膨胀。3.3 全文索引模糊查询和别名兜底靠它约束建的索引只对精确匹配生效。Cypher 里写WHERE n.name CONTAINS 痛是走不上索引的会全库扫。所以问答模块的词典匹配和精确查询都尽量用 id 或完整 name。但用户输入常常带噪音比如「头痛挂哪个科」和「头疼挂哪个科」这种场景需要一个全文索引来做模糊兜底。// 全文索引支持中文分词 CREATE FULLTEXT INDEX entity_name IF NOT EXISTS FOR (n:Disease|Symptom|Drug|Department) ON EACH [n.name]; // 用全文索引做模糊查询 CALL db.index.fulltext.queryNodes(entity_name, 头疼) YIELD node, score RETURN node.name, score;参数说明FOR (n:Disease|Symptom|Drug|Department)表示索引覆盖四类节点的 name 属性查询时可以跨类型命中。全文索引会占内存十万节点以内没问题如果语料膨胀到百万级要评估是否真的需要全文索引还是用实体词典先从用户输入里切出标准名。4. 问答系统落地实体识别、模板匹配与兜底回答4.1 实体识别先用词典最大匹配别急着上模型很多初学者第一反应是训练一个命名实体识别模型。但简易项目里你手里可能只有几千个实体连标注数据都没有模型效果不会比词典好。常见做法是把图里所有节点名称导出来做成词典用正向最大匹配做实体抽取命中率足够而且完全可解释——你清楚每一个实体是怎么被切出来的。# ner.py正向最大匹配 VOCAB { 布洛芬: Drug, 感冒: Disease, 头痛: Symptom, 呼吸内科: Department, } def max_match(text: str, max_len: int 8) - list: i 0 entities [] # [(实体词, 类型), ...] while i len(text): hit None # 从最长词开始尝试优先匹配长实体 for end in range(min(i max_len, len(text)), i, -1): w text[i:end] if w in VOCAB: hit w break if hit: entities.append((hit, VOCAB[hit])) i len(hit) # 跳过已匹配片段 else: i 1 # 单字前进不阻塞后续匹配 return entities print(max_match(感冒了吃什么药)) # [(感冒, Disease)]逻辑说明指针从文本开头向右移动每次从当前位置取最长的候选词命中词典就记录实体并跳过这段没命中就前移一个字。这个算法对「专有名词被切碎」的病有天然免疫力因为它根本不依赖分词器。参数说明max_len应该取词典里最长的实体长度可以从词典统计max(len(w) for w in VOCAB)而不是拍脑袋写死。匹配顺序「左到右、最长优先」保证「上呼吸道感染」不会被先切出「上呼吸」再切出「道感染」。词典本身不要手写用一段 Cypher 从图里导MATCH (n:Disease|Symptom|Drug|Department) RETURN labels(n)[0] AS type, collect(n.name) AS names图里新增节点后词典同步更新问答能力跟着涨这是「图驱动问答」的核心优势。4.2 意图分类与模板把口语问句翻译成 Cypher实体识别解决了「问题里有哪些医学概念」接下来要解决「用户想干什么」。简易方案用正则做意图分类每个意图对应一个 Cypher 模板。模板和 2.2 的三类关系一一对应。# qa.py意图分类与模板映射 import re from neo4j import GraphDatabase TEMPLATES [ (re.compile(r.*什么病|.*怎么(了|回)事), symptom2disease, MATCH (s:Symptom {{name:{e1}}})-[:HAS_SYMPTOM]-(d:Disease) RETURN d.name), (re.compile(r.*吃什么药|.*用什么药), disease2drug, MATCH (d:Disease {{name:{e1}}})-[:TREATS]-(dr:Drug) RETURN dr.name), (re.compile(r.*挂什么科|.*哪个科), disease2dept, MATCH (d:Disease {{name:{e1}}})-[:BELONGS_TO]-(dp:Department) RETURN dp.name), ] def run_cypher(driver, cypher, **params): with driver.session() as s: return [r.values()[0] for r in s.run(cypher, **params)] def answer(driver, question: str): entities max_match(question) if not entities: return 这句话里没找到我认识的症状或疾病换个问法试试。 e1 entities[0][1] for pattern, intent, cypher in TEMPLATES: if pattern.search(question): rows run_cypher(driver, cypher.format(e1e1)) if rows: return 、.join(dict.fromkeys(rows)) # 去重保序 break # 意图已确定查不到就走兜底 return fallback(driver, e1)逻辑说明先做实体识别拿到查询主体e1再匹配正则确定意图最后用意图对应的模板拼出 Cypher 执行。模板里的{{name:{e1}}}双大括号是 Pythonstr.format的转义这样外层格式化不会误伤 Cypher 自身的属性语法。参数说明三个模板的方向必须和 2.2 保持一致。symptom2disease 用的是-[:HAS_SYMPTOM]-说明查询方向是从症状反向找疾病对应关系方向 Disease → Symptom。如果当初关系建反了这里所有模板都得反着写。dict.fromkeys(rows)是保序去重的常用技巧比set()好在结果顺序稳定用户感知更友好。意图正则要点查询方向symptom2disease什么病、怎么回事症状 ← 疾病disease2drug吃什么药疾病 ← 药物disease2dept挂什么科疾病 → 科室4.3 兜底策略查不到时给「相关实体」而不是一句对不起空结果是问答系统最伤体验的地方。与其回「没找到」不如把目标实体的一跳邻居列出来用户至少能看出系统「理解了一半」。def fallback(driver, e1): # 拉一跳邻居按标签分组返回 cypher ( MATCH (n {name:$name})-[r]-(m) RETURN labels(m)[0] AS t, collect(DISTINCT m.name) AS names LIMIT 8 ) with driver.session() as s: rows s.run(cypher, namee1).data() if not rows: return f没有找到和「{e1}」相关的信息可能是语料里还没有收录。 parts [f{r[t]}{、.join(r[names])} for r in rows] return f「{e1}」的相关信息 .join(parts)逻辑说明兜底查询对实体先不求类型用(n {name:$name})不带标签地匹配拉出所有一跳邻居按邻居标签分组展示。这样即使用户问的实体类型猜错了也能给出有价值的上下文。参数说明这里用$name参数而不是字符串拼接一是防止 Cypher 注入二是避免实体名里的引号把查询搞挂。不带标签的MATCH在小数据量下可以接受等数据量上来以后要么在实体识别阶段就把类型带出来要么改成按类型分别查再合并结果。5. 避坑指南医疗问答知识图谱的五个典型翻车点以下五个坑来自我用某健康问答 Demo 反复迭代的过程每一条都是真实发生过的按「现象 → 原因 → 解决」写方便对照排查。5.1 坑一通用分词把「布洛芬」切成「布洛 / 芬」现象用通用中文分词器对问题分词后再去匹配抽出来的实体是「布洛」「芬」这种碎片查询永远命中不了。 原因通用分词器的词典以新闻、通用语料为主医疗专名基本不在里面长专名被切碎是必然的。 解决换用 4.1 的词典最大匹配绕过分词器如果坚持用分词器就用jieba.load_userdict把图里全部实体名灌进去并对常用药名调用add_word(布洛芬, freq100000)强制不拆分。5.2 坑二关系重复导入路径结果暴涨现象问「感冒吃什么药」返回三行一模一样的「布洛芬」用 Cypher 统计关系数量时数字明显虚高。 原因语料里多条记录指向同一个关系导入脚本用的是CREATE每执行一次就新插一条边图里产生平行边。 解决导入时把CREATE换成MERGE或者在建关系前先MATCH判断是否已存在。更省事的做法是查询统一RETURN DISTINCT但根因还是在导入阶段去重。判断一张关系表该不该去重可以直接查MATCH (a)-[r]-(b) RETURN a.name, type(r), b.name, count(*) HAVING count(*) 1看有没有重复边。5.3 坑三CSV 里看不见的 BOM 让 MATCH 永久落空现象LOAD CSV 导入成功节点数量也对但导关系时MATCH (a:Disease {id: row.start})匹配不到任何节点。 原因CSV 文件经过 Excel 另存带 UTF-8 BOM第一列的第一个值混进了不可见字符\ufeffid 对不上所以关系一条都建不起来。 解决Python 导出时用encodingutf-8-sig或者在 LOAD CSV 后用WITH row, trim(row.start) AS sid统一清洗。根治办法是坚持所有 CSV 都由脚本生成不经手工具软件。5.4 坑四无界路径查询把内存打满现象测试「查布洛芬相关的所有信息」写了MATCH (n {name:布洛芬})-[*]-(m)浏览器直接转圈容器内存被打满重启。 原因无界变长路径在连接度高的图里呈指数级膨胀。一个高热度症状节点能连出大量疾病每个疾病又连出药物和科室子图迅速爆炸。 解决所有变长路径都加深度上限*1..3起步。如果业务确实需要全深度遍历用apoc.path.expand配合关系类型白名单和maxLevel参数不要用裸的*。5.5 坑五实体不归一「感冒」和「上呼吸道感染」各建一个节点现象问「感冒挂什么科」有答案问「上呼吸道感染挂什么科」直接走兜底明明数据里都有。 原因语料来源不同同一疾病一个用俗称、一个用学名导入阶段没做同义词映射图里出现两个独立节点。 解决导入前建一张同义词表把「感冒 → 上呼吸道感染」这类映射统一到标准名再建节点或者在图里加一种ALIAS_OF关系查询时先解析别名。简易方案用前者别名少、维护成本低。判断是否需要归一可以把全部节点名称做一次编辑距离对比距离很近的极可能就是同义词。6. 进阶把问答从单跳升级为可解释的多跳推理链6.1 用变长路径回答「间接」问题单跳模板只能回答直连关系但真实问答里很多问题是多跳的。比如「头痛吃什么药」头痛是症状先要找对应疾病再找治疗药物中间隔了两跳。用变长路径一次写出来MATCH (s:Symptom {name:头痛})-[:HAS_SYMPTOM]-(d:Disease)-[:TREATS]-(dr:Drug) RETURN DISTINCT dr.name;这条查询把两跳压缩在一条路径里关系类型白名单限定了只能走 HAS_SYMPTOM 和 TREATS既灵活又不会像裸*那样爆炸。给路径加*1..3上限时务必把关系类型写进白名单这是控制查询成本的关键。6.2 用三个指标衡量问答质量跑通之后不能只看「感觉还行」要量化。我习惯维护 200 条真实问句作为评测集手工标好答案然后批量跑问答脚本看三个指标指标计算方式合格线P1正确答案出现在第一条返回的比例≥ 85%兜底率触发 fallback 的问题占比≤ 15%平均延迟从收到问题到返回答案的耗时≤ 200ms兜底率太高说明语料覆盖不足P1 偏低说明实体识别或模板匹配存在系统性问题延迟超标第一反应是查索引回到 3.3 看约束有没有建、查询有没有带标签。6.3 拿一个刁钻问题检验你的图搭建完成之后拿「吃了布洛芬能不能喝酒」去测你的图你会发现自己图谱在「禁忌关系」上是空白的。这不是 bug而是本体设计阶段的取舍。我自己吃过的教训是第一版恨不得把整个医学知识都塞进图里结果三个月没跑通后来砍到三类关系、四类节点两周上线再按真实问答记录逐个补关系反而越补越准。需求驱动的图谱才养得活先把核心链路跑通边界留给数据说话。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进