
简介面向知识图谱入门者、医疗问答系统开发者及课程设计人群这份项目资料以ask120医疗问答数据为素材演示把问题、答案、疾病、症状和治疗方式等实体导入neo4j构建可检索的简易医疗知识图谱重点解决医疗信息关联查询与原型验证问题。资料包共37个文件以Python脚本为主辅以pyc缓存、XML配置、HTML页面和少量图片、前端脚本压缩后约78KB内容覆盖爬虫采集、Django项目骨架、数据库操作、路由视图、问答处理与资源目录等模块结构便于按功能查阅。已有5463人学习下载适合作为知识图谱课程作业、毕业设计或小型问答系统原型的参考。读者可从中了解医疗实体与关系的建模方式借鉴从数据采集、清洗整理到Cypher查询的实现思路并基于现有工程快速搭建可查询疾病症状、治疗方案的演示环境。1. 从为什么不直接用检索说起这套医疗问答图谱到底解决什么问题做医疗问答知识图谱毕设开题时八成会被追问问答对直接丢进表里、用关键词检索不就行了为什么要套一个 Neo4j。这个质疑听着像刁难其实恰好是这套方案的价值所在——用户问感冒了吃什么药不犯困关键词检索只能匹配到感冒两个字返回一堆含这个词的句子而图里存的是感冒—症状—发热、感冒—推荐用药—某类感冒药、某类感冒药—禁忌—嗜睡三条边沿着边走两跳就能拼出一句针对性回答。这套简易医疗问答知识图谱面向两类人一类是要交毕设或课设、需要在有限时间里跑通数据入库—图谱查询—自然语言问答完整闭环的学生另一类是手上有垂直领域问答需求、想先做个原型验证的开发者。它不追求通用医疗智能只把六类实体、七种关系做扎实让每一条回答都能在图上找到出处答辩时点开图数据库就能对质。2. 需求边界与图模型设计六类实体、七种关系的取舍图模型定错后面所有代码都是在给错误结构打补丁。这一章先把图上到底有什么钉死再谈为什么某些需求要主动砍掉。2.1 把问题域收窄六类实体与七种关系医疗领域的实体种类可以无限扩展从疾病、症状、药品、科室到检查项、并发症、饮食禁忌、病原体。一个学期或一个课程设计的周期里全都要等于全都做不深。我的做法是砍到六类实体每一类都必须在问答链路里被真实用到用不到的实体一律不进图。实体标签含义典型属性是否参与问答Disease疾病name、alias、cure_rate、desc是Symptom症状name、alias、body_part是Drug药品name、alias、category是Department科室name是Check检查项name是Food食物name、nature是仅用于饮食建议关系只保留七种HAS_SYMPTOM疾病—症状、RECOMMAND_DRUG疾病—药品、BELONGS_TO疾病—科室、NEED_CHECK疾病—检查、NO_EAT疾病—忌口食物、DO_EAT疾病—宜吃食物、ACOMPANY疾病—并发症。每一条关系都是从一个真实问句倒推出来的用户会问X 病有什么症状X 病挂什么科X 病要做什么检查X 病不能吃什么X 病会引起什么病七种关系对应七个高频意图多一条都是冗余。提示属性字段宁少勿多。cure_rate这类数值属性如果没有可靠来源宁可换成文本描述字段答辩时被问这个 78% 怎么来的很难圆场。2.2 为什么是图数据库三类必须走多跳的查询证明选型合理不需要长篇大论只要能举出三个用 SQL 写起来很别扭、用 Cypher 一行就能出的查询。第一类是反向查询。哪些疾病会有发热这个症状在关系型库里是两次 join 再加一次去重在图上就是MATCH (d:Disease)-[:HAS_SYMPTOM]-(:Symptom {name:发热}) RETURN d.name。第二类是两跳推理。感冒药里哪些吃了会犯困路径是Disease - Drug - 药品属性/禁忌图上按关系类型顺着走即可SQL 里要预先设计好中间表一旦需求变了就得改表结构。第三类是路径解释。答辩时最有说服力的一句话是这条回答的来源是这条路径图数据库天然返回路径对象可以把d.name - s.name的边原样展示出来关系型库只能返回结果集解释链是断的。也正因为这三类查询占了问答请求的大头图模型不需要做成万能本体只要保证每种意图都有一条对应的路径模式选型就站得住。2.3 约束与索引入库前必须先跑的那几条 Cypher很多人把建约束当成可选项结果导入脚本跑了一夜还没结束。原因是 Neo4j 的MERGE在无索引的情况下需要扫描全部同标签节点来判断是否已存在数据量一上去就是平方级开销。所以约束必须在第一批数据写入之前建好。// Neo4j 5.x 的写法标签 属性唯一约束 CREATE CONSTRAINT disease_name_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT symptom_name_unique IF NOT EXISTS FOR (s:Symptom) REQUIRE s.name IS UNIQUE; // 给高频查询字段建普通索引避免 MATCH 时全表扫描 CREATE INDEX disease_alias IF NOT EXISTS FOR (d:Disease) ON (d.alias);4.x 版本的语法是ON (d:Disease) ASSERT d.name IS UNIQUE两种写法不能混用装完库先执行call dbms.components()确认版本再决定写哪一套。别小看这一行差异环境里装的是 5.x 却照抄 4.x 语法报错信息会让新手卡半天。还有一条容易被忽略的属性唯一约束只保证同名节点唯一不保证同义节点唯一。高血压和原发性高血压是两个不同的name约束拦不住这个问题留到第 5 章展开。3. 图谱构建流水线从原始语料到一个能查的 Neo4j 库这一章是整套方案里工作量最大的部分。问答链路再多技巧图上只有两三百个孤立节点也问不出东西。流水线拆成四步清洗、抽取、入库、校验每一步都有可复现的做法。3.1 数据来源与清洗三类语料怎么变成三元组适合毕设规模的数据来源通常是三类公开的医疗问答对文本、结构化的疾病百科条目、手工整理的少量对照表。第一类提供问句表达习惯第二类提供实体和关系第三类补齐别名。清洗的核心目标只有一个把不同来源的表达统一成(头实体, 关系, 尾实体)的三元组 CSV。中间格式定成三列加一列来源方便入库后溯源。head,relation,tail,source 感冒,has_symptom,发热,qa_corpus 感冒,has_symptom,咳嗽,qa_corpus 感冒,recommand_drug,某类复方感冒药,encyclopedia 感冒,belongs_to,呼吸内科,encyclopedia清洗规则不用写得很复杂但下面几条必须落地否则脏数据会一路带进图里去掉实体前后的空白和全角标点把忌食不能吃避免食用统一映射成no_eat把尾实体里带括号的补充说明截掉只保留主名称对长度超过 20 字的头实体做人工复核这类多半是整句话被误当成实体。清洗脚本建议单独一个文件输入原始目录、输出triples.csv和一个rejected.log。被丢掉的记录一定要写日志答辩时被问数据量怎么比原始语料少这么多打开日志就能解释清楚是重复还是格式错误。3.2 实体抽取的轻量做法词典 规则 人工校验这个体量的项目上深度学习做命名实体识别投入产出比很低训练数据还得自己标。更稳的做法是词典加规则。词典来自两部分已有的疾病、症状、药品名录以及从问答对里高频出现的名词。分词工具用来切词但绝对不能直接拿分词结果当实体——高血压被切成高和血压是这类项目最经典的翻车场景一旦入库用户问高血压永远匹配不上。所以分词只用来召回候选最终边界由词典最长匹配决定。def match_entity(text, vocab, max_len10): 从长到短滑窗匹配命中即返回保证高血压不会被切成血压 for length in range(min(max_len, len(text)), 1, -1): for start in range(len(text) - length 1): span text[start:start length] if span in vocab: return span return None这段逻辑在抽取阶段和问答阶段是复用的抽取时用它从整句里抠实体问答时用它从用户问句里抠实体同一套词典保证两边口径一致。参数max_len按最长实体名设定医学实体名一般不超过 10 个字超过这个长度的基本是句子而不是实体。关系抽取用规则模板比如X 的症状有 A、B、C这类句式用正则捕获 X 和后面的并列项再逐条生成三元组。规则覆盖率不会很高能到六七成就算合格剩下的靠人工补几十条高频关系比调模型快得多。3.3 批量入库脚本UNWIND MERGE 的写法与参数入库脚本用官方 Python 驱动核心是一次事务批量写而不是一条一条写。逐条run()的写法在几千条数据上还能忍上万条就是灾难。from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, your_password)) # 关系类型和标签不能用查询参数占位只能拼进语句 # 因此必须走白名单映射绝不能拿外部输入直接拼 REL_MAP { has_symptom: (Disease, HAS_SYMPTOM, Symptom), recommand_drug:(Disease, RECOMMAND_DRUG,Drug), belongs_to: (Disease, BELONGS_TO, Department), need_check: (Disease, NEED_CHECK, Check), no_eat: (Disease, NO_EAT, Food), do_eat: (Disease, DO_EAT, Food), acompany: (Disease, ACOMPANY, Disease), } def bulk_merge(tx, head_label, rel_type, tail_label, rows): cypher f UNWIND $rows AS row MERGE (h:{head_label} {{name: row.head}}) MERGE (t:{tail_label} {{name: row.tail}}) MERGE (h)-[:{rel_type}]-(t) result tx.run(cypher, rowsrows) return result.consume().counters.relationships_created def load_all(triples, batch_size2000): with driver.session() as session: for rel, (hl, rt, tl) in REL_MAP.items(): subset [t for t in triples if t[relation] rel] for i in range(0, len(subset), batch_size): batch subset[i:i batch_size] session.execute_write(bulk_merge, hl, rt, tl, batch)几个参数值得解释。batch_size默认给到 2000太大事务内存吃紧太小事务开销占比高2000 到 5000 之间都可以试。execute_write是驱动的托管事务写法内部带重试比手写session.run稳。MERGE在这里承担了去重职责同名头实体只会建一次同一条边重复导入也只会建一次。但这依赖第 2 章建的唯一约束没建约束的MERGE会慢到让人怀疑人生。另外UNWIND $rows传的是列表每个元素是一个字典字段名必须和 Cypher 里的row.xxx完全对齐写错一个字母不会报字段不存在而是静默地把null传进去最后图上是空的——这类问题查起来非常费劲建议入库后立刻跑一遍计数校验。注意MERGE (h)-[:REL]-(t)这种写法不会更新已有边的属性。如果后续要补weight、source之类的字段需要改成MERGE ... ON CREATE SET ...否则重复导入时属性永远是第一次写入的值。3.4 入库后的完整性校验计数、孤立节点与抽样走查导入完成不等于导入正确。三步校验每次重新导入后都跑一遍。第一步计数对齐MATCH (n) RETURN labels(n) AS label, count(*)得到的各标签数量必须和清洗后 CSV 里的去重实体数一致差得多说明标签映射写错了。边数同理MATCH ()-[r]-() RETURN type(r), count(r)逐类核对。第二步找孤立节点MATCH (n) WHERE NOT (n)--() RETURN n.name LIMIT 50。孤立节点意味着这个实体在清洗阶段被抽出来了但没有任何关系连上它问答链路里它永远查不到东西属于纯占位垃圾要么补边要么删掉。第三步抽样走查随机抽 20 个疾病手工在 Neo4j Browser 里点开看它的邻居是否合理。这一步没有技术含量但能发现规则抽取里最典型的错位问题比如把感冒的症状错连到了咳嗽上形成(感冒)-[:HAS_SYMPTOM]-(咳嗽)这种症状连症状的边。抽样走查花二十分钟能省掉答辩现场被老师随手一点就点出错误的尴尬。4. 问答链路实现意图识别、槽位归一与 Cypher 模板图建好只是有了答案仓库问答链路负责把一句人话翻译成一次图查询。这个体量不需要上大模型意图加槽位加模板的组合足够撑起演示效果而且每一步都可控、可解释。4.1 意图体系设计把自由问句收敛到七类模板用户的问法千变万化但落到这套图模型上能回答的意图只有七类正好对应七种关系。设计意图体系的原则是意图数量等于图上能回答的问题类型数量多出来的意图都是答不出来的。意图标识典型问法目标关系disease_symptom感冒有什么症状HAS_SYMPTOMsymptom_disease发热可能是哪些病HAS_SYMPTOM反向disease_drug感冒吃什么药RECOMMAND_DRUGdisease_dept感冒挂什么科BELONGS_TOdisease_check感冒要做什么检查NEED_CHECKdisease_food感冒不能吃什么NO_EAT / DO_EATdisease_acompany感冒会引起什么并发症ACOMPANY识别用关键词规则表按优先级从高到低匹配INTENT_RULES [ (disease_food, [不能吃, 忌口, 吃什么好, 宜吃]), (disease_check, [做什么检查, 检查项目, 查什么]), (disease_dept, [挂什么科, 哪个科, 科室]), (disease_drug, [吃什么药, 用什么药, 药]), (disease_acompany,[并发症, 会引起]), (disease_symptom, [有什么症状, 症状有哪些, 表现]), (symptom_disease, [可能是什么病, 哪些病, 怀疑]), ] def detect_intent(text): for intent, keywords in INTENT_RULES: if any(kw in text for kw in keywords): return intent return None顺序有讲究不能吃什么里含吃字如果disease_drug规则排在前面就会被误判成药。原则是把约束更强、更具体的问法排前面宽泛的排后面。detect_intent返回None时不要硬猜直接走兜底话术这一点在第 4.4 节展开。4.2 槽位填充与实体归一别名表怎么建意图确定后要回答问的是哪个病。这一步的难点不在抽取在归一——用户说的是高血压图里存的是原发性高血压字符串不相等查出来就是空。别名表用最简单的两列结构标准名和别名一行一个别名允许一个标准名对应多行。构建方式有三种从实体属性的alias字段拆出来、从问答对里人工积累、把包含关系明显的名称做规则合并XX病和XX指同一实体时合并。def load_alias_map(path): alias.csv: alias,standard 两列返回 别名-标准名 的字典 alias_map {} with open(path, encodingutf-8) as f: for line in f: alias, standard line.strip().split(,) alias_map[alias] standard return alias_map def fill_slot(text, vocab, alias_map): mention match_entity(text, vocab) # 复用 3.2 节的最长匹配 if mention is None: return None, None return mention, alias_map.get(mention, mention)返回两个值一个是用户原话里的提法一个是归一后的标准名。前者用于组织回答关于高血压……更自然后者用于查询。这个区分看着小实际很影响体验全部用标准名回答用户会觉得自己问的和答的不是一回事。词汇表vocab要把标准名和所有别名都装进去否则别名根本匹配不上。这一步做漏了表现就是问高血压没反应问原发性高血压有答案属于第 5 章要重点排查的问题。4.3 Cypher 模板与参数化查询意图和槽位齐了最后一步是把它们塞进预定义的 Cypher 模板。模板集中放在一个字典里一条意图对应一条查询参数一律走$name占位。CYPHER_TEMPLATES { disease_symptom: MATCH (d:Disease {name: $name})-[:HAS_SYMPTOM]-(s:Symptom) RETURN s.name AS answer LIMIT 20 , symptom_disease: MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom {name: $name}) RETURN d.name AS answer, d.cure_rate AS score ORDER BY score DESC LIMIT 10 , disease_drug: MATCH (d:Disease {name: $name})-[:RECOMMAND_DRUG]-(m:Drug) RETURN m.name AS answer LIMIT 15 , disease_food: MATCH (d:Disease {name: $name})-[r:NO_EAT|DO_EAT]-(f:Food) RETURN f.name AS answer, type(r) AS kind LIMIT 30 , } def query(intent, name): cypher CYPHER_TEMPLATES.get(intent) if cypher is None: return [] with driver.session() as session: return [dict(r) for r in session.run(cypher, namename)]参数化不只是防注入虽然这也是理由之一更重要的是让 Neo4j 能复用执行计划。用户输入里带引号、带空格、带问号都是常事字符串拼接的写法遇到这些字符不是报语法错误就是被截断参数化把这类问题一次性消掉。LIMIT一定要写。症状这类实体的连接度可能很高不加限制时发热可能是哪些病能返回几百条页面上根本没法看。给一个 10 到 20 的硬上限回答里补一句以上为常见情况比堆一屏更有用。4.4 答案组织与兜底策略查询结果是一堆字符串直接抛给用户很生硬。组织成自然语言的规则很简单按意图套话术模板把结果列表拼进去个数超过五条就截断并提示还有更多。disease_food这种带关系类型的还要按NO_EAT和DO_EAT分成两组否则能吃和不能吃混在一起是要出事的。兜底分三层缺一层体验就断。第一层是实体没识别到回没听清您说的是哪种疾病或症状可以换个说法试试第二层是意图没命中回这个问题暂时答不了目前支持问症状、用药、科室、检查、饮食和并发症第三层是意图和实体都有但查询结果为空回图谱里暂时没有收录相关条目。三层话术不同排查问题时看返回哪句话就知道断在哪一环这就是自己给自己留的黑匣子。回答里最好附一句出处比如以上关系来自图谱中 XX 的直接关联答辩时老师追问你怎么保证准确性指向图数据库里那条边就行。5. 常见问题排查五个最容易翻车的点这一章列的五条基本覆盖了这类项目从导入到演示会踩的绝大多数坑。每条按现象、原因、解决三步写遇到问题时对着现象找就行。5.1 数据层的三个翻车点现象一导入脚本跑了一整夜还没结束CPU 打满但边数几乎不涨。原因是没建唯一约束就用了MERGE。无索引时 Neo4j 要为每次MERGE扫描同标签下的全部节点判断是否已存在几千条数据就能退化成平方级。解决办法是先执行第 2.3 节的约束语句SHOW CONSTRAINTS确认生效后再导入。血泪经验是约束和导入脚本写在同一个 README 里顺序不能反。现象二问高血压没结果问原发性高血压有结果。原因是别名没归一图上同时存在高血压和原发性高血压两个独立节点各自连了一半的边。查MATCH (d:Disease) RETURN d.name一眼就能看出来。解决办法是入库前先把别名表跑一遍把别名映射成标准名再MERGE已经导错的库要重建不要试图在图上打补丁补到后面边会越连越乱。现象三症状查询返回几千条页面直接卡死。原因是路径没限定关系类型和深度。写成MATCH (d:Disease)-[*]-(s:Symptom {name:$name})这种不定长路径等于允许任意跳数图上稍微密一点就爆炸。解决办法是明确写出关系类型-[:HAS_SYMPTOM]-配合LIMIT和必要的ORDER BY排序把结果收窄到可读范围。5.2 问答层的两个翻车点现象四用户输入里带个英文引号或者问号接口直接 500。原因是 Cypher 用字符串拼接生成。报错信息通常是语法错误位置还指向莫名其妙的地方。解决办法是全部改成$param参数化模板里不出现任何用户输入。检查方法很土但有效拿、、\、?各测一遍四个都过基本就没问题。现象五问感冒了吃什么返回空但换个说法感冒吃什么药就有答案。原因是意图规则表覆盖不全吃什么这种省略了药字的问法没命中disease_drug落到disease_food又因为没匹配到忌口关键词而返回空。解决办法是补充同义表达把吃什么吃啥用点什么都归到用药意图同时在兜底话术里提示支持哪些问法。规则问答的短板就在这里靠的是持续积累问法不是一次写全。提示把这五条做成一张排查清单贴在 README 首页。演示前一小时按清单过一遍比现场发现问题再翻代码强太多。6. 验收与答辩用一套问题集证明图谱真的能用毕设答辩最怕的不是被问难题是拿不出证据证明东西真的能跑。图谱类项目尤其如此老师不可能现场翻你的代码只能看你演示、听你讲。我一般会准备一套固定问题集二十到三十条覆盖七种意图每条标注预期答案和实际答案跑完统计命中率。编号问句意图预期答案要点结果1感冒有什么症状disease_symptom发热、咳嗽、咽痛命中2发热可能是哪些病symptom_disease感冒、流感等命中3感冒挂什么科disease_dept呼吸内科命中4感冒不能吃什么disease_food分忌口与宜吃两组命中5感冒了吃什么disease_drug走兜底提示补充问法兜底这张表的价值在最后一行。答不出来的问题不要藏主动标成兜底然后解释清楚是意图覆盖不足还是图谱没收录老师反而会觉得你对系统边界有清醒认识。全对的表格没人信有一条兜底且解释得通可信度高得多。演示流程控制在五分钟内先用 Neo4j Browser 展示一个疾病的完整邻居让老师看到图长什么样再打开问答界面连续问三个不同类型的问题其中至少一个走两跳推理最后拿出问题集表格说明命中率。顺序别反先讲指标后看实物说服力会打折。答辩常被问的三个问题提前准备答案「数据从哪来」要能说出具体来源和清洗后的规模「答错了怎么办」要能说清三层兜底「和直接用大模型有什么区别」不要说大模型不好说这套方案每条回答都能回溯到图上的具体路径回答有据可查这是图谱的定位。从那以后我每次带这类项目都会在开题第一周就把问题集表格建起来后面每加一个功能就往里补一行。功能做完了表格也满了验收时不用现编直接跑一遍。希望帮到你。本文还有配套的精品资源点击获取