简介:面向医疗知识图谱入门、课程设计及小型医疗问答系统实践者的一套简易项目源码,围绕 Neo4j 图数据库组织疾病、症状、治疗方式等实体及其关联关系,数据采集自 ask120 医疗问答平台,可用于理解医疗信息从问答语料到图谱结构的转化过程。压缩包共 37 个文件,整体约 78KB,以 13 个 Python 脚本为核心,辅以 10 个 pyc 缓存、7 个 XML 配置、3 个 HTML 页面以及图片、JS 等资源,覆盖爬虫采集、Django 项目配置、数据处理与图谱操作等模块。已有 5463 人学习下载,具备一定参考热度。读者可据此梳理从问答数据爬取、清洗整理到节点与关系建模、导入 Neo4j 并用 Cypher 查询的完整链路,也能借助现有目录快速定位采集脚本、业务视图和配置入口,观察各模块之间的组织方式,适合作为医疗问答知识图谱的练手样例、课程实践参考与二次开发基础。
1. 从「一句话问症状」倒推:这套医疗问答图谱到底要交付什么
做医疗问答的毕设,最容易走的弯路是先接一个大模型,再回头补数据。我一般反过来:先用 Neo4j 把疾病、症状、药品、科室之间的关系固化成一张可查询的知识图谱,让每一句回答都能沿着一条关系路径追溯到来源。这套简易医疗问答知识图谱面向三类人:要交毕设或课设的学生、要给内部做导诊原型的开发者、要把问答准确率讲清楚的方案负责人。它不追求全科覆盖,只把常见病、症状词、常用药品和科室关系跑通,问答链路上能给出可解释的答案,也留好兜底。
2. 需求拆解与数据建模:五类节点、六种关系的属性图
图谱项目的成败,八成在建模阶段就已经决定了。很多同学上来就写 Cypher,写着写着发现「问症状查病」和「问病查药」要写两套完全不同的查询,回头改模型,前面导入的数据全废。我一般的做法是先把「用户会怎么问」列成清单,再从问题倒推节点和关系,模型定下来之后再动数据。这一章把实体划分、关系方向、主键与约束三件事讲透,后面所有代码都建立在这套模型上。
2.1 从问答类型倒推实体:为什么要这五类节点
先把真实问句摆出来看:「感冒发烧吃什么药」「高血压挂哪个科」「糖尿病会引起哪些并发症」「头痛可能是什么病」「吃了某药有什么禁忌」。这五类问句,其实只涉及五类节点:疾病(Disease)、症状(Symptom)、药品(Drug)、科室(Department)、检查(Check)。判断某个概念该不该做成独立节点,标准只有一条——它会不会被单独查询、被单独展示。科室如果只是疾病的一个属性,那「哪些病挂心内科」这种反向问题就得全表扫;做成节点之后,一条反向关系就解决了。
| 节点标签 | 业务主键 | 关键属性 | 教学场景量级 |
|---|---|---|---|
| Disease | disease_id | name、alias、icd_hint、summary | 200~500 |
| Symptom | symptom_id | name、alias、body_part | 300~800 |
| Drug | drug_id | name、alias、drug_class、usage_hint | 100~300 |
| Department | dept_id | name、alias | 20~40 |
| Check | check_id | name、alias、category | 50~150 |
关系一共定义六种,全部从「被描述的主体」指向「描述内容」:HAS_SYMPTOM、BELONGS_TO、RECOMMEND_DRUG、NEED_EXAM、HAS_COMPLICATION、CONTRA_INDICATION(药品指向疾病)。数量看起来少,但六种关系已经能组合出十几类问句,这就是属性图相对关系型表结构的优势——路径本身就是答案的解释。
注意:不要为了「看起来丰富」把医院、医生、费用这些字段塞进节点。毕设答辩时被问到「这个字段用来回答哪个问题」,答不上来就是减分项。
2.2 关系方向与属性:把「怎么问」提前写进图里
方向必须全图统一,否则查询会变成玄学。我定的规则是:主语在左,被描述对象在右。疾病有症状,就写成(d)-[:HAS_SYMPTOM]->(s);疾病属于科室,写成(d)-[:BELONGS_TO]->(dept)。反方向的问法不加反向边,而是在查询里把箭头反过来写,(dept)<-[:BELONGS_TO]-(d)。多加一条反向边省不了一行代码,却让维护成本翻倍,这是踩过的坑。
关系属性比节点属性更容易被忽略。HAS_SYMPTOM上至少要有两个属性:weight(0~1,表示这个症状对该病的典型程度,感冒对应「发热」的权重高,「胸痛」的权重低)和source(数据出处标识,答辩时用来证明数据不是拍脑袋编的)。RECOMMEND_DRUG上要加first_line(是否一线用药)和note(用药提示)。这些属性在答案排序阶段直接决定 Top1 命中率,没有它们,用户问「发烧可能是什么病」,返回顺序就只能靠运气。
还有一点值得说:并发症用疾病指向疾病的自环关系,是为了支持两跳查询。有了HAS_COMPLICATION,「高血压的并发症会引起哪些症状」就变成一条两跳路径,不需要在应用层做两次查询再拼装。多跳能力是选图数据库的核心理由,建模时就要把它用起来。
2.3 唯一 ID 与约束索引:建图前的第一道保险
主键不要用名称。同一种病有全称、简称、俗称,今天叫「上呼吸道感染」,明天数据里写「感冒」,用名称做主键,MERGE 会给你造出两个节点,而且是悄无声息地造。正确做法是给每类节点分配独立的业务 ID,名称只作为普通属性,把别名单独存成数组,专门服务于实体抽取。ID 生成规则简单点就行:前缀加六位序号,比如D000123、S000456,稳定、可读、便于对账。
// 唯一约束:MERGE 依赖它才能走索引,否则每插一行都是全图扫描 CREATE CONSTRAINT disease_id_uq IF NOT EXISTS FOR (d:Disease) REQUIRE d.disease_id IS UNIQUE; CREATE CONSTRAINT symptom_id_uq IF NOT EXISTS FOR (s:Symptom) REQUIRE s.symptom_id IS UNIQUE; CREATE CONSTRAINT drug_id_uq IF NOT EXISTS FOR (g:Drug) REQUIRE g.drug_id IS UNIQUE; // 普通索引:问答链路上按名称反查实体时使用 CREATE INDEX disease_name_idx IF NOT EXISTS FOR (d:Disease) ON (d.name); CREATE INDEX symptom_name_idx IF NOT EXISTS FOR (s:Symptom) ON (s.name);这几条语句要在导入数据之前执行,顺序不能颠倒。IF NOT EXISTS保证脚本可以重复执行而不报错,这一点在你要反复调数据的时候很关键。约束只对唯一性负责,不做业务校验;真正的数据质量校验要放在导入后的检查查询里,第三章会给出具体写法。名称索引不是可选项——实体抽取阶段如果没命中别名表,兜底逻辑会用名称做一次模糊匹配,没有索引这一步就是全图扫描,几百个节点还看不出来,上千个节点就会明显卡顿。
3. 图谱构建流水线:CSV 到 Neo4j 的四步导入
数据导入这一段,很多人写得像一次性脚本:跑通了就行,跑第二遍就出问题。真正交付的时候,数据一定会改——导师让你补二十种病,或者发现某个症状词写错了要全局替换。所以流水线必须设计成可重跑、可回滚、可校验的。这一章按环境、文件、导入、校验四步走,每一步都给能直接照着敲的命令和语句。
3.1 环境与依赖:最小可用的组合
数据库用 Neo4j 5 的社区版,单机足够支撑一个毕设甚至一个小型内部原型的量级。用容器起服务,把data和import两个目录挂出来,前者方便备份快照,后者是LOAD CSV唯一能读到的路径——这是新手最常忽略的限制,把 CSV 随便放在桌面然后写绝对路径,一定会报找不到文件。
# 单机社区版;import 目录必须挂载,LOAD CSV 只能读取该目录下的文件 docker run -d --name medkg \ -p 7474:7474 -p 7687:7687 \ -v $(pwd)/neo4j/data:/data \ -v $(pwd)/neo4j/import:/var/lib/neo4j/import \ -e NEO4J_AUTH=neo4j/MedKg2024 \ neo4j:5-community-p 7474是浏览器控制台端口,调 Cypher 的时候在网页里点一下比写脚本快得多;7687是 Bolt 协议端口,Python 驱动连这个。NEO4J_AUTH采用「用户名/密码」的格式,密码长度不够容器会起不来,这是排查启动失败时第一个要看的地方。Python 侧只装两个包:neo4j官方驱动和pandas,前者负责执行查询,后者负责数据清洗。不需要额外装 ORM,属性图没有对象映射的必要。
清洗环节做三件事:去空格、别名拆分、空值过滤。别名在 CSV 里用竖线分隔(感冒|伤风|上感),导入时用split()切成数组。这一步不要偷懒留在查询阶段做,字符串处理放在查询里,每查一次就多一次开销。
3.2 文件清单与字段规范:五张表定生死
CSV 的组织方式直接决定导入脚本的复杂度。我建议按「节点一张表、关系一张表」拆开,宁可文件多几个,也不要在一张宽表里塞几十列。下面这套文件清单可以直接照搬。
| 文件名 | 内容 | 必填字段 |
|---|---|---|
| disease.csv | 疾病节点 | disease_id, name, alias, summary |
| symptom.csv | 症状节点 | symptom_id, name, alias, body_part |
| drug.csv | 药品节点 | drug_id, name, alias, drug_class |
| dept.csv | 科室节点 | dept_id, name, alias |
| rel_disease_symptom.csv | 疾病-症状 | disease_id, symptom_id, weight, source |
| rel_disease_drug.csv | 疾病-药品 | disease_id, drug_id, first_line, note |
| rel_disease_dept.csv | 疾病-科室 | disease_id, dept_id |
| rel_drug_contra.csv | 药品禁忌 | drug_id, disease_id, level |
字段命名统一用小写下划线,不要混用驼峰,否则 Cypher 里一会儿diseaseId一会儿disease_id,写错一个字母就是空结果,而且不报错。weight列必须全部是 0~1 之间的小数,导入前用 pandas 做一次范围检查,超范围直接丢弃并打印行号——脏数据混进图里,后面排序出问题很难定位。
3.3 LOAD CSV 批量导入:节点先上,关系后连
导入顺序是固定的:先建约束,再导节点,最后导关系。关系行里的两端 ID 如果找不到对应节点,MATCH会返回空,整行被静默丢弃,所以节点必须先落库。
// 症状节点导入:先 MERGE 主键保证幂等,再 SET 覆盖属性 LOAD CSV WITH HEADERS FROM 'file:///symptom.csv' AS row WITH row WHERE row.symptom_id IS NOT NULL AND trim(row.name) <> '' MERGE (s:Symptom {symptom_id: trim(row.symptom_id)}) SET s.name = trim(row.name), s.alias = split(coalesce(row.alias, ''), '|'), // 别名数组,供实体抽取使用 s.body_part = trim(coalesce(row.body_part, '未知')) RETURN count(s) AS symptom_count;MERGE只匹配主键,SET负责更新其余属性,这个组合让脚本跑一百遍结果都一样,这就是幂等。coalesce(row.alias, '')处理空列,避免split(null)直接报错。WHERE里过滤掉空名称,是把数据质量问题的处理前移到入库阶段——宁可少几个节点,也别让「空字符串症状」进图。最后一行的RETURN count()是给自己看的验收数字,和 CSV 行数对不上就说明有行被过滤了。
关系导入稍微复杂一点,因为要做两次MATCH:
// 疾病-症状关系:两端都要能 MATCH 到节点,否则整行丢弃 LOAD CSV WITH HEADERS FROM 'file:///rel_disease_symptom.csv' AS row MATCH (d:Disease {disease_id: trim(row.disease_id)}) MATCH (s:Symptom {symptom_id: trim(row.symptom_id)}) MERGE (d)-[r:HAS_SYMPTOM]->(s) SET r.weight = toFloat(row.weight), // 0~1,症状对该病的典型程度 r.source = coalesce(row.source, 'manual') RETURN count(r) AS rel_count;toFloat()不能省,CSV 读进来全是字符串,不做类型转换的话后续sum(r.weight)会直接报类型错误。rel_count的输出要和 CSV 行数比对,差额就是没匹配上的关系——通常是对端节点的 CSV 漏了某一行,或者 ID 前后有空格。数据量上万以后,可以在语句前面加CALL {} IN TRANSACTIONS OF 2000 ROWS分批提交,避免单个大事务把内存吃满;几百到几千行的教学量级不需要,加了反而让调试变麻烦。
3.4 导入后的校验:三条查询判断图有没有建歪
导入完成不等于建图完成。每次改完数据,我都会跑三组检查,加起来不到十秒,却能省掉几个小时的排查。
// 1. 孤立节点:没有任何关系的节点,通常是关系表漏了数据 MATCH (n) WHERE NOT (n)--() AND NOT n:Disease RETURN labels(n)[0] AS label, count(*) AS orphan_cnt ORDER BY orphan_cnt DESC; // 2. 关系总数分布:和预期对照,某一类明显偏少就是导入被静默丢弃 MATCH ()-[r]->() RETURN type(r) AS rel_type, count(r) AS cnt ORDER BY cnt DESC; // 3. 抽样路径:随便挑一个常见病,看看两跳能不能走通 MATCH p = (d:Disease {name:'高血压'})-[:HAS_COMPLICATION]->(:Disease)-[:HAS_SYMPTOM]->(:Symptom) RETURN p LIMIT 5;第一条里排除了Disease标签,是因为疾病节点允许暂时没有关系(数据还在补),但症状、药品孤立基本可以断定是关系表的问题。第三条抽样路径我建议固定挑三个高频病,每次导入后都跑一遍,路径断在哪一跳,问题就在哪张关系表。这个习惯看起来笨,但它能把「查询返回空」这种最容易让人怀疑人生的现象,定位到具体某一行 CSV 上。
4. 问答链路实现:意图分类、Cypher 模板与答案组装
图建好了,接下来要让自然语言接上去。这一层不需要上大模型,规则加模板足够撑起一个毕设,而且好处是每一步都可解释——答辩时老师问「为什么返回这个答案」,你能从问句一路讲到 Cypher。整条链路四步:判意图、抽实体、填模板、装答案。
4.1 意图划分:把自然语言压到七类模板
意图不宜多,七类足够覆盖绝大多数问法,而且每一类都能对应一到两条 Cypher。这七类是:症状查病、病查症状、病查药、病查科室、病查检查、病查并发症、药查禁忌。判定方式优先用关键词规则,比如问句里出现「吃什么药」「用什么药」就归到病查药,出现「挂什么科」「看哪个科」归到病查科室,出现「并发症」「引起」归到病查并发症。
规则匹配的准确率在垂直领域其实不低,因为医疗问句的表达方式高度收敛。剩下的长尾用兜底策略处理:抽取到疾病名但意图不明,就返回该疾病的概况卡片,把症状、科室、常用药一次性列出来。这个兜底在演示环节特别好用——老师随便问一句没准备过的话,你至少能给出一个结构化的回答,而不是一句「暂不支持」。
| 意图 | 触发关键词示例 | 主要 Cypher 模板 |
|---|---|---|
| 症状查病 | 可能是什么病、什么原因 | disease_by_symptom |
| 病查症状 | 有什么症状、表现 | symptom_by_disease |
| 病查药 | 吃什么药、怎么治 | drug_by_disease |
| 病查科室 | 挂什么科、看哪科 | dept_by_disease |
| 病查检查 | 做什么检查、怎么确诊 | exam_by_disease |
| 病查并发症 | 并发症、会引起 | complication_by_disease |
| 药查禁忌 | 禁忌、不能吃 | contra_by_drug |
4.2 实体抽取:词典加最大正向匹配
医疗领域的实体抽取,用别名词典做最大正向匹配,性价比远高于上模型。原因很实在:症状词大多是两到六个字的固定短语,词典覆盖得住;而通用分词会把「心慌胸闷」切成两个词,反而丢了原有的语义边界。所以我一般把别名表整理成 JSON,标准名映射到别名数组,启动时反转成「别名 → 标准名」的哈希表。
import json def load_alias(path="alias.json"): """把一个标准名对应多个别名的字典,反转成 别名->标准名 的查找表""" with open(path, encoding="utf-8") as f: raw = json.load(f) # {"高血压": ["血压高", "原发性高血压"], ...} table = {} for standard, aliases in raw.items(): table[standard] = standard # 标准名本身也要能被匹配到 for a in aliases: table[a] = standard return table def extract(text, alias_table, max_len=8): """最大正向匹配:每个位置尽量吃最长的词,命中后跳过已消费长度""" found, i = [], 0 while i < len(text): for size in range(min(max_len, len(text) - i), 1, -1): word = text[i:i + size] if word in alias_table: std = alias_table[word] if std not in found: found.append(std) i += size - 1 # 已匹配部分不再参与下一轮 break i += 1 return foundmax_len=8是按最长症状词定的,词表里有更长的就调大,但不要超过 12,否则每个位置要试十几次,长句会明显变慢。found去重是必要的,同一句话里「发烧」出现两次,返回结果里不该有两个相同实体。匹配不到实体时返回空列表,调用方据此决定走兜底还是让用户补充信息——这个返回值约定比抛异常好用,前端能直接拿去做提示。
词典的来源就是第二章建好的节点属性,启动时从 Neo4j 里MATCH (n:Symptom) RETURN n.name, n.alias一次性拉出来构建,不用单独维护一份文件。数据改了,词典自动跟着变,这是图数据库顺带带来的好处。
4.3 Cypher 模板库:参数一律走占位符
模板的核心原则只有一条:结构写死在模板里,实体参数全部用$占位符绑定。不要用字符串拼接把实体名塞进查询,一方面是注入风险,另一方面是 Neo4j 无法复用已缓存的执行计划,每次都要重新编译。症状查病这个模板是使用频率最高的,值得单独讲。
TEMPLATES = { # 症状查病:按命中症状数和典型度打分排序 "disease_by_symptom": """ MATCH (d:Disease)-[r:HAS_SYMPTOM]->(s:Symptom) WHERE s.name IN $symptoms WITH d, count(s) AS hit, sum(r.weight) AS score WHERE hit >= $min_hit RETURN d.name AS disease, d.summary AS summary, hit, score ORDER BY score DESC, hit DESC LIMIT $top_k """, # 病查药:一线用药优先展示 "drug_by_disease": """ MATCH (d:Disease {name: $disease})-[r:RECOMMEND_DRUG]->(g:Drug) RETURN g.name AS drug, g.drug_class AS class, r.note AS note ORDER BY r.first_line DESC, g.name LIMIT $top_k """, }min_hit是命中症状数下限,默认定 1,症状多的时候可以调到 2,宁可少返回也不要给出十个不相干的病。top_k控制返回条数,5 条在演示时刚好一屏。score是加权求和,权重来自第二章建关系时写入的weight,这就是当时强调「关系属性不能省」的兑现点——没有权重,排序只能退回按命中数,而命中数会把「症状描述越详细」的疾病顶到前面,明显不合理。ORDER BY score DESC, hit DESC做二级排序,是为了在得分相同时让命中更多的疾病排前面,避免结果不稳定。
模板库的规模控制在每条意图一到两条,够用就行。模板太多,维护和调试成本陡增,而且很多所谓的「不同问法」在意图分类那一步就已经归一了。
4.4 答案组装与兜底:置信度低于阈值时说什么
查到数据只是第一步,怎么组织成人话,直接决定用户觉得这个系统「聪明」还是「呆」。我的组装规则是:Top1 直接给结论加解释,Top2 到 Top5 作为候选列表,每条附上支撑理由(命中了哪几个症状、属于哪个科室)。理由比结论更重要——用户看到「因为你提到了发热和咳嗽,所以可能是上呼吸道感染」,信任度立刻不一样。
兜底分三种情况,每种给不同的话术。第一种是实体抽到了但图里没数据,回复「图谱里暂未收录该条目,建议线下就医确认」,并提示可以换个说法;第二种是实体没抽到,回复引导句,列出几个示例问法;第三种是查到了但得分低于阈值,这种情况不要硬答,改成「以下结果仅供参考,请以医生诊断为准」并展示低分候选。第三种最容易被忽略,而它恰恰是避免「一本正经胡说」的关键防线。
def build_answer(intent, rows, threshold=0.6): """组装答案:高分给结论,低分降级为参考,空结果走引导""" if not rows: return {"code": "NO_MATCH", "text": "图谱里暂未收录对应条目,建议结合线下就医确认。"} top = rows[0] conf = min(top["score"] / 2.0, 1.0) # 归一化,2.0 为经验权重上限 if conf < threshold: return {"code": "LOW_CONF", "text": "以下结果仅供参考,请以医生诊断为准。", "items": rows[:5]} return {"code": "OK", "text": f"可能相关的是:{top['disease']}", "items": rows[:5]}threshold定 0.6 是个折中值,调高会让很多正常问题掉进「仅供参考」,调低又会把明显不相关的结果当成结论。这个数没有理论最优,是拿测试集一条条试出来的,第 6 章的验收方法会讲怎么定。conf的归一化用固定分母,看起来粗糙,但比动态归一好在结果稳定——同一句话问两次,得分一样,不会出现演示时前后不一致的尴尬。
4.5 接口层:一个路由把链路串起来
接口层不需要复杂框架,Flask 一个路由就够,返回结构固定成code / text / items三个字段,前端照着渲染即可。
from flask import Flask, request, jsonify from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "MedKg2024")) @app.route("/qa", methods=["POST"]) def qa(): q = (request.json or {}).get("q", "").strip() intent = classify(q) # 关键词规则判意图 ents = extract(q, ALIAS_TABLE) # 词典抽实体 tpl = TEMPLATES.get(intent) if not ents or not tpl: return jsonify({"code": "NEED_MORE", "text": "请补充症状或疾病名称。"}) params = {"disease": ents[0], "symptoms": ents, "min_hit": 1, "top_k": 5} with driver.session() as s: rows = [r.data() for r in s.run(tpl, **params)] return jsonify(build_answer(intent, rows))params一次性把可能用到的键都塞进去,Cypher 里没用到的参数会被忽略,不会报错,这样同一份参数可以喂给所有模板,省掉按意图分别构造的麻烦。driver在模块级初始化,不要每个请求都新建连接,这是压测时最常见的性能瓶颈来源。返回统一走jsonify,code字段让前端能区分「无结果」和「低置信度」,做不同的 UI 提示。
5. 避坑与排查:五个最容易翻车的点
这一章单独拿出来,是因为下面五个问题几乎每个做医疗图谱的人都遇到过,而且症状都很像——「查询返回空」「结果不对」「跑得慢」。现象相似,原因完全不同,按顺序排一遍能省掉大量时间。
5.1 症状同义词没归一,召回率直接腰斩
现象:用户问「一直低烧怎么回事」返回空结果,换成「发热」就正常。原因不在查询,在数据入口:CSV 里写的是「发热」,用户的说法是「低烧」,别名词典里没收录这个词,实体抽取直接空手而归,后面的 Cypher 根本没执行。解决办法是把同义词归一当成一项独立工作来做,从节点属性里拉出标准名,人工补一批常见口语说法,覆盖「低烧/发烧/体温高」「拉肚子/腹泻」「喘不上气/呼吸困难」这类高频变体。补完之后用 50 条真实问句回归一遍,召回率通常能从百分之五六十抬到八十五以上。词表这件事没有终点,但前 200 个别名能覆盖掉绝大部分场景。
5.2 关系方向搞反,问「吃什么药」返回空
现象:病查药的模板写好了,参数也传对了,就是返回零行。原因多半是导入时箭头写反了,(g:Drug)-[:RECOMMEND_DRUG]->(d:Disease),查询写的是(d)-[r:RECOMMEND_DRUG]->(g),方向不符自然匹配不上。这类问题最难查的地方在于它不报错,只是安静地返回空集。排查手段是先不带方向查一次:MATCH (a)-[r:RECOMMEND_DRUG]->(b) RETURN labels(a), labels(b) LIMIT 5,看一眼实际方向。根治办法是建模阶段把方向约定写进文档,所有关系统一「主体指向描述对象」,不要凭感觉写。
5.3 LOAD CSV 重复执行,图谱膨胀成三份
现象:改了几次数据,节点数变成了预期值的三倍,症状查病返回一堆重复条目。原因是节点导入用了CREATE而不是MERGE,或者虽然用了MERGE但约束没建,导致每次执行都新建一批。解决分两步:先把约束补上(第 2.3 节的语句),再清理重复数据——按业务主键分组,保留id()最小的那个,其余DETACH DELETE。清理完重新导一遍验证总数。血泪经验是:数据脚本一定要能在干净库上一次跑通,第二次跑完结果不变,这个性质要在写完当天就验证,别等到答辩前一天才发现。
5.4 全图扫描拖垮响应,PROFILE 一看就明白
现象:数据从几百涨到几千,问答开始卡顿,某些问题要等两三秒。原因通常是MATCH (n)起手、没有索引支撑的名称匹配,或者可选匹配写太多导致笛卡尔积。排查工具是PROFILE,把慢查询前面加上它执行一次,看执行计划里有没有AllNodesScan。只要出现这个词,就说明缺索引或者WHERE的写法让索引失效了——比如WHERE toLower(d.name) = $name会让名称索引用不上,改成在导入时就把名称统一存成小写,查询直接等值匹配。给所有高频查询字段建索引之后,同样的查询通常能压到几十毫秒。
5.5 答案没有置信度,系统会一本正经地胡说
现象:用户问一个图谱里完全没有的病,系统仍然返回了三个疾病,看着还挺像回事。原因是查询用了模糊匹配又没做结果校验,只要有行就当成答案。这种问题在演示时最致命,因为老师很容易随手问一个偏门病来试。解决办法就是 4.4 节那套兜底逻辑:空结果给引导,低分给免责提示,只有得分过阈值才给结论。判断阈值合不合理,不要凭感觉,拿测试集跑一遍,看有多少正常问题被误判成低置信度——误判率超过百分之十,说明阈值调高了。
6. 验收与答辩:用一张测试集把「能用」量化出来
图谱类项目最怕答辩时被问「你这个准确率多少」,然后只能回答「感觉还行」。破解办法很朴素:在开发早期就手工标 100 条问题,覆盖七类意图,每条标上期望命中的疾病或药品。这份测试集是整个项目的度量尺,改完数据、调完阈值都跑一遍,指标掉了立刻能发现是哪一步退化的。指标不用多,六个就够。
| 指标 | 计算方式 | 目标值 |
|---|---|---|
| 意图分类准确率 | 100 条标注问句中意图判对的比例 | ≥ 90% |
| 实体抽取 F1 | 抽取出的实体与标注对齐后的调和平均 | ≥ 0.85 |
| Top1 命中率 | 首条答案包含正确疾病或药品 | ≥ 70% |
| Top3 命中率 | 前三条包含正确答案 | ≥ 88% |
| 平均响应时间 | 单次问答端到端耗时 | ≤ 300 ms |
| 图谱覆盖率 | 测试问题在图谱内有答案的比例 | ≥ 80% |
跑这套指标写个脚本就行,几十行 Python 循环调用接口、比对结果、输出表格,不要手工点。有一个细节值得注意:Top1 命中率通常比 Top3 低不少,这不丢人,反而可以在答辩时主动讲——「首条命中七成,前三条接近九成,说明排序还有优化空间,主要卡在权重数据的覆盖度上」。主动指出短板并给出改进路径,比硬撑一个漂亮数字可信得多,老师一般也就顺着这个话题往下问,主动权在你手里。
演示脚本建议提前写死,三分钟讲完:第一分钟问「发烧咳嗽可能是什么病」,展示多候选和支撑理由;第二分钟问「高血压挂什么科」和「糖尿病会引起哪些并发症」,展示一跳和两跳的区别,这里一定要点出「两跳是图数据库相对关系表的核心优势」;第三分钟故意问一个图谱里没有的病,展示兜底话术,说明系统不会硬答。三个场景刚好覆盖正常链路、图特性、异常处理,比漫无目的地演示十分钟有效。
从那以后我每次带这类项目,都会在第一周就把测试集建起来,哪怕只有三十条,也强制走一遍指标脚本再改代码。没有度量的时候,所有优化都是凭感觉,改到最后自己都不知道是变好还是变坏。这套东西从数据模型到问答接口都是可以整份拿去改的,把疾病和症状换成你自己的领域词,链路一行不用动,希望帮到你。
本文还有配套的精品资源,点击获取