简介:一份基于Neo4j图数据库的医疗知识图谱智能问答机器人项目包,适用于Python开发者、知识图谱学习者及需要完成课程大作业或毕业设计的高校学生。资源共36个文件,大小15.33MB,主要包含8个Python源码文件(覆盖医疗图谱构建、问句解析、查询生成与答案检索模块)、前端HTML/CSS/JS交互页面、文本数据文件以及若干PNG/JPG图片示例,便于直接运行与二次开发。目前已有478人在CSDN学习浏览此资源,说明其具备一定的实用性和参考价值。项目完整实现了从医疗实体关系抽取、知识图谱存储到自然语言问句分析、Cypher查询生成、答案返回的闭环流程,同时提供了可视化界面和说明文档,可帮助读者快速理解Neo4j图数据库在问答系统中的应用方式。无论是作为知识图谱方向的课程设计模板,还是作为技术面试的项目实践素材,都有较强的借鉴意义。
1. 一个医疗问答需求,为什么偏偏选 Neo4j 图数据库
把“基于Neo4j图数据库的医疗知识图谱智能问答机器人”这个 zip 解压完你会发现,真正让机器人“会答”的,不是那层问答接口代码,而是图里的知识图谱本身。医疗场景里,患者问“高血压头晕该挂哪个科”,背后要穿过“高血压—表现—头晕—对应科室”好几跳关系,用关系型数据库做这种多跳关联查询,要么嵌套 join 好几张表,要么写一串临时表,维护成本高,查询还慢;Neo4j 直接用边表达关系,一跳一个 MATCH,语义和性能都顺。这套方案适合两类人:一类是做医院导诊、慢病管理、健康咨询类产品的后端工程师,另一类是刚接触知识图谱、想找一个业务可感又能自己造数据的场景来练手的人。下面按环境建模、数据入库、问答链路、避坑排查、进阶验证的顺序,把整条路走一遍。
2. Neo4j 安装与配置,以及医疗图谱的本体建模
2.1 Neo4j 安装与配置:桌面版还是社区服务端
Neo4j 的安装与配置有两条常见路线:Neo4j Desktop 桌面版和 Community Server 社区服务端。如果项目还在原型验证阶段、数据量只有几百上千条,桌面版最省事,界面里自带数据库管理,官方下载安装教程里默认推荐它,也可以当作日常查看图谱的可视化工具来用。但医疗问答这类项目最终要部署到内网服务器上,桌面版的图形化界面反而多余,我一般直接用 tar.gz 包装 Community Server,自己控制 JVM 内存和远程访问开关,排查问题也更容易定位到具体配置项。
Neo4j 的版本和 JDK 绑定得比较死:3.x 配 JDK 8,4.x 配 JDK 11,5.x 配 JDK 17。下载解压后第一件事不是急着启动,而是先改conf/neo4j.conf里的内存参数。默认堆内存只有 512M,后面 LOAD CSV 导几万行数据时基本必挂,这是新手装完环境后踩得最密的一个坑。我的习惯是装到固定目录再软链,方便后续写 systemd 服务或做版本切换。
cd /opt tar -xzf neo4j-community-4.4.19-unix.tar.gz mv neo4j-community-4.4.19 neo4j cd neo4j # 在 conf/neo4j.conf 中调内存,这是启动前必做的步骤 # dbms.memory.heap.initial_size=2G # dbms.memory.heap.max_size=4G # dbms.memory.pagecache.size=1G bin/neo4j start # 启动后浏览器访问 http://localhost:7474 # 首次登录使用初始账号 neo4j/neo4j,系统会强制要求改密码这段命令的逻辑是:先解压到固定目录,避免后续配置里写错相对路径;改内存参数必须早于启动,不然启动进程后堆内存不足,建唯一约束都会直接报错;启动后第一次登录会强制改密码,改完的密码要记到配置里,后面 Python 驱动连接靠的就是它。如果服务要部署在服务器上供局域网内其他机器访问,还需要放开server.bolt.listen-address和server.http.listen-address,默认绑定的 localhost 是“本机能连、别人连不上”的常见原因。堆内存负责查询排序和聚合,pagecache 负责缓存节点和关系,问答场景读多写少,pagecache 给大一点收益明显,但两者之和不要超过物理内存,否则系统开始交换,界面很卡但 CPU 占用却不高。
提示:Neo4j 5.x 对部分配置键做了重命名,比如堆内存键改成了
server.memory.heap.max_size。拿到 conf 文件先看注释模板,不要盲抄网上的旧配置。
2.2 医疗本体建模:先画实体关系图,再谈构建知识图谱
本体建模是决定问答质量的上游工作,比写代码更值得花时间。很多新手拿到医疗数据的第一反应是“建表”,这是关系型数据库的惯性;图数据库里应该先问:领域里有哪些实体、实体之间是什么关系、每个实体需要保留哪些属性。我一般会在建模阶段用可视化工具把知识图谱草图先画出来,draw.io 或者专门的 ontology editor 都可以。vibecoding 风格的“本体建模编辑器 + 可视化知识图谱”思路也适用:建模不是写文档,而是把图摆出来让人能看懂,后面写 Cypher 时照着图翻译就行。
画图时我习惯把医疗实体收敛成六类,分类越少,后面数据清洗和问答模板越省事:
| 实体类型 | 医疗含义 | 典型属性 | Neo4j 标签 |
|---|---|---|---|
| 疾病 | 诊断结论 | 名称、ICD-10 编码 | Disease |
| 症状 | 患者主诉、体征 | 名称、发生部位 | Symptom |
| 药物 | 药品通用名 | 名称、剂型、妊娠分级 | Drug |
| 科室 | 挂号与诊疗单元 | 名称、楼层 | Department |
| 检查项 | 检查检验项目 | 名称、参考范围 | Examination |
| 手术 | 治疗操作 | 名称 | Operation |
实体定完就定关系。医疗图谱里最常用的是下面这些关系,名称统一用大写加下划线,避免查询时大小写不一致导致匹配失败:
| 关系 | 三元组示例 | 语义 |
|---|---|---|
| HAS_SYMPTOM | (Disease)-[:HAS_SYMPTOM]->(Symptom) | 疾病表现出某症状 |
| TREATS | (Drug)-[:TREATS]->(Disease) | 药物治疗某疾病 |
| CONTRA | (Drug)-[:CONTRA]->(Disease) | 药物对某疾病禁忌 |
| VISIT | (Disease)-[:VISIT]->(Department) | 疾病去某科室就诊 |
| EXAM | (Disease)-[:EXAM]->(Examination) | 疾病需要做的检查 |
属性要克制。只保留问答需要展示和查重需要的字段,比如疾病名称、ICD-10、药品通用名。属性加多了,导入时字段验错的机会也多,问答模板里还要做额外判断,得不偿失。
2.3 从诊疗现实到图模型:多跳查询为什么这么顺
医疗场景天然适合图模型,因为一次真实的就医决策往往是一条关系链。患者因为“头晕”就诊,医生考虑“高血压”,开“硝苯地平”,让患者去“心内科”随访,还建议做“血压测量”——这串信息在 Neo4j 里就是 5 个节点和 4 条边。用图表达后,问“高血压有什么症状”只需要一次短查询:
MATCH (d:Disease {name: '高血压'})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS symptom这条查询走的是 Disease 节点上的唯一约束定位到“高血压”,再沿着 HAS_SYMPTOM 边把相邻 Symptom 节点取出来。换成 SQL,先查症状表、再查疾病症状关联表、再按疾病名过滤,三张表 join 起步。图模型的优势在边多了以后更明显:患者问“高血压头晕该挂哪个科”,SQL 得把疾病、症状、科室三层关系全部 join 出来,而 Cypher 只要把 HAS_SYMPTOM 和 VISIT 两条边依次走出即可。这也是为什么医疗问答这类需求里,Neo4j 图数据库常被作为首选方案——答案的结构就藏在关系里,而不是靠模型去“猜”。
建模阶段最容易反复的是关系方向。我踩过的真实教训是:把 CONTRA 建成了 (Disease)-[:CONTRA]->(Drug),结果查“哪些药对痛风禁用”时语法正确但结果永远为空。方向定了之后不要轻易改,改了就要全库重查。关系方向验证有个笨办法:导入完数据后,对每条关系类型分别跑一次反向查询,比较结果数量,数量明显异常的那一条大概率方向建反了。
3. 知识图谱构建与数据入库:把医疗数据变成可查询的图
3.1 医疗数据清洗:从病历表、药品说明书到 Csv
Neo4j 官方推荐的数据导入方式是 CSV,所以知识图谱构建的前置工作其实是数据清洗。医疗数据常见来源有三类:电子病历系统导出的结构化表、药品说明书里的半结构化文本、ICD-10 或药典这类公开编码表。清洗原则只有一个:只保留建模时定义的字段和实体,别把原始表整个搬进来。比如原始病历表里有患者姓名、身份证号,这些不但涉及隐私,对问答也没价值,导入前直接删掉。
我做清洗时用 Python 脚本完成三件事:去掉诊断名里的修饰语、合并同义写法、剔除空行。诊断名里的“高血压(原发性)”和“原发性高血压”在图谱里必须是同一个节点,否则问答时匹配不上,所以先在清洗阶段统一:
import csv import re def clean_name(raw: str) -> str: # 去掉“高血压(原发性)”这类括号内修饰,保留规范诊断名 name = re.sub(r'[((].*?[))]', '', raw or '').strip() return name def dedup(rows, key_idx=0): # 按诊断名去重,合并到图里只保留一个节点 seen = set() for row in rows: k = row[key_idx].strip().lower() if k and k not in seen: seen.add(k) yield row # raw_diseases.csv 的结构:诊断名,ICD10,病历年份 with open('raw_diseases.csv', encoding='utf-8') as f: reader = csv.reader(f) header = next(reader) rows = [r for r in reader if r[0].strip()] clean_rows = [] for r in dedup(rows): clean_rows.append([clean_name(r[0]), r[1]]) with open('diseases.csv', 'w', newline='', encoding='utf-8') as f: w = csv.writer(f) w.writerow(['disease_name', 'icd10']) w.writerows(clean_rows)这里两个关键点:clean_name把括号里的修饰信息去掉,让同一疾病落到同一标准名;dedup用小写化后的名称做去重键,防止“高血压”和“高血压 ”因空格差异生成两个节点。写文件时编码必须用utf-8,Excel 默认另存的编码在 Neo4j 里会出现乱码,这一点后面避坑章节还会专门说。
药品和症状表按同样套路处理,只是字段不同。药品建议保留通用名、剂型、妊娠分级;症状保留名称和发生部位。所有 CSV 的列名最好用英文小写加下划线,因为 LOAD CSV WITH HEADERS 会把列名当作字段名,中文列名在后续 Cypher 里写起来很别扭。
3.2 批量写库:LOAD CSV 的字段映射、事务批次与去重
数据文件准备好后,放到 Neo4j 安装目录的import文件夹下,Cypher 里的file:///路径就指向这里。写入节点时我坚持用 MERGE 而不是 CREATE,原因很直接:CREATE 无脑插入,同一个疾病名会生成一堆重复节点,问答时MATCH返回列表里有多个同名结果,还得靠业务代码去重;MERGE 先查后插,配合唯一约束能保证图谱里每个疾病只有一个节点。
:auto USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM 'file:///diseases.csv' AS row MERGE (d:Disease {name: trim(row.disease_name)}) SET d.icd10 = row.icd10;USING PERIODIC COMMIT 500的含义是每处理 500 行提交一次事务,避免几万行数据在单个大事务里把内存打爆。实测中批次太小(比如 50)会让提交频率过高反而变慢,500 到 2000 之间通常是安全区间。LOAD CSV 只能处理相对扁平的字段,如果某列本身是数组(比如一个疾病的多个症状),最好提前在清洗脚本里拆成多行,而不是在 Cypher 里用 split 处理,后者出错更难排查。
关系导入是另一个独立步骤,核心方法是先匹配两端节点,再 MERGE 关系:
:auto USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM 'file:///symptom_relation.csv' AS row MATCH (d:Disease {name: trim(row.disease_name)}) MATCH (s:Symptom {name: trim(row.symptom_name)}) MERGE (d)-[:HAS_SYMPTOM]->(s);这段脚本有两个会踩坑的前提:第一,两端节点必须已经入库,否则 MATCH 为空,关系静默写入失败;第二,节点标签对应的属性上必须建了唯一约束,否则 MATCH 每次都对整个标签做全表扫描,几万条关系够你等一下午。我建议导入顺序严格按“先节点、后关系”执行,节点导入完先跑一遍计数确认数量,再导关系。关系去重用 MERGE 而不是 CREATE,反复执行同一份 CSV 不会产生重复边。
3.3 索引与唯一约束:把查询性能的底子打好
知识图谱构建完不能直接上线,索引和唯一约束是问答响应速度的第一道闸门。唯一约束不仅是数据质量的保证,它还隐式创建一个索引,让 MATCH 按名称定位节点时走索引而非扫描。问答场景里大多数查询都是“按名称找实体”,所以每个核心实体标签的名称属性都要建约束。
CREATE CONSTRAINT disease_name_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT drug_name_unique IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; CREATE INDEX disease_icd10_idx IF NOT EXISTS FOR (d:Disease) ON (d.icd10);注意这里的语法是 Neo4j 5.x 的写法,4.x 对应的是CREATE CONSTRAINT ON (d:Disease) ASSERT d.name IS UNIQUE,两者不要混用。很多人在线查资料时抄到旧语法,启动报语法错误,就是版本不匹配造成。约束和索引建成后,可以用SHOW INDEXES验证状态,或者直接在浏览器里执行一次带EXPLAIN的查询,看执行计划是否走了 NodeIndexSeek。如果走的是 NodeByLabelScan,说明约束没生效,问答时每条查询都要把整个 Disease 标签过一遍。
4. 问答智能体开发:从自然语言到 Cypher 再到答案
4.1 意图识别与实体抽取:把口语问句变成槽位
问答智能体开发的第一步是让机器从口语里抽出结构化槽位。这个阶段不需要一上来就上大模型,医疗问答里大量问句是高频模板,规则匹配能覆盖八九成场景,而且结果完全可控。我常用的做法是维护一个同义词表,把用户的自然说法映射到图谱里的标准实体名——比如“血压高”归一化成节点“高血压”,“心痛定”归一化成药品“硝苯地平”。
from neo4j import GraphDatabase # 同义词表:键是图谱里的标准名,值是用户可能的说法 SYNONYMS = { "高血压": ["血压高", "高血压", "hypertension"], "头晕": ["头晕", "头昏", "眩晕"], "硝苯地平": ["硝苯地平", "心痛定"], "心内科": ["心血管科", "心内科"], } def extract_entities(question: str): entities = {"disease": [], "drug": [], "symptom": [], "department": []} for std_name, alias in SYNONYMS.items(): if any(a in question for a in alias): if std_name in ("高血压", "糖尿病", "痛风"): entities["disease"].append(std_name) elif std_name in ("头晕", "头痛"): entities["symptom"].append(std_name) elif std_name == "硝苯地平": entities["drug"].append(std_name) else: entities["department"].append(std_name) return entities这段逻辑的要点是:先按同义词命中,再按实体类目放进对应槽位。顺序很重要,any()检查必须在前,因为用户问“高血压有什么症状”时,同一条问句里既命中了疾病名也命中了查询意图词。实体抽取后如果 disease 和 symptom 同时有值,问答系统应该把“高血压”当主实体、“头晕”当关联条件,而不是两个都作为匹配目标。实际开发中我会把同义词表单独放一个 JSON 文件,而不是写死在代码里,因为后续每周都要根据用户日志补新词。
4.2 模板化 Cypher 生成:按槽位拼查询,但别拼字符串
实体槽位拿到后,下一步是根据意图选择 Cypher 模板。我把常见医疗问答归纳成几类:问症状、问科室、问用药、问禁忌、问检查。每种意图对应一段模板 Cypher,槽位值通过参数传进去。这里最需要克制的一件事:不要用 Python 的 f-string 或字符串拼接把用户输入直接塞进 Cypher。
def build_query(question: str, entities: dict): # 问药:某药治疗什么病 if entities["drug"] and "治疗" in question: cypher = """ MATCH (d:Drug {name: $drug})-[:TREATS]->(dis:Disease) RETURN dis.name AS disease LIMIT 5 """ return cypher, {"drug": entities["drug"][0]} # 问科室:某病挂什么科 if entities["disease"] and ("挂什么科" in question or "哪个科" in question): cypher = """ MATCH (d:Disease {name: $disease})-[:VISIT]->(dep:Department) RETURN dep.name AS department LIMIT 3 """ return cypher, {"disease": entities["disease"][0]} return None, None这里用$drug、$disease参数而不是直接拼字符串有两个原因。第一是安全:用户输入里如果带了引号或特殊字符,拼接进 Cypher 要么让查询直接语法错误,要么改变查询语义,造成事实上的注入风险。第二是可读性和缓存:参数值变化但查询结构不变时,Neo4j 驱动层可以复用执行计划,性能更好。模板不匹配时返回None,由外层兜底逻辑处理,而不是硬猜一个查询去跑。
4.3 结果排序与兜底:多结果、空结果、实体落空
问答系统的最终体验往往由兜底逻辑决定,而不是由命中路径决定。常见情况有三种:一是大量结果需要排序展示,二是查询结果为空,三是实体抽取阶段一个槽位都没命中。我自己的处理顺序是:先确认有槽位,再执行查询,最后对空结果做模糊匹配。
def run_qa(driver, question: str): entities = extract_entities(question) cypher, params = build_query(question, entities) if cypher is None: # 槽位全空时,用模糊匹配尝试给出最接近的疾病实体 return suggest_similar(driver, question) with driver.session() as session: records = session.run(cypher, params).data() if not records: return f"图谱里暂时没有找到关于「{question}」的直接答案,换个问法试试" return records实体落空时的兜底用 apoc 文本距离函数,在疾病名称上做模糊匹配:
MATCH (d:Disease) WHERE d.name IS NOT NULL WITH d, apoc.text.levenshteinDistance(d.name, $kw) AS dist WHERE dist < 3 RETURN d.name AS name ORDER BY dist LIMIT 5;这个查询会返回拼写相近或说法接近的疾病名,比如用户输“高血牙”能匹配到“高血压”。dist < 3这个阈值不是万能的,中文场景里两个字内的差异通常可接受,英文药品名或化学名建议放宽到 5。apoc 需要单独安装,版本必须和 Neo4j 主版本一致,这个坑在下一章专门说。
多结果排序方面,我习惯在 Cypher 里直接ORDER BY某一属性,比如按疾病发病率或科室优先级排序,而不是取回 Python 再排。取回 Python 再排意味着所有结果都会经过网络传输,问答场景 Top5 就够用了,在 Cypher 里用LIMIT截断,代价小很多。
5. 医疗图谱问答避坑与排查:五个最容易翻车的位置
5.1 中文乱码:Csv 导入后节点名是乱码
现象:LOAD CSV 导入完成后,查询出来的节点名显示成“é«è¡å”之类的乱码,或者正常显示但字段名变成了“\ufeffdisease_name”。
原因:这个现象在 Windows 上尤其常见。Excel 另存的 Csv 默认是 GBK 编码,Neo4j 的 LOAD CSV 默认按 UTF-8 读取,编码不一致导致乱码;如果另存为选的是带 BOM 的 UTF-8,BOM 会被当作字段名的一部分,WITH HEADERS 解析时列名就会多一个不可见字符。
解决:在清洗阶段统一用 Python 写utf-8文件,注意不要带 BOM;如果手里只有 GBK 的 Csv,用iconv转码再导入。转码命令如下:
iconv -f GBK -t UTF-8 raw_diseases.csv > diseases.csv已经导进库里的乱码数据没有后悔药可吃,节点一旦创建,属性里的乱码需要逐条更新,不如直接删掉重导。所以导入前在 Neo4j 浏览器里先LOAD CSV ... RETURN count(*)看一眼解析结果,比导完再后悔成本低得多。
5.2 LOAD CSV 慢到怀疑人生:MERGE 没有唯一约束兜底
现象:几 MB 的 Csv,几千行数据,导入跑了半小时还没结束;观察执行计划发现每个 MERGE 都在扫描整个标签。
原因:MERGE 需要先“查”再“插”,这个“查”如果在没有唯一约束的情况下发生,就会对标签下所有节点做一次全遍历。数据量小时感知不到,几千行时立刻变成灾难。很多人以为 MERGE 自带索引加速,实际上它只保证语义,性能完全依赖约束是否存在。
解决:导入前先把所有节点的名称属性唯一约束建好,再执行 LOAD CSV。正确做法是先跑约束脚本,再跑节点导入,最后跑关系导入。有人用CREATE INDEX建普通索引代替唯一约束,这也能加速查询,但约束还能阻止重复节点,推荐两者都建。如果数据已经导入一半才发现没建约束,停下来删库重导通常比中途补救更快。
5.3 关系方向建反:查询结果静默为空
现象:问答系统上线后,某个问题类型返回结果永远为空,比如“查药品禁忌”只返回空白,但图谱可视化里明明能看到相关边。
原因:关系方向建反了。Cypher 的 MATCH 是严格区分方向的,(Drug)-[:CONTRA]->(Disease)和(Drug)<-[:CONTRA]-(Disease)查出来是两套完全不同的结果。建模阶段画图时方向是对的,写数据脚本时不小心写成反方向,查询时又没有显式写出箭头方向,于是静默返回空。最头疼的是这类错误不会报错,语法正确,只是结果为空,排错很难直接定位。
解决:每个关系类型入库后,用无方向的查询统计数量关系做一次冒烟测试:
MATCH (a)-[r:HAS_SYMPTOM]-(b) RETURN count(r)如果某条关系无方向统计的数量和预期一致,再分别跑两个方向的定向查询,比较结果数量分布。明显异常的那个方向就是建反了。修数据时不要写 UPDATE,直接删掉反边重建:
MATCH (d:Drug)-[r:CONTRA]->(dis:Disease) DELETE r5.4 Neo4j 启动失败:apoc 插件版本和数据库版本错配
现象:把 apoc jar 包放进 plugins 目录后重启 Neo4j,服务直接起不来,日志里报 “Could not load extension class” 或 “Unsupported version”。
原因:apoc 插件的主版本必须和 Neo4j 主版本严格对应。把 apoc 4.x 的 jar 装进 Neo4j 5.x,加载扩展时因为内部 API 版本不一致直接拒绝启动。这个坑特别隐蔽,因为 jar 文件名里通常只写了版本号,没有标明对应 Neo4j 版本,很多人只看了“apoc 最新版”就下载。
解决:去 apoc 的 release 页面找到和当前 Neo4j 主版本一致的版本,比如 Neo4j 4.4 配 apoc 4.4.x,下载完放进plugins目录重启,然后执行RETURN apoc.version()确认加载成功。这个验证步骤不要省,因为加载失败时有时只是告警,不是致命错误,服务照样起,但调用 apoc 函数时才会报 Function not found。
5.5 问答答非所问:问题出在词典而不是图谱
现象:用户问“血压高挂哪个科”答不出来,但改成“高血压挂哪个科”立刻能答;或者同一种病换个说法就完全命中不了。
原因:图谱里的实体名是标准诊断名,用户的自然说法是千奇百怪的口语。知识图谱本身没有错,错在同义词词典不够全。实体抽取阶段字典没命中“血压高”到“高血压”的映射,后续所有 Cypher 都无从执行。这类问题反馈到用户侧就是“机器人傻了”,但实际黑匣子里图谱毫发无损,纯粹是入口词典漏词。
解决:把未命中的用户问句全部落日志,每周导出一次,按高频词成批补充同义词表。比如日志里反复出现“挂哪个科室”“心血管科”,就补进 SYNONYMS 的科室别名。同义词表的扩充是无止境的,但边际收益递减,覆盖频率 TOP20 的说法后,问答准确率通常能稳定在八成以上。这也是我建议把同义词表外置成 JSON 文件的原因——改词典不用动代码,业务人员也能维护。
6. 进阶与验证:把答案量化评估,再让知识图谱长出来
6.1 用 50 条测试问句把问答准确率变成数字
问答系统上线前最怕的是“感觉还行”四个字。我的习惯是提前攒 50 条真实用户问句,每条标好预期答案,跑一遍全链路,把命中率变成可量化的数字。这 50 条不要求覆盖所有场景,但要覆盖四类高频问题:问科室、问症状、问药物禁忌、问检查项目。评测脚本很简单,但能让每次改词典、改 Cypher 模板的收益一目了然:
TEST_SET = [ {"question": "高血压挂什么科", "expect": "心内科"}, {"question": "硝苯地平治疗什么病", "expect": "高血压"}, {"question": "糖尿病患者有什么症状", "expect": "多饮"}, ] hit = 0 for case in TEST_SET: answer = run_qa(driver, case["question"]) if answer and case["expect"] in str(answer): hit += 1 print(f"QA 准确率: {hit / len(TEST_SET):.2%}")这套测试集要跟着词典一起迭代。每轮评测后把未命中的问题分两类看:一类是词典缺失,补 SYNONYMS;另一类是图谱里根本没有对应数据,那就需要回头补数据,而不是硬改查询模板。测试集本身也是团队的验收基线,新人接手时跑一遍就知道系统当前能力边界。
6.2 增量更新与同义词扩展,让图谱接得住新数据
知识图谱不是静态资产,医疗领域的药品说明书、临床指南每年都在更新。增量导入的常规做法是周期任务把新增的结构化数据清洗成 CSV,再走一遍 LOAD CSV 流程。唯一约束保证了重复导入不产生重复节点,所以增量导入脚本和全量导入脚本可以完全一样,这算图数据库相对关系型表的一个明显优势——不需要先判断是否存在的业务逻辑,MERGE 本身就是幂等操作。新药入图时,我会先在浏览器里执行一次单条 MERGE 验证字段,再批量跑全量脚本,避免字段错配污染整个标签。
图谱长大以后,同义词表也要跟着长。每周末花半小时看未命中日志,把高频说法补进 JSON 词典,这个动作比优化任何算法都见效快。我自己最深的教训是:第一次做医疗问答时把精力全花在调图结构上,上线后被用户问“血压高怎么看”才意识到,图谱建得再漂亮,词典缺一个词,问答就是哑巴。后来学乖了,先拿 20 条高频问题定测试集,再回头补建模和词典,整个节奏顺了很多。希望这个路径也能帮你在医疗知识图谱问答方向上少绕几圈。
本文还有配套的精品资源,点击获取