☰
基于Neo4j的医药知识图谱问答系统:从本体设计到Cypher查询落地
2026/10/11 7:24:36 网站建设 项目流程

简介:这是一份以疾病为中心的医药领域知识图谱问答系统设计源码,采用Python构建,覆盖知识图谱搭建、问题意图分类、自然语言解析、答案检索等完整环节,适合对知识图谱与自动问答感兴趣的开发者、医药信息化研究人员参考学习。资源共31个文件,包含8个Python源码、3个编译文件、9张PNG图片、9个文本文件,以及JSON、PPT等,压缩包大小49.19MB。Python源码实现了图谱构建、问题分类、答案检索等核心功能,文本与JSON数据则提供实体、关系及问答语料,PNG用于展示图谱可视化与系统流程;项目包含从数据爬取、知识抽取到问答匹配的完整链路,模块划分清晰,便于二次开发。目前已有307人学习下载,通过源码可掌握从数据准备、图谱构建到问答交互的完整实现思路,尤其适合作为医药领域智能问答项目的设计模板与二次开发基础。

1. 知识图谱问答系统源码:以疾病为中心的医药项目到底怎么落地

把「胃疼应该挂什么科」这样的问题丢给一个程序,它能直接告诉你「消化内科」,而不是甩给你一屏广告。这就是这份源码在做的事:以疾病为中心,把医药领域的实体和关系组织成知识图谱,再用 Python 实现完整的自动问答链路。我拆这份源码时最大的感受是,它的价值不在算法多深,而在「疾病—症状—药品—科室」这套本体设计足够扎实,问答模块的规则与模板也很工程化。适合正在做知识图谱课程设计、想入门医药 NLP 信息检索、或者需要一份能跑通全流程的问答源码的从业者。下面按构建到问答的顺序,把每个环节的选型理由和可复现细节拆开讲。

2. 图谱构建技术选型:实体识别、关系抽取的工程取舍

2.1 本体先于代码:六大实体和五类关系怎么定

任何知识图谱项目,第一步都不是写爬虫或调模型,而是定本体。医药领域实体嵌套多、指称杂,如果一开始不把 schema 固定下来,后面实体识别、关系抽取、Cypher 查询全都会乱。这份源码的本体设计很明确:六个实体类型,五类关系。

实体类型示例关系类型示例
Disease(疾病)胃炎、高血压、糖尿病HAS_SYMPTOM(疾病→症状)胃炎→恶心
Symptom(症状)恶心、头晕、多尿TREAT_BY(疾病→药品)高血压→氨氯地平
Drug(药品)阿司匹林、氨氯地平CHECKS(检查→疾病)胃镜→胃炎
Department(科室)消化内科、心内科IN_DEPARTMENT(疾病→科室)糖尿病→内分泌科
Check(检查项目)胃镜、血糖检测CONTRAINDICATED(药品→疾病/人群)阿司匹林→胃溃疡
Food(食物)高糖食物、辛辣食物

为什么要先定表?因为关系抽取阶段,你要告诉模型「哪些关系是有意义的」。比如「患者因为胃炎去医院挂了消化内科」,这里能抽取的关系至少有三条:患者-患-胃炎、胃炎-IN_DEPARTMENT-消化内科。如果不限定关系类型,模型会把「患者—去—医院」这种无效三元组也抽出来,图谱越建越脏。源码的做法是先人工维护一份关系白名单,再去做抽取,而不是让模型自由发挥。

2.2 实体识别:词典兜底、CRF 补召回的实现方式

实体识别在这个项目里走了双路:词典匹配负责精确命中,CRF 负责召回词典外的新词。医药实体有个特点——别名和嵌套特别多,「胃痛」和「胃疼」是同一个症状,「上腹疼痛」也是它。单靠词典,永远收不全;单靠标注训练,又没有那么多标注数据。所以源码里的 NER 模块是两路取并集。

我摘一段源码里的 CRF 特征模板,这段是实体识别能跑出效果的关键:

def word2features(sent, i): word = sent[i] features = { 'word': word, 'word.lower': word.lower(), 'is_first': i == 0, 'is_last': i == len(sent) - 1, 'prefix1': word[:1], 'suffix2': word[-2:] if len(word) >= 2 else word, 'prev_word': sent[i-1] if i > 0 else 'BOS', 'next_word': sent[i+1] if i < len(sent) - 1 else 'EOS', 'is_digit': word.isdigit(), 'is_alnum': word.isalnum(), } if i >= 2: features['prev2_word'] = sent[i-2] if i <= len(sent) - 3: features['next2_word'] = sent[i+2] return features

