资讯动态

基于知识图谱与相似度匹配的肝病智能问答系统构建实践

发布时间:2026/10/18 21:33:58 来源:尧图企业网站定制
简介这份资源是一套基于知识图谱与相似度匹配的肝病智能问答系统源码及说明文档面向自然语言处理或医疗信息方向的开发者、研究者适合需要构建垂直领域问答系统或学习知识图谱落地实践的人群。包内共32个文件以Python源码含问答解析、图谱构建、词向量相似度匹配模块、JSON与TXT格式的疾病、药品、检查项等知识图谱数据以及Pyc编译文件、XML工程配置和README说明为主压缩包约7.89MB。已有94人学习下载。系统在原医药知识图谱项目基础上重构知识结构以三类常见肝病为核心建立实体关系并设计了当图谱检索无法命中时通过相似度计算从问答库中匹配相近问题的兜底方案同时提供了病状、食物、药物等词表与停用词表便于直接替换数据或扩展其他疾病领域。整体适合用作毕业设计、课程项目或企业智能导诊模块的参考实现。1. 基于知识图谱和相似度匹配的肝病智能问答系统垂直问答为什么这么拆做医疗方向的 NLP 应用第一个绕不开的问题是用户问「肝硬化腹水吃什么药」通用大模型能写出很像样的回答但你要的是「答案能解释来源、能覆盖医院内部沉淀的常见咨询、能离线部署到内网」的问答系统。基于知识图谱和相似度匹配的肝病智能问答系统就是这个背景下被反复采用的技术方案知识图谱负责存「肝病实体之间的关系」相似度匹配负责把用户口语化的问题对应到系统能理解的意图和实体上。它把知识表示和文本匹配两件事分开做每一层都可解释、可调试答案不对要么是图谱缺数据要么是意图匹配错了定位路径非常清晰。适合正在做垂直领域问答落地的人——不管是课程设计、给某医院信息科做内部知识库还是想把这套架构迁移到其他病种都值得完整走一遍。2. 肝病知识图谱构建实体关系建模与 Neo4j 导入的完整链路2.1 实体与关系怎么设计六类实体、五类关系的选型依据知识图谱不是把资料堆进数据库它先回答一个问题用户会怎么问。把肝病领域的常见提问过一遍你会发现问法高度集中在几类「XX 病有什么症状」「XX 病怎么治」「XX 病需要做什么检查」「XX 病不能吃什么」「XX 病会引发什么」。对应到图谱上就是实体和关系的设计依据。我一般把实体分成六类疾病、症状、药物、检查项目、食物、人群。注意「人群」不是答非所问它用来承接「哪些人容易得脂肪肝」这类问题也让高危因素可以参与图谱推理。关系直接映射提问类型字段定义如下关系头实体尾实体对应的用户问题治疗用药药物疾病这个病用什么药临床表现疾病症状这个病有什么症状检查确认疾病检查这个病查什么能确诊食物禁忌疾病食物这个病不能吃什么并发症疾病疾病这个病会引发什么易感人群人群疾病哪些人容易得这个病关系方向要在一开始就定死否则数据写进去之后再改方向等于重做一遍导入。还有一个务实的建议不要急着把「病因」「预后」这类语义关系塞进来一是数据整理成本高二是这类问题用户问得少、答起来容易有医学争议。先把高频、边界清晰的关系跑通图谱稳定了再扩充比一上来就铺一个大而全的 schema 稳妥得多。2.2 三元组数据准备从医学资料到 CSV 的结构化流程定好 schema 之后最花时间的不是写代码而是把资料整理成三元组。常见做法是找临床指南、药品说明书、医学教材里的描述和表格人工梳理后导出成 CSV每一行一个三元组列是 head、relation、tail外加一个 source 列记录出处将来答案有争议时能溯源。我在整理时发现一个实用的技巧不要逐句去抽三元组而是按疾病建目录每个疾病名下维护症状清单、药物清单、检查清单、禁忌食物清单、并发症清单最后统一转换成三元组。这样做的速度比逐句标注快得多而且漏了哪个病种、缺了哪类关系一眼就能看出来。转换脚本如下import pandas as pd def build_triples(disease_records): rows [] relation_map { symptom: 临床表现, drug: 治疗用药, check: 检查确认, food_avoid: 食物禁忌, complication: 并发症, risk_group: 易感人群, } for record in disease_records: disease record[disease] for rel_key, items in record.items(): if rel_key disease: continue rel relation_map[rel_key] for item in items: # 方向按 2.1 的 schema 统一处理避免源头反复纠结 if rel in (治疗用药, 食物禁忌): head, tail item, disease else: head, tail disease, item rows.append({head: head, relation: rel, tail: tail}) df pd.DataFrame(rows) df df.drop_duplicates() df[source] medical_manual_v1 return df # disease_records 是从各资料整理出的结构化字典列表 df build_triples(disease_records) df.to_csv(liver_disease_triples.csv, indexFalse, encodingutf-8-sig) print(df.shape)代码逻辑说明这个脚本把每个疾病下的清单字典扁平化成三元组表关系方向在转换时统一处理避免不同的人在整理时各自为政。drop_duplicates 去掉完全重复的三元组source 列统一打上资料版本号将来数据更新时可以做 diff知道哪些三元组是这次新加的。参数说明encoding 用 utf-8-sig 是为了让生成的 CSV 在 Windows 的 Excel 里打开不乱码relation_map 里每类关系的方向是固定的后续生成 Cypher 时不会出现方向歧义。数据规模上覆盖常见十几种肝病、每个病种维护 2040 条三元组总量 5001000 条就足以支撑一个演示级问答系统。不要一上来追求几万条稀疏的小图谱比规模大但质量差的图谱好调试得多。2.3 写入 Neo4jpy2neo 批量导入与索引设置三元组就绪后写入 Neo4j。选 Neo4j 而不是关系型数据库理由有三点肝病知识的关系是多对多——一种药治多种病、一种病有多个症状图谱查询表达多跳关系时比 SQL 连续 JOIN 直观Cypher 查询本身就是可解释的答案能顺着关系路径讲清楚按关系方向扩展知识比如新增「同类药物」关系不需要改表结构。写入代码用 py2neo 事务分批提交生产环境下不会因为一条脏数据回滚一整批from py2neo import Graph, Node, Relationship import pandas as pd graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def import_triples(csv_path, batch_size500): df pd.read_csv(csv_path) for start in range(0, len(df), batch_size): batch df.iloc[start : start batch_size] tx graph.begin() for _, row in batch.iterrows(): head Node(Entity, namerow[head]) tail Node(Entity, namerow[tail]) tx.merge(head, Entity, name) tx.merge(tail, Entity, name) rel Relationship(head, row[relation], tail) tx.create(rel) tx.commit() print(f已导入 {min(start batch_size, len(df))}/{len(df)})逻辑说明tx.merge 保证同名实体节点只存在一份重复导入不会累积重复节点tx.create 创建关系关系的类型来自 CSV 里的 relation 列。每 500 条一个事务出错时只需要排查当前批次的数据。参数说明batch_size 取 5001000太小事务数多、导入慢太大会推高单事务内存auth 里的密码不要硬编码在源码里建议从环境变量或 config 读取。这个脚本跑完后务必建索引否则后续查询在实体名上会全库扫描注意索引是知识图谱查询性能的分水岭。几千条数据可能没感觉到上万条时没有索引的 name 查询延迟会从毫秒级掉到秒级。CREATE INDEX entity_name_index FOR (n:Entity) ON (n.name);这个 Cypher 对应 py2neo 里创建的 Entity 标签和 name 属性。导入完成后可以用一句 MATCH (n:Entity) RETURN count(n) 验证节点数是否和 CSV 行数一致这是最便宜的完整性检查。3. 相似度匹配与问答核心从用户问题到 Cypher 查询的转换路径3.1 分词与实体识别用自定义词典修正医学名词问答链路的第一步不是算相似度而是把用户问题里的医学实体找出来。这一步做不好后面算相似度都是在错误的基础上计算。jieba 默认词典对医学名词的支持很弱「肝硬化腹水」可能被切成「肝硬/化腹水」甚至更碎。解决方法是给 jieba 加载自定义词典每行一个词、词频、词性UTF-8 编码肝硬化腹水 20 n 乙型肝炎 20 n 恩替卡韦 20 n 谷丙转氨酶 20 n加载与实体识别的代码import jieba import jieba.posseg as pseg jieba.load_userdict(data/medical_dict.txt) def extract_entities(question, entity_vocab): words [w.word for w in pseg.cut(question) if w.flag.startswith(n)] found [w for w in words if w in entity_vocab] for vocab in entity_vocab: if vocab in question and vocab not in found: found.append(vocab) # 词典未命中时用整串匹配兜底 return list(set(found))逻辑说明第一轮靠 jieba 分词找出词性以 n 开头的词再过滤掉没出现在实体词表里的词第二轮做整串匹配兜底把「乙肝」这类词典缺漏但知识库里存在的别名找回来。entity_vocab 直接从 Neo4j 的 Entity 节点 name 集合里读保证识别出来的词一定能查到图谱。参数说明词频 20 是稳妥起点太低可能切不出来太高会让 jieba 在歧义场景下过度合并。pseg 词性过滤里nz 是专有名词n 是普通名词医学实体基本都落在 n 和 nz 上。3.2 意图识别与相似度计算TF-IDF 余弦相似度的工程实现实体识别拿到「问的是谁」意图识别决定「问的是它的什么」。我一般把意图分成五类症状查询、治疗查询、检查查询、禁忌查询、通用咨询。实现上先用规则触发词做粗分类——问句含「怎么治」「吃什么药」直接归治疗查询含「查什么」「做什么检查」归检查查询——再用 TF-IDF 加余弦相似度做规则的兜底覆盖那些没有明显触发词的口语化问法。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity INTENT_TEMPLATES { 症状查询: [这个病有哪些症状, 得了这个病会有什么表现], 治疗查询: [这个病应该怎么治疗, 这个病吃什么药], 检查查询: [这个病需要做什么检查, 怎么确诊这个病], 禁忌查询: [这个病有什么饮食禁忌, 这个病不能吃什么], 通用咨询: [请介绍一下这个病, 这个病严重吗], } def match_intent(question, templatesINTENT_TEMPLATES): corpus [q for qs in templates.values() for q in qs] [question] tfidf TfidfVectorizer(token_patternr\S).fit_transform(corpus) q_vec tfidf[-1] t_vecs tfidf[:-1] scores cosine_similarity(q_vec, t_vecs).flatten() # 每个意图取多条模板里的最高分避免模板数量不均导致偏差 intent_scores {intent: 0.0 for intent in templates} idx 0 for intent, qs in templates.items(): for _ in qs: intent_scores[intent] max(intent_scores[intent], float(scores[idx])) idx 1 best_intent max(intent_scores, keyintent_scores.get) return best_intent, intent_scores[best_intent]逻辑说明把每个意图的多条模板句和用户问题放进同一个 TF-IDF 向量空间最后一行是问题向量前面是全部模板向量。先算问题与每条模板的余弦相似度再把属于同一意图的模板分数取最大值避免某个意图模板多但单条都对不齐的问题。参数说明token_pattern 设成 r\S是因为中文不需要按空白切 token分词结果就是整词。模板句的写法决定了相似度的上限这是最需要投入的地方——模板句要和真实问法贴近比如「肝硬化腹水怎么治」和「这个病吃什么药」词面交集不多所以每个意图至少准备两条以上、覆盖不同句式的模板。这里有个血泪经验阈值不要拍脑袋定。先用 3050 条真实问句跑一遍记录正确意图的相似度分数分布再把阈值设在「正确样本最低分」和「错误样本最高分」中间的某个位置。后面第 6 章会讲怎么量化这一步。3.3 答案生成与兜底相似度阈值怎么定、查不到怎么办意图和实体确认后答案生成就是一次图谱查询加上一句自然语言拼接。查询用意图对应的关系类型构造 CypherINTENT_TO_RELATION { 症状查询: 临床表现, 治疗查询: 治疗用药, 检查查询: 检查确认, 禁忌查询: 食物禁忌, 通用咨询: 并发症, } ANSWER_HEAD { 临床表现: 常见症状, 治疗用药: 常用治疗药物, 检查确认: 相关检查项目, 食物禁忌: 饮食禁忌, 并发症: 可能引发的并发症, } def build_answer(question, intent, entities): if not entities: return 知识库里暂时没有匹配到您说的疾病请换一种说法再试。 disease max(entities, keylen) rel INTENT_TO_RELATION.get(intent, 并发症) cypher ( fMATCH (d:Entity {{name: {disease}}})-[r:{rel}]-(t:Entity) fRETURN t.name AS name LIMIT 10 ) rows graph.run(cypher).data() if not rows: return f知识库里暂时没有「{disease}」的「{ANSWER_HEAD[rel]}」信息。 items [row[name] for row in rows] answer f{disease}的{ANSWER_HEAD[rel]}{、.join(items)}。 return answer 以上信息仅供参考具体请遵医嘱。逻辑说明MATCH 按实体名和关系类型查询尾实体LIMIT 10 防止答案过长。disease 取实体列表里最长的那个因为「乙型肝炎」比「乙肝」信息量更足。查询前没有做转义这是演示代码的简化——真实项目里先把实体名与知识库的 name 列表做精确匹配匹配不到直接返回兜底不把用户输入拼进 Cypher。参数说明LIMIT 控制答案最多列几项回退到「并发症」的默认关系是为了让通用咨询类问题至少能返回相关内容。如果知识库没有结果返回文案要明确说「暂时没有」而不是编造。相似度阈值是这套系统里最需要调的超参数。设高了用户问「得了脂肪肝要查什么」匹配不到任何模板走兜底等于没答设低了完全不相关的问题也会被塞进某个错误意图。我一般把阈值放在 0.35 左右作为起点然后按评估集调整。4. 系统串联与运行把知识图谱、相似度模块和 Flask 服务接起来4.1 源码结构梳理每个 Python 文件在做什么一个能跑的肝病问答系统模块边界要清楚否则改一个参数要翻遍整个项目。常见的源码结构是这样拿到手先对照这个清单看liver_qa/ ├── config.py # 全局配置Neo4j 连接、阈值、文件路径 ├── data/ │ ├── medical_dict.txt # jieba 自定义词典 │ └── liver_disease_triples.csv # 三元组原始数据 ├── kg/ │ ├── build_graph.py # 三元组导入 Neo4j │ └── query_graph.py # 封装 Cypher 查询 ├── nlp/ │ ├── entity.py # 实体识别 │ └── intent.py # 意图识别 相似度计算 ├── qa/ │ └── answer.py # 答案生成与兜底 ├── app.py # Flask 接口 └── requirements.txtconfig.py 集中管理所有需要改的参数是这个结构里最值得保留的工程习惯。Neo4j 的 URI、账号密码、相似度阈值、实体词表路径都在一处换环境或调参只改这个文件。数据文件单独放 data 目录和代码分离后续更新知识库不用碰代码。拿到源码包时还要先看两样东西requirements.txt 里的依赖版本以及说明文档里的数据格式约定。我见过不少翻车案例都是本地 Python 版本和项目用的 py2neo 版本不匹配导数据时 API 行为完全不同。py2neo 的 4.x 和 5.x 在 Graph 初始化、事务接口上就有差异如果说明里写了版本号优先按它建虚拟环境。4.2 启动问答服务Flask 接口的最小运行方式接口层用 Flask 是最常见的做法一个 /api/chat 的 POST 接口接收问题返回答案和调试信息from flask import Flask, request, jsonify from nlp.entity import extract_entities from nlp.intent import match_intent from qa.answer import build_answer from config import SIMILARITY_THRESHOLD app Flask(__name__) app.route(/api/chat, methods[POST]) def chat(): data request.get_json(silentTrue) or {} question data.get(question, ).strip() if not question: return jsonify({code: 400, msg: 问题不能为空}) entities extract_entities(question, ENTITY_VOCAB) intent, sim_score match_intent(question) if sim_score SIMILARITY_THRESHOLD: intent 通用咨询 answer build_answer(question, intent, entities) return jsonify({ code: 0, answer: answer, debug: {entities: entities, intent: intent, sim_score: sim_score}, }) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)启动命令就两行pip install -r requirements.txt python app.py逻辑说明debug 字段返回实体、意图、相似度分数这是排查问题最重要的入口。用户问「帮我看看乙肝怎么治」如果 debug 里 entities 是空问题在分词或词典如果意图是「症状查询」问题在相似度匹配如果答案说知识库没有信息问题在知识图谱数据。提示debugFalse 是必须的。Flask 的 debug 模式会把调试器暴露到网络上内网演示也不建议开。接口起来后先用 curl 做一次冒烟测试curl -X POST http://127.0.0.1:8000/api/chat \ -H Content-Type: application/json \ -d {question: 肝硬化腹水怎么治}返回里先看 debug 字段确认实体和意图都对再看 answer 是否来自知识图谱。这一步通过再接入前端页面或者即时通讯机器人都不迟。参数说明host 设为 0.0.0.0 方便局域网联调但要确认所在网络环境允许对外监听。port 8000 换其他端口也行注意和 Neo4j 的 Bolt 端口 7687 区分别混淆。4.3 三个必调参数相似度阈值、词典路径、连接池配置把最容易影响效果的参数集中说明一下参数推荐初始值调整依据SIMILARITY_THRESHOLD0.35用 3050 条真实问句实测后按正确/错误相似度分布取中位DICT_PATHdata/medical_dict.txt新增病种或新药名时扩充路径用绝对路径更稳graph.max_connections50压测报连接池耗尽时调大同时确认服务端配置Cypher LIMIT10答案太长用户读不完太短信息不完整三个最容易踩的参数坑第一相似度阈值不是常量换一批模板句就要重新测别指望一个阈值吃遍所有问法第二Windows 下文件路径的规范化写法反斜杠和正斜杠不统一会导致 jieba 静默加载词典失败错误日志完全没有提示第三py2neo 连接池在 4.x 版本默认连接数偏小压测时会出现连接获取超时优先把 max_connections 调大同时检查 Neo4j 服务端的 Bolt 线程池配置。5. 避坑清单肝病问答系统开发中的五个典型翻车现场5.1 相似度匹配总是答非所问现象用户问「脂肪肝需要做什么检查」系统返回的是「脂肪肝的常用治疗药物」。意图识别把「需要做什么检查」匹配到了「治疗查询」模板。原因TF-IDF 是词面匹配用户问句里的「需要做什么」和模板里的「应该怎么治疗」几乎没有共用词向量距离被「脂肪肝」这个词占满而所有模板句里都不含「脂肪肝」相似度分数分布随机误匹配就成了家常便饭。解决每个意图至少准备三条不同句式的模板覆盖「需要做什么检查 / 一般查什么 / 去医院查哪些项目」这类变体同时把规则触发词放在相似度之前命中「检查」直接指定意图相似度只兜底规则覆盖不到的句子。两层配合之后误匹配率能明显降下来。5.2 知识图谱查询结果为空日志里却没有报错现象实体识别正常、意图识别正常build_answer 返回「知识库里暂时没有信息」。用 Neo4j Browser 手动执行同样的 Cypher 却有结果。原因最常见的是关系方向反了或者导入时创建了同名但属性不一致的节点。比如「肝硬化」同时出现在疾病和并发症的尾实体里查询匹配到了错误的节点关系方向对不上。解决在导入脚本里统一校验关系方向查询侧加一层防护——先按 name 查出节点再在 Python 里检查该节点是否有对应出边关系没有就尝试反向关系再查一次。这样即使 schema 设计有疏漏运行期也能给出答案而不是空回复。这个双保险我一直在用。5.3 医学实体被分词器切成碎片现象extract_entities 返回空列表但问题里明显有「慢性乙型肝炎」这个实体。原因jieba 自定义词典没有加载成功或者词频太低被 HMM 切分覆盖。另一个隐蔽原因是 load_userdict 的路径写错jieba 不报错静默跳过排查时特别容易忽略。解决在启动脚本里显式验证词典是否生效jieba.initialize() jieba.load_userdict(DICT_PATH) print(jieba.lcut(慢性乙型肝炎)) # 期望输出 [慢性乙型肝炎]如果切出来还是碎片把词频从 20 提到 50 再试。另外检查词典文件编码是否为 UTF-8Windows 记事本默认的 ANSI 编码在 Linux 环境下会静默加载失败这个细节值得最先排查。5.4 Neo4j 连接超时与写入变慢现象系统跑了一段时间后第一次问答要等好几秒日志报连接获取超时。原因本地调试时常开着 Neo4j Browser 和 Python 服务同时占用连接py2neo 默认连接池不够大压测时连接被占满另外 Neo4j 默认堆内存较小数据量上来后 GC 频繁写事务明显变慢。解决连接池按 4.3 的表调大查询统一走 2.3 建的索引避免全库扫描。Neo4j 服务端的堆内存配置在 neo4j.conf 里开发机可以给 1G 起步改完必须重启服务这个配置不是热生效的改完不重启等于白改。5.5 医疗答案的合规边界哪些话不能说现象系统直接回答「恩替卡韦治疗慢性乙型肝炎推荐剂量 0.5mg 每日一次」。带具体剂量的用药建议一旦有误责任边界说不清。原因知识图谱存了药物节点问答模块把「治疗用药」关系直接展开成答案没有做风险分级。剂量、用法这类信息在医学上属于高风险内容机器给出具体剂量容易误导用户。解决答案模板分两层。涉及药物的答案统一加「以上信息仅供参考具体请遵医嘱不要自行调整剂量」固定后缀更稳妥的做法是把剂量、用法从图谱数据里剔除图谱只存药品与疾病的对应关系。问答系统的定位是科普和分诊辅助不是诊疗工具这个边界要在需求阶段就和需求方明确对齐。6. 从能答到答得准词向量改造、评估集与跨病种迁移6.1 用词向量替代 TF-IDF 的改造路径TF-IDF 的问题在词面匹配用户问「乙肝好治吗」和模板「这个病治疗效果如何」几乎没有重合词。改成句向量相似度是最直接的优化用 word2vec 或 sentence-transformers 把问句编码成向量再算余弦。改造量不大match_intent 里把 TfidfVectorizer 换成 SentenceTransformer代码只改几行但相似度分布的区分度会明显提升。需要注意的是这类模型首次加载要下载权重离线环境要提前备好模型文件CPU 推理单条约 50ms比 TF-IDF 慢一个数量级但绝对延迟可以接受。6.2 构建评估集问答准确率的量化方式没有评估就没法调参。我习惯在项目第三天就建一个评估集从咨询记录里整理 100 条问句每条标注正确意图和期望答案包含的知识图谱实体。跑一遍全流程统计三个指标——实体识别召回率、意图识别准确率、答案正确率。答案正确率的标注标准是「图谱中是否存在该三元组且方向正确」。每次调整阈值或词典后重跑一遍指标不回退才进入下一个改动。这套流程比凭感觉调参靠谱得多也方便向需求方交代效果。6.3 向其他病种迁移改动只集中在数据层这套架构的病种相关性全部集中在数据层jieba 词典换成新病种的术语表、三元组 CSV 换成新病种的知识、模板问句按新病种的常见问法调整代码的 nlp 和 qa 部分基本不用动。某导师带过的模拟项目X就是这么处理的——从肝病换到糖尿病只替换了数据文件和词典一周内跑通。这个取舍让这套方案在后续维护时的价值比一次性问答脚本高很多。最后说一个我做这类系统养成的习惯每次上线前用 20 条边界问句做冒烟测试包括空问题、纯口语问题、带错别字的问题、超长问题。因为答得好的系统不只是知识库够大而是知道自己不知道什么并且能体面地说不知道。这个习惯帮我挡掉过不少线上翻车。希望这篇笔记能帮你在肝病问答系统上少走弯路把时间花在数据质量和阈值调试上——那才是这套系统的真正灵魂。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