简介:面向知识图谱与自然语言处理开发者的一份实战源码包,基于Python构建肝病知识图谱问答系统。项目涵盖疾病、症状、药物等实体及其关系,涉及图谱存储、意图识别、语义解析和答案检索等关键环节,整体设计贴近实际医疗问答场景,可依据用户自然语言问题从知识图谱中定位答案。压缩包共28个文件,以9个Python脚本(医疗知识图谱构建、问题分类器、答案搜索等)为主体,辅以8个txt实体字典与语料、5个xml配置、3个json数据及说明文档,整体约9.31MB,模块划分清晰,便于按源码路径对照复现实验。截至目前已有112人学习,适合作为NLP与知识图谱结合的入门实践,也可用于医疗问答原型开发或课程设计参考。通过阅读源码可完整理解从数据处理、图谱入库到问题解析与答案生成的全链路实现。
1. 肝病知识图谱问答系统:一份master分支zip包背后的QA与KG组合
拿到一个名为QASystemOnHepatopathyKG-master.zip的压缩包,通常说明这是从Git仓库某个master分支导出的项目快照,里面装的是一个以肝病知识图谱为后端的问答系统工程。这类系统做的事情是:接收用户用自然语言提出的肝病问题,抽取疾病、症状、药物等实体与意图,再转成图谱查询并获得答案。它比关键词搜索强在回答路径可解释,比纯生成式大模型强在答案可控、不会一本正经地胡说八道。下面顺着这类项目最常走的落地路径展开:先讲图谱schema与问答流水线,再给最小可运行的实体识别与Cypher生成代码,接着说从zip解压到服务启动的步骤,最后用测试集评估问答准确率。适合正在部署领域KG问答系统、或刚拿到类似zip包不知道怎么入手的工程师。
2. 肝病知识图谱的schema与问答流水线:QA系统在KG上的骨架
2.1 强术语的肝病领域,为什么值得用KG做问答底座
肝病是知识图谱落地比较典型的医学子领域,因为它的知识高度结构化:疾病、病毒分型、肝功能指标、抗病毒药物、禁忌食物之间的关系相对稳定,且描述同一概念的用词高度集中。比如“肝硬化”在不同资料里很少出现别名歧义,药物也以通用名和商品名两层清晰组织。用KG做底座,问答系统就能把问题映射成三元组查询,回答能追溯到图谱里的具体节点和关系边。相比之下,纯全文检索会把“肝硬化患者饮食禁忌”和“肝硬化不能吃什么”当成两套关键词,而图谱问答通过关系边一次就能取到同类答案。
除了可解释性,KG问答的另一层优势是维护成本低。新增知识时只需往图谱里插入节点与关系,不需要微调模型,这对医学这类更新频繁但增量有限的领域尤其友好。常见做法是:先积累一批肝病诊疗指南和百科条目,用流程化脚本抽成实体关系文件,再导入Neo4j,这也是项目名里KG部分的核心工作。若把这块换成向量数据库加生成式模型,就变成近期常说的agentic qa路线,但在这类场景下可控性和可溯源要求更高时,显式图谱仍然是更稳的底座。
2.2 肝病图谱schema:六类节点加十二类关系就够了
见过或写过肝病KG项目的人都知道,schema不需要一次建得特别复杂。一个能支撑常规问答的典型图谱,节点类型控制在6类左右,关系类型在12类以内就已足够。过多的节点类型会让实体识别和关系抽取的样本都翻倍,维护成本迅速上升。下面这张表是这类项目最常用的一套骨架,能覆盖“肝硬化有什么症状”“乙肝吃什么药”“戊肝怎么传播”三类最高频问题。
| 节点类型 | 典型实例 | 关系类型 | 语义 | 典型三元组 |
|---|---|---|---|---|
| 疾病 | 肝硬化、乙肝、戊肝 | 表现为 | 疾病有症状 | 肝硬化-表现为-腹水 |
| 症状 | 黄疸、腹水、肝区疼痛 | 治疗用 | 疾病用药物 | 乙肝-治疗用-恩替卡韦 |
| 药物 | 恩替卡韦、阿德福韦酯 | 禁忌于 | 药物禁忌疾病 | NSAIDs-禁忌于-肝硬化 |
| 检查项 | ALT、AST、胆红素 | 用于诊断 | 检查发现疾病 | 肝弹-用于诊断-肝纤维化 |
| 食物 | 薏米、动物肝脏 | 建议摄入 | 疾病饮食建议 | 脂肪肝-建议摄入-山楂 |
| 传播途径 | 血液、母婴、性接触 | 传播途径 | 疾病传播方式 | 戊肝-传播途径-粪口传播 |
还有一类多跳查询,如“治疗肝硬化的药物里哪种不适合乙肝患者”,需要沿两条关系边做图搜索,这类查询单独建模板。建立schema后,在Neo4j里先建约束与索引,导入效率和后续查询质量都会提升。用Cypher建索引的脚本通常长这样:
CREATE CONSTRAINT disease_name IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT drug_name IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; CREATE INDEX symptom_name_idx IF NOT EXISTS FOR (s:Symptom) ON (s.name);这里用CREATE CONSTRAINT给疾病和药物名称加唯一约束,防止批量导入时出现重复节点;对症状建立普通索引,是为了加快实体链到症状节点的匹配速度。如果后续要支持英文别名或ICD编码,可以给对应节点再加code属性并建唯一约束。注意Neo4j 5.x的语法中FOR ... REQUIRE是标准写法,旧版4.x用的是ON (n:L) ASSERT n.p IS UNIQUE,从zip包的说明文件里确认Neo4j版本再执行,否则会报语法错误。
2.3 问答流水线:先理解问题,再查图谱,最后组织语言
一个完整的QA流程,在绝大多数肝病KG项目里是四步:第一步是问题预处理,包含分词、去停用词和标点规范化;第二步是联合完成意图识别与实体识别,判断用户是想问症状、药物还是传播途径,同时抽出问题里的“肝硬化”“恩替卡韦”等实体词;第三步是根据意图和实体生成Cypher查询,这一步通常是模板加槽位,而不是自由生成;第四步拿到查询结果后,按模板组织成自然语言答案。
前两步的质量决定整个系统上限。实体识别漏掉一个核心词,后面的查询再正确也查不到数据;意图识别错了,抽出的实体再准也只能答非所问。所以工程上最常用的不是直接上BERT做序列标注,而是用“领域词典+最大匹配或jieba自定义词表”先跑一版,把准确率基线拉到八成以上,再决定要不要上模型。第三步的Cypher生成在规则系统里是一张意图到查询模板的映射表,查询模板的数量通常不超过30个。这样设计的原因很直接:医学问答的句式高度固定,真正需要开新模板的场景很少,维护成本远低于训练一个NL2Cypher模型。
3. 把自然语言问题翻译成Cypher:肝病QA后端核心代码的最小实现
3.1 先做领域词典,再做实体识别
实体识别最朴素也最稳定的做法是自定义词典加正向最大匹配。jieba支持把领域词表加载进分词结果,这能让“肝细胞癌”“乙型肝炎病毒”这类长词不被错误切开。
import jieba # 词典格式:词 词频 词性,词频给高一些避免被拆散 DOMAIN_WORDS = [ "肝硬化 3000 n", "肝细胞癌 2800 n", "乙型肝炎病毒 2600 n", "恩替卡韦 2600 nz", "黄疸 2500 n", "腹水 2400 n", ] for line in DOMAIN_WORDS: word, freq, pos = line.split() jieba.add_word(word, freq=int(freq), tag=pos) def extract_entities(question: str) -> list: seg_list = jieba.lcut(question) entity_set = set() for token in seg_list: if token in entity_set: continue if _is_domain_term(token): entity_set.add(token) return list(entity_set) def _is_domain_term(token: str) -> bool: # 实际项目里这里会查图谱里的label,避免魔法表 return token in {"肝硬化", "肝细胞癌", "乙型肝炎病毒", "恩替卡韦", "黄疸", "腹水"}这里的freq参数很关键,肝病领域词必须给到2500以上才不会在通用语料词频面前被切开。比如默认词库会把“肝炎”和“病毒”切开,加了乙型肝炎病毒整词并抬高词频后,分词结果才会保留完整实体。_is_domain_term在工程版里应该改成查询Neo4j的节点label或一个Redis缓存集合,避免把领域词表写死在代码里。
3.2 意图识别用规则表:把问题动词和疑问词映射到意图槽位
肝病问答里问得最多的是症状、治疗、禁忌、饮食、传播途径五类。用关键词优先级加权,比训练分类器更容易调试。代码逻辑是:先定义每个意图的触发词列表,逐一匹配并打分,得分最高且超过阈值的作为输出。
INTENT_RULES = { "symptom": {"keywords": ["症状", "表现", "有什么感觉", "会有哪些"], "weight": 3}, "treatment": {"keywords": ["治疗", "吃什么药", "用药", "怎么治", "用什么药"], "weight": 3}, "contraindication": {"keywords": ["禁忌", "不能吃", "注意什么", "忌口", "慎用"], "weight": 3}, "diet": {"keywords": ["饮食", "吃什么好", "建议吃", "营养", "食谱"], "weight": 2}, "transmission": {"keywords": ["传播", "传染", "传染途径", "怎么感染"], "weight": 3}, } def classify_intent(question: str): scores = {} for intent, rule in INTENT_RULES.items(): hit = sum(1 for kw in rule["keywords"] if kw in question) scores[intent] = hit * rule["weight"] best = max(scores, key=scores.get) return best if scores[best] > 0 else "unknown"这样实现的好处是每条规则的触发词都可以从历史日志里直接补,用户问法一变,往对应列表里加词就行。坏处是触发词之间有重叠时会误判,比如“脂肪肝患者平时饮食要注意什么”同时命中饮食与禁忌,这时需要让contraindication的权重明显高于diet。真实工程里我一般会在规则结果后面接一个阈值判断,得分低于设定值就走兜底话术,别硬答。
3.3 槽位填充与Cypher模板拼接
得到意图和实体列表后,还要把实体分类对到图谱的label上。比如“肝硬化”是Disease,“黄疸”是Symptom,再按意图模板拼查询。这一步的关键是模板的参数化,永远不要直接拼接用户输入进Cypher,防注入也防特殊字符破坏语法。
from typing import Optional def build_cypher(intent: str, entities: list) -> Optional[str]: entity_label = resolve_entity_label(entities[0]) if entities else None if entity_label is None: return None if intent == "symptom": return ( f"MATCH (d:{entity_label} {{name: $name}})-[:表现为]->(s:Symptom) " "RETURN collect(s.name) AS answer" ) if intent == "treatment": return ( f"MATCH (d:{entity_label} {{name: $name}})-[:治疗用]->(m:Drug) " "RETURN collect(m.name) AS answer" ) if intent == "transmission": return ( f"MATCH (d:{entity_label} {{name: $name}})-[:传播途径]->(p:Transmission) " "RETURN collect(p.name) AS answer" ) return None参数$name由Neo4j驱动绑定,传参时把实体名放参数里。resolve_entity_label负责把实体文本映射到图谱标签,常见做法是先精确查节点label,查不到再按同义词表展开,最后才做模糊前缀匹配。之所以用collect,是为了把多值结果合成一个数组,后端可以直接转成中文枚举句。这里必须同时做两件事:空结果判断和标签不匹配兜底,否则查询就返回空列表,用户会看到“该问题暂未收录”。
3.4 把Cypher结果组装成中文答案
答案组装规则通常是“主实体+意图动作+结果枚举”。查询出的每条答案是字符串列表,直接用顿号连接,并补上“根据知识图谱查询结果”这类前缀。意图对应的句式也要有对应关系,做一张模板表:
| 意图 | 回答句型 | 示例 |
|---|---|---|
| symptom | 疾病常见的症状包括:X、Y。 | 肝硬化常见的症状包括:腹水、黄疸。 |
| treatment | 治疗可考虑药物:X、Y,具体请遵医嘱。 | 乙肝治疗可考虑药物:恩替卡韦、替诺福韦,具体请遵医嘱。 |
| transmission | 传播途径包括:X、Y。 | 戊肝传播途径包括:粪口传播。 |
| contraindication | 需注意:X、Y。 | 肝硬化需注意:禁用NSAIDs、避免饮酒。 |
组装完成后才交给上游接口。如果返回了None或空数组,就回一句话术“图谱中暂时没有对应这个问题的知识”,而不是让用户看到一条空JSON。把这条兜底做成可配置项,后续接大模型重写时也不用改主逻辑。
4. 在本地跑通master分支zip包:解压、导数据、起服务
4.1 从github的zip包到可运行项目:解压和虚拟环境
github的zip包怎样安装,这类问题在社区里反复出现。其实zip包不等于Git仓库,它只是Git导出的快照,解压后.git目录以及远程仓库关联信息都在打包时被丢弃了。所以不要尝试在目录里直接git pull;先解压,再自己初始化Git仓库并指向远程地址。
unzip QASystemOnHepatopathyKG-master.zip cd QASystemOnHepatopathyKG-master python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt解压后第一件事不是装依赖,而是看目录结构里有没有README或config文件。这类项目一般会有三个目录:kg_import放图谱原始数据与导入脚本,qa_server放Flask后端,web放问答前端。requirements.txt里常见的是flask、neo4j驱动、jieba,版本建议在Python 3.8到3.10区间运行,过高的Python版本偶尔会遇到旧版py2neo不兼容的情况。如果requirements里锁了py2neo==2021.2.3这类老版本,就需要手动把项目里from py2neo import Graph改成新版neo4j驱动的写法,或者保持低版本Python运行。
4.2 把肝病图谱数据导入Neo4j:两种常见做法
项目里的kg_import目录通常放着两种文件之一:一是nodes.csv和rels.csv,二是cypher.cypher批处理脚本。用csv批量导入的效率高,适合首次建库;用脚本逐条执行Cypher适合增量修改。
# 方式1:csv批量导入(Neo4j 5.x) $NEO4J_HOME/bin/neo4j-admin database import full \ --nodes=import/disease.csv \ --nodes=import/symptom.csv \ --relationships=import/disease_symptom.csv \ --database=hepatopathy \ --overwrite-destination=true # 方式2:用cypher-shell执行批处理 $NEO4J_HOME/bin/cypher-shell -u neo4j -p your_password \ < kg_import/neo4j_init.cypherdatabase import full会在目标库为空时执行,如果库里有数据会报错;重复导入前先删掉旧库或用--overwrite-destination=true。csv文件需要放在Neo4j的import目录下,表头字段顺序必须与Cypher脚本中LOAD CSV的字段名对应,否则会出现类型解析错位。项目自带的数据文件如果是旧格式,通常会缺:START_ID和:END_ID这样的隐含列,需要读一下导入脚本确认。
4.3 改配置并启动服务
后端连接Neo4j的配置一般在qa_server/config.py或者环境变量里。最常见的配置项是NEO4J_URI、NEO4J_USER、NEO4J_PASSWORD。先把密码改成自己的,再启动Flask服务。
export NEO4J_URI="bolt://localhost:7687" export NEO4J_USER="neo4j" export NEO4J_PASSWORD="your_password" cd qa_server python app.py启动日志里看到Running on http://127.0.0.1:5000就说明后端已就绪。先不要直接打开前端页面,用curl验证一下接口:
curl -X POST http://127.0.0.1:5000/qa \ -H "Content-Type: application/json" \ -d '{"question": "肝硬化患者有哪些症状?"}'返回的JSON里如果包含答案数组,说明整条链路是通的。如果返回500,下一步去查Flask日志,多半是实体识别没问题但Cypher执行报错。
4.4 启动失败的四个常见点位
| 失败现象 | 常见原因 | 处理方式 |
|---|---|---|
启动报找不到py2neo | zip包与Python版本不匹配 | 换到Python 3.8环境或升级驱动代码 |
| 连接Neo4j超时 | 默认URI或端口不对 | 核对config里的7687端口与密码 |
| 查询全部返回空 | 图谱数据没导入成功 | cypher-shell执行MATCH (n) RETURN count(n)确认 |
| 中文乱码 | csv文件编码不是UTF-8 | 使用iconv -f GBK -t UTF-8转换后再导入 |
排查启动问题时,我一般会从下往上检查数据层再检查接口层。若图谱里节点总数是0,后面一切都不用查,直接回到4.2节重新导入。经常出现的“github上下载的zip项目与git项目关联变基到远程仓库失败”也常在这步出现,解决办法是先git init、git remote add origin,再用git fetch origin然后从master分支重新检出新分支,不要在解压根目录上直接变基。
5. 用测试集给肝病QA打分,并提升实体召回
5.1 先建一份golden问答集
没有测试集的QA系统升级就是盲人摸象。常见做法是维护一份50到100条的golden JSON文件,每条包含question、intent、golden实体和期望答案集合。跑回归时逐条调用后端接口,统计意图命中、实体F1、答案是否有包含关系三个指标。
| 指标 | 计算方式 | 合格线参考 |
|---|---|---|
| 意图准确率 | 预测意图与标注一致占比 | 90% |
| 实体F1 | 实体集合与标注的精确率/召回率 | 85% |
| 答案覆盖率 | 期望答案中至少一条出现在返回列表 | 88% |
跑完测试集后可以很快定位问题:意图准确率低,先改INTENT_RULES里的触发词;实体F1低,扩充领域词典或同义词表;答案覆盖率低,通常不是模型问题,而是去图谱里查对应关系类型缺了哪些边。这个排错顺序能省下大量调模型参数的时间。
5.2 用同义词表把实体识别召回拉起来
肝病问题最大的坑是用户不说标准名,说俗称或近义表达。比如“乙肝”与“乙型肝炎”、“肝腹水”与实际疾病“肝硬化腹水”。在extract_entities之后加一个同义词规整步骤,能有效提升后续匹配成功率。
SYNONYM_MAP = { "乙肝": "乙型肝炎", "肝腹水": "肝硬化腹水", "打乙肝疫苗": "乙型肝炎疫苗", } def normalize_entity(entity: str) -> str: return SYNONYM_MAP.get(entity, entity)规整后的实体直接交给build_cypher。这个映射表的来源可以是搜索日志里用户实际输入和标准术语的配对,也可以从疾病术语库导出。注意只做单向映射,不要反向替换,否则“乙型肝炎”也会被拆成异常表达。同义词表加多了以后用字典文件存,不要塞在代码里。
5.3 用图谱degree定位回答不到的盲区
一个少有人提但很有效的技巧:导出图谱中每个关系类型的数量分布,再对照golden测试集看哪些意图的答案老是空。若禁忌于关系数量明显少于治疗用,则改进方向非常清晰——补禁忌于关系,而不是调NER或换模型。把这条统计放进CI里,每次改图谱后自动跑一次,能防止导入错位导致整类关系失效。
MATCH ()-[r]->() RETURN type(r) AS rel, count(*) AS cnt ORDER BY cnt DESC;当某类关系数量为0但csv里明明有数据时,优先怀疑导入时关系表头:END_ID写错,导致关系全部被跳过。QA系统的上限往往不在模型,而在图谱数据质量;把这个关系数量分布定期刷新到问题追踪系统里,持续补边,比反复调参带来的收益更大。
本文还有配套的精品资源,点击获取