这段特征模板的核心逻辑是:把当前词、前后词、前后缀、是否数字都转成特征向量,交给 CRF 去学习「什么上下文的词是疾病名、什么上下文的词是症状名」。suffix2 对医药分词特别有用,因为「炎」「瘤」「痛」这类后缀高度集中在疾病和症状实体上。is_digit用来捕捉「2型糖尿病」「高血压3级」这类带数字的实体。我一般会在原基础上加一个pos词性特征,但源码里已经跑通了,就不必额外引入。

词典匹配那块没什么玄学,就是拿维护好的实体别名表对句子做最大匹配。注意一个顺序问题:先跑长词、再跑短词。否则「高血压患者」会被「血压」先匹配走,导致漏掉「高血压」。这一步建议按实体名长度降序排序后再匹配。

2.3 关系抽取:句式模板加依存句法,否定词必须单独处理

关系抽取源码里没有用复杂模型,而是「句式模板 + 依存句法」的组合。句式模板负责高频模式,比如「X 可用于治疗 Y」「X 的典型症状是 Y」「得了 X 应该挂 Y 科」。每命中一个模式,就生成一条对应类型的三元组。

但模板覆盖不了所有说法,所以源码又用依存句法做了一层兜底:先分词、依存分析,找到句子中的核心动词(如「治疗」「引起」「导致」),再看动词的宾语是否命中疾病或药品实体,从而推断关系。这层兜底可能产生噪音,所以它后面挂了一个过滤条件:关系类型必须在 2.1 的白名单里。

这里有个特别容易翻车的点——否定词。比如「胃溃疡患者禁用阿司匹林」,模板「X 可用 Y」如果没做否定检测,会把「胃溃疡—TREAT_BY—阿司匹林」抽进去,这跟事实完全相反。源码的做法是在模板匹配前先查句子中是否出现「禁用、忌用、不宜、不能」等否定词,一旦命中就转到 CONTRAINDICATED 关系。这步顺序不能颠倒,先做否定检测再做正向关系匹配,否则正向模板已经把三元组生成了,后面再想补救就麻烦。

3. Neo4j 存储与查询:从三元组到 Cypher 模板的落地细节

3.1 为什么选 Neo4j 而不是 MySQL

医药问答最典型的查询是「高血压可能引发哪些并发症」「这种药跟什么药不能一起吃」,这类问题天然是图上的多跳查询。用 MySQL 存三元组也能查,但 JOIN 层级一多,查询效率和写 SQL 的复杂度都会失控。工业知识图谱也普遍是这个问题:设备—故障—维修链路用关系型表存,两层以上 JOIN 就很痛苦。Neo4j 的 Cypher 用MATCH沿着关系走,一趟就能把三跳路径全取回来。

这份源码的图模型也考虑了查询效率:疾病和症状节点都建了唯一约束,关系上建了索引,所以症状反查疾病、疾病查科室这类高频查询基本是毫秒级。如果只是把三元组倒进 MySQL,这个问答系统的核心体验会差很多。

3.2 批量导入脚本:py2neo 写库的正确姿势

源码里给了一个完整的 py2neo 批量导入脚本,核心思路是先建约束、再分批写入。我把它简化成下面这个结构:

from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) graph.run("CREATE CONSTRAINT disease_uniq IF NOT EXISTS " "FOR (d:Disease) REQUIRE d.name IS UNIQUE") graph.run("CREATE CONSTRAINT drug_uniq IF NOT EXISTS " "FOR (d:Drug) REQUIRE d.name IS UNIQUE") batch_size = 500 batch = [] for subj, rel_type, obj, subj_type, obj_type in triplets: s_node = Node(subj_type, name=subj) o_node = Node(obj_type, name=obj) batch.append(Relationship(s_node, rel_type, o_node)) if len(batch) >= batch_size: graph.create(batch) batch.clear() if batch: graph.create(batch)

两个工程细节值得注意。第一,batch_size设在 500 而不是一次性全量写入,是因为 py2neo 在单次事务里塞太多关系时,Neo4j 会频繁做堆内存扩展,图一大就容易卡死。第二,CREATE CONSTRAINT必须在写入之前执行,否则重复导入时会出现重复节点,后续查询按 name 匹配会同时命中多个同名字节点,结果集直接翻倍。

参数化写法也建议照做:graph.run("CREATE CONSTRAINT ...", ...)这种不带文字的硬编码在项目里问题不大,但如果三元组数据里有特殊字符,单引号会破坏 Cypher 语句。安全起见,节点写入用Node(subj_type, name=subj)而不是拼字符串。

3.3 Cypher 查询模板:三类高频查询直接抄

图谱建完以后,问答系统的后半段全靠 Cypher 模板撑着。源码里封装了一批命名查询,建图完成后可以直接调用。我挑三个覆盖最高频诉求的模板:

