简介:面向知识图谱入门者、医疗问答系统开发者及课程设计人群,这份项目资料以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_len=10): """从长到短滑窗匹配,命中即返回,保证'高血压'不会被切成'血压'""" 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, rows=rows) return result.consume().counters.relationships_created def load_all(triples, batch_size=2000): 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_SYMPTOM |
| symptom_disease | 发热可能是哪些病 | HAS_SYMPTOM(反向) |
| disease_drug | 感冒吃什么药 | RECOMMAND_DRUG |
| disease_dept | 感冒挂什么科 | BELONGS_TO |
| disease_check | 感冒要做什么检查 | NEED_CHECK |
| disease_food | 感冒不能吃什么 | NO_EAT / DO_EAT |
| disease_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, encoding="utf-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, name=name)]参数化不只是防注入(虽然这也是理由之一),更重要的是让 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 展示一个疾病的完整邻居,让老师看到图长什么样;再打开问答界面连续问三个不同类型的问题,其中至少一个走两跳推理;最后拿出问题集表格说明命中率。顺序别反,先讲指标后看实物,说服力会打折。
答辩常被问的三个问题,提前准备答案:「数据从哪来」要能说出具体来源和清洗后的规模;「答错了怎么办」要能说清三层兜底;「和直接用大模型有什么区别」不要说大模型不好,说这套方案每条回答都能回溯到图上的具体路径,回答有据可查,这是图谱的定位。
从那以后我每次带这类项目,都会在开题第一周就把问题集表格建起来,后面每加一个功能就往里补一行。功能做完了表格也满了,验收时不用现编,直接跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取