// 症状反查疾病 MATCH (s:Symptom {name: $symptom})<-[:HAS_SYMPTOM]-(d:Disease) RETURN d.name AS disease LIMIT 10; // 疾病查科室与常用药 MATCH (d:Disease {name: $disease}) OPTIONAL MATCH (d)-[:IN_DEPARTMENT]->(dep:Department) OPTIONAL MATCH (d)-[:TREAT_BY]->(drug:Drug) RETURN dep.name AS department, collect(drug.name)[..5] AS drugs LIMIT 10; // 药品禁忌反查 MATCH (d:Disease {name: $disease})-[:CONTRAINDICATED]-(drug:Drug) RETURN drug.name AS forbidden_drug LIMIT 10;

第一类查询用于「头痛挂什么科、恶心是什么病」;第二类用于「胃炎怎么治、挂哪个科室」;第三类用于药物安全性反查。这些模板的共性是用$name参数绑定,而不是拼进 Cypher 字符串——依赖注入的风险虽然低,但参数化能顺带解决中文引号兼容问题,源码里这个习惯保持得不错。长期的问答中使用LIMIT约束返回条数,也是为了避免「糖尿病」这种宽泛实体一下子返回上百条结果。

4. Python 自动问答主流程:问句分类、槽位填充与答案排序

4.1 问句分类:意图识别决定走哪条查询链路

问答系统的第一步不是抽取实体,而是判断「这句问话到底在问什么」。源码里的意图识别并没有上深度学习模型,而是用关键词模式匹配做分诊。因为它服务的问句类型是有限的:查症状、查用药、查科室、查禁忌、查检查项目。在有限的意图集合里,规则匹配的稳定性和可解释性都优于一个小模型。

看这段核心代码:

def detect_intent(question): q = question.strip() rules = [ ("disease_symptom", ["什么症状", "有哪些表现", "典型症状"]), ("disease_drug", ["吃什么药", "用什么药", "如何治疗", "怎么治"]), ("disease_department", ["挂什么科", "哪个科室", "去哪科"]), ("drug_contraindication", ["禁忌", "不能用", "哪些人不能吃"]), ("check_item", ["做什么检查", "怎么确诊", "检查什么"]), ] for intent, patterns in rules: if any(p in q for p in patterns): return intent return "unknown"

这段逻辑虽然简单,但有个必须处理的冲突:问句「胃病吃什么药好」同时包含「什么药」和「怎么治」,命中disease_drug没问题;可「高血压不能吃什么药」同时命中了disease_drug和drug_contraindication,如果按规则顺序走,会被错误分到disease_drug。所以源码把drug_contraindication的优先级提到最前面,用否定词 + 药名组合优先拦截禁忌类问题。我建议你直接照这个优先级顺序调整,省得问答阶段答案反了。

4.2 槽位提取与别名归一:把「胃疼」映射到「胃痛」

意图定好之后,就要从问句里抠出查询需要的槽位。这一步挑大梁的是别名归一表。医药里的指称差异很夸张:「胃疼」「胃痛」「上腹疼痛」「胃脘痛」说的多半是同一件事,但图谱里节点只保留一个标准名。不归一,Cypher 的WHERE name = '胃疼'就永远匹配不到节点。

源码中的做法是用实体字典做最大匹配,命中别名后统一转成标准名:

def normalize_entity(raw_text, entity_dict): for std_name, aliases in entity_dict.items(): for alias in aliases: if alias in raw_text: return std_name return raw_text

这里有一个细节:entity_dict的构建顺序很关键,别名表里必须把长词放在前面。比如「上腹疼痛」和「腹痛」同时出现在一个句子中时,如果不先匹配长词,「上腹疼痛」会被「腹痛」截胡,导致槽位提取错误。源码里对实体字典做过一次按别名长度的降序排序,这个习惯建议保留。

槽位提取完成后,会得到一个结构化的中间态,比如{"intent": "disease_symptom", "disease": "胃炎"}。后续所有 Cypher 生成、答案格式化都基于这个中间态,而不是直接在原始问句上操作。这样做的收益是,实体缺失时你能明确知道「是意图错了还是实体提错了」,排错成本大幅下降。

4.3 模板生成 Cypher 与答案排序:为什么需要排序逻辑

槽位填好后,源码会调用一个build_cypher(intent, entities)函数,根据不同意图拼出 3.3 节中对应的查询语句。但这只是前半段。图查询返回的结果往往是多条的——「胃炎的典型症状是什么」可能返回八条症状,「糖尿病患者不能吃什么」可能返回五种需要避开的食物。如果不排序,答案列表就是随机的。

源码的排序逻辑并不复杂:先按关系类型本身的可信度排序,再按属性完整度排序。比如HAS_SYMPTOM关系上如果带有frequency或severity属性,频率高的排在前面;TREAT_BY关系上如果带first_line标记,一线药物排在前面。没有属性时,就用图中关系的出现次数兜底。实际效果是:用户拿到的前三条回答通常都是主流的症状或常用药,而不是冷门边缘结果。这也符合用户对医疗信息检索「先看到最重要信息」的预期。

5. 避坑与排查:医药问答系统最容易翻车的五个地方

5.1 同义词未归一,图谱里有节点但问答查不到

现象:用户问「胃疼该挂什么科」,系统返回「未找到相关疾病」,但图谱里明明有「胃痛」节点。原因:问句里的「胃疼」没有通过别名表映射到标准名「胃痛」,Cypher 按name = '胃疼'匹配时直接落空。解决:在槽位提取阶段强制走一遍normalize_entity,并且把「疼/痛」「呕吐/恶心」这类高频近义词全部收进别名表。这个问题排查起来最花时间,因为它不是报错,而是静默失败。

5.2 否定句被当成正向意图

现象:用户问「高血压患者不能吃什么药」,系统返回了一堆降压药的用药建议。原因:意图分类时disease_drug的规则先匹配了「什么药」,而drug_contraindication的否定词检测放在后面,还没来得及跑就返回了。解决:把禁忌类意图的规则在detect_intent中放在最前面,同时增加一个前置检测:句子中出现「不、别、忌、禁」等否定前缀时,优先往禁忌链路走。这是我拆这份源码时踩得最深的坑。

5.3 一问多答没有排序,答案质量不稳定

现象:问「糖尿病的症状有哪些」,返回的列表里既有「多饮多尿」这种典型症状,也有「皮肤瘙痒」这种边缘症状,顺序时好时坏。原因:图查询返回的结果没有排序逻辑,直接按 Neo4j 内部存储顺序输出。解决:给关系增加频率属性,或者在答案生成时按关系在图中出现的次数降序排列。这类排序问题不会让系统报错,但会直接影响用户对「这个问答系统靠不靠谱」的判断。

5.4 主语省略导致查询落空

现象:用户先问「糖尿病能治吗」,再问「平时饮食注意什么」,第二个问题里没有疾病实体,系统无从下手。原因:问答系统没有维护对话上下文状态,实体抽取直接返回空。解决:最简单的方式是引入一个session_entity变量,当槽位为空时,回退到上一轮命中的疾病实体。注意这里不要做复杂的对话管理,先回退实体,已经能覆盖一半以上的省略场景。

5.5 数据更新后,图谱和别名表不同步

现象:图谱里新增了一批药品节点,但问答里查「泰诺」还是返回旧结果。原因:新增药品时只导入了图数据,没有同步更新实体别名表,NER 阶段根本没有匹配到「泰诺」这个词。解决:把图谱导入脚本和别名表维护脚本放在同一个流程里,每次跑数据更新时强制重新生成别名表。我那段时间反复在这个问题上滚,后来干脆写了个数据一致性检查脚本,更新完自动跑一遍「随机抽 50 个别名查询」,有失败就中断发布。

6. 验证与进阶:用评测集跑分,再往重排序走一步

源码跑通之后,最容易被忽略的是验证环节。只看几条测试问句通过,不代表系统真实可用。我拿到这份源码后做的第一件事,是搭一个模拟问句评测集,把精度量化出来。评测集不需要大,50 条足够发现主要问题,但覆盖面要全:每个意图至少 8 条,其中要混入否定句、别名句、省略主语句各若干。评测脚本的核心就是比对意图和实体是否都正确:

test_cases = [ {"q": "胃炎有什么典型症状", "intent": "disease_symptom", "entity": "胃炎"}, {"q": "胃疼应该挂什么科", "intent": "disease_department", "entity": "胃痛"}, {"q": "高血压不能吃什么药", "intent": "drug_contraindication", "entity": "高血压"}, ] correct = 0 for case in test_cases: intent = detect_intent(case["q"]) entities = extract_entities(case["q"], alias_dict) if intent == case["intent"] and case["entity"] in entities.values(): correct += 1 print(f"意图+实体准确率: {correct / len(test_cases):.2%}")

跑完之后我发现准确率只有 70% 上下,主要扣分点都在否定句和别名上。把 5.2 和 5.1 两个问题修掉后,准确率能到 90% 以上。验证的价值就在这儿——它把「我觉得差不多能用了」变成了「我知道哪 15% 是不行的」。

进阶的方向我很推荐走重排序,而不是急着换深度学习模型。当前源码的答案排序是基于关系频率的硬规则,可以再接一层轻量重排:把图查询召回的候选结果转成特征向量(实体类型、关系权重、属性完整度),用一个 XGBoost 或简单逻辑回归排序。这样意图识别和槽位提取保持原样,只动答案排序,改动范围小、回退风险低。从那以后我每次拿到问答项目源码,都强制先跑一遍评测集再改代码,这个习惯帮我滤掉了很多想当然的优化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询