基于Neo4j的医疗问答知识图谱实战:建模、Cypher与问答链路
简介:围绕医疗问答场景构建的知识图谱实践项目,面向希望掌握图数据库建模与医疗信息检索的开发者及学生。它从医疗问答平台采集问题、答案、疾病、症状与治疗信息,经清洗整理后以节点和关系的形式存入 Neo4j,并借助 Cypher 完成关联查询,可用于理解实体抽取、关系定义到图谱应用的完整链路,也适合作为课程设计或入门实战参考。压缩包共 37 个文件,约 78KB,以 Python 源码与编译缓存为主,辅以 Django 项目的 XML 配置、HTML 页面及少量前端脚本,另含一个图片资源,整体呈现出一个轻量级 Web 问答系统的目录结构。已有 5463 人学习下载,说明其在医疗知识图谱入门方向具有一定参考价值。读者可据此了解数据采集、预处理、图模型设计、数据导入与查询呈现的基本流程,并观察爬虫脚本、数据库操作与页面展示之间的协作方式,为自行搭建小型知识图谱应用提供可借鉴的组织思路与实现骨架。
1. 从一个“挂号挂哪个科”说起:这套医疗问答图谱能解决什么
前几天有人问,他在搜索框里敲“最近老头晕还恶心想吐,该挂什么科”,翻了七八页没找到能直接用的答案。这种问题难就难在它不像“感冒吃什么药”那样有一句标准问句可以比对,它是一句大白话,里面塞了两个症状,还藏着一个“问科室”的意图。基于 Neo4j 的简易医疗问答知识图谱,做的就是把这句大白话处理成一次查询:把症状、疾病、科室、药品、检查项都写进图数据库,让用户的一句话先落到具体实体上,再顺着关系走两步,把答案取出来。它适合想入门知识图谱、又不想一上来就啃论文的后端开发者,也适合拿它当课程设计或毕设骨架的人,整条链路短、代码量可控,跑通之后每一层都拆得开、改得动。
2. 技术选型与整体架构:Neo4j 怎么把“关系”变成查询优势
2.1 为什么用图数据库,而不是三张 MySQL 表
先说结论:这个项目如果把 Neo4j 换成 MySQL,功能一个都不会少,但写起来会变得很别扭,而且改动一次就要重新考虑一次表结构。
医疗数据的特点是关系比实体更重要。一个症状对应几十种疾病,一种疾病又有十几个症状,这是典型的多对多;一种疾病可能并发另一种疾病,这是自反关系;疾病下面挂科室、挂药品、挂检查项,是三种不同语义的一对多。在关系型数据库里,这些都会被拆成独立的关联表,字段只有两列:左 id、右 id。你查一次“同时有两个症状的疾病,按命中的症状数量排序”,SQL 大致长这样:
-- 关系库里的两跳查询,症状 A 和症状 B 同时命中的疾病 SELECT d.name, COUNT(*) AS hit FROM disease d JOIN disease_symptom ds1 ON ds1.disease_id = d.id JOIN symptom s1 ON s1.id = ds1.symptom_id AND s1.name = '头晕' JOIN disease_symptom ds2 ON ds2.disease_id = d.id JOIN symptom s2 ON s2.id = ds2.symptom_id AND s2.name = '恶心' GROUP BY d.id, d.name ORDER BY hit DESC LIMIT 10;每多一个症状就多两组 JOIN,写到第四个症状时语句已经不太能读了。更麻烦的是“疾病 → 并发症 → 并发症的常见症状”这种两跳查询,SQL 得靠递归 CTE,而 Cypher 里就是一个变长路径:
// 同一件事,在图库里的写法 MATCH (s:Symptom) WHERE s.name IN ['头晕', '恶心'] MATCH (s)<-[:HAS_SYMPTOM]-(d:Disease) RETURN d.name AS disease, count(s) AS hit ORDER BY hit DESC LIMIT 10; // 两跳:某疾病的并发症还有哪些常见症状 MATCH (d:Disease {name: '高血压'})-[:ACOMPANY_WITH*1..2]-(other:Disease) MATCH (other)-[:HAS_SYMPTOM]->(s:Symptom) RETURN other.name, collect(s.name)[0..5] AS symptoms;这两段代码的差别不只是长度。图库里“关系”是真实存在的存储单位,顺着指针走就行,不需要在内存里做哈希连接;路径长度在模式里直接写死,*1..2表示走一到两步。血泪经验是,一旦需求里出现“再多走一层”这种话,关系库就要动 SQL 甚至动表结构,图库只要把*1..2改成*1..3。这就是这个项目选 Neo4j 最实在的理由。
当然图数据库不是没有代价。它不擅长做聚合报表,也不适合存那种需要强事务、频繁更新的业务流水。这个项目的数据是相对静态的医学常识,一次导入、长期只读,正好避开图库的短板。
2.2 四层结构:一次问答从进入到返回经过什么
整个项目我习惯拆成四层,从下往上分别是数据准备层、图谱存储层、问答推理层和接口层。这么拆的好处是每一层都能单独跑、单独验证,出问题时能快速定位是哪一层的问题,而不是对着一个“答非所问”的界面瞎猜。
目录结构上大致是这样:
medical-kg/ ├── data/ # 数据准备层:原始 CSV,与代码完全解耦 │ ├── disease.csv # 疾病:名称、简介、病因、预防、治疗周期 │ ├── symptom.csv # 症状:名称、所属部位 │ ├── drug.csv # 药品:名称、通用名、厂商 │ ├── department.csv # 科室:名称 │ ├── check.csv # 检查项:名称 │ └── relation/ # 关系表:全部是两列,左实体右实体 │ ├── disease_symptom.csv │ ├── disease_drug.csv │ ├── disease_department.csv │ └── disease_check.csv ├── build/ # 图谱存储层:只在初始化时跑一次 │ ├── schema.cypher # 约束与索引 │ ├── load.cypher # LOAD CSV 批量导入 │ └── validate.cypher # 导入后的质量校验 ├── qa/ # 问答推理层:纯 Python,不依赖 Web 框架 │ ├── dictionary.py # 实体词典与最大匹配 │ ├── intent.py # 意图分类 │ ├── template.py # 意图到 Cypher 模板的映射 │ ├── query.py # 执行查询、统一返回结构 │ └── answer.py # 结果拼装成自然语言 ├── web/ # 接口层:一个最小的 HTTP 入口 │ └── app.py ├── config.py # 连接串、阈值、LIMIT 都收在这里 └── requirements.txt数据层和代码层彻底分开这一点很关键。很多同类项目把医学词条硬编码在 Python 字典里,想加十个病种就得改代码、重新发版;这里新增数据只是往 CSV 里加行,重跑一遍导入脚本即可。问答推理层不依赖 Web 框架也是个有意为之的约束,它意味着你可以在命令行里直接调qa.query.ask("头晕恶心挂什么科")来调试,不用起服务,这在排查词表和模板问题时省下大量时间。
2.3 数据模型:六类节点和七种关系
模型设计是整个项目的基础,一旦把节点类型定歪了,后面所有 Cypher 都要跟着改。这个项目里的六类节点和关系定义如下:
| 节点类型 | 业务主键 | 主要属性 | 说明 |
|---|---|---|---|
| Disease | name | desc、cause、prevent、cure_way、easy_get | 中心节点,几乎所有关系都从它出发 |
| Symptom | name | body_part | 症状,注意“头晕”和“眩晕”要分开建 |
| Drug | name | generic_name、manufacturer | 药品,通用名单独存一个字段方便归一 |
| Department | name | —— | 科室,数量少但作用大,是分诊类问答的落点 |
| Check | name | —— | 检查项,如血常规、CT |
| Food | name | —— | 宜吃、忌吃,做饮食建议类问答用 |
| 关系类型 | 起点 → 终点 | 语义 |
|---|---|---|
| HAS_SYMPTOM | Disease → Symptom | 该疾病有哪些症状 |
| RECOMMAND_DRUG | Disease → Drug | 常用药 |
| BELONGS_TO | Disease → Department | 就诊科室 |
| NEED_CHECK | Disease → Check | 需要做的检查 |
| DO_EAT / NO_EAT | Disease → Food | 宜吃 / 忌吃 |
| ACOMPANY_WITH | Disease → Disease | 并发症,自反关系 |
关系方向一律从 Disease 指向其他实体,这一点要严格守住。理由很实际:如果一会儿(Disease)-[:HAS_SYMPTOM]->(Symptom),一会儿又写成(Symptom)-[:HAS_SYMPTOM]->(Disease),你的 Cypher 模板就得为每一种方向单独写一遍,模板数量会迅速翻倍,而且写错方向时查询返回空集却不报错,排查起来特别折磨人。统一方向之后,问症状时就反向匹配(s)<-[:HAS_SYMPTOM]-(d),单向关系也能双向查,代价只是多敲两个字符。
提示:
ACOMPANY_WITH这类自反关系建议只存一份,不要 A→B 和 B→A 各存一条。查询时用无向匹配-[:ACOMPANY_WITH]-,既省一半存储,也避免统计时重复计数。
3. 数据建模与入库:从 CSV 三元组到可查的图
3.1 把三元组整理成两张表:实体表和关系表
入库之前先要解决一个常见问题:医学词条里同一个东西有多个叫法。“二型糖尿病”“2型糖尿病”“Ⅱ型糖尿病”在文本里都出现过,如果原样导入,图里就会长出三个互不相连的节点,用户问其中一个,另外两个永远查不出来。这个项目采用的做法是在 CSV 阶段就做一次归一,也就是在实体表里额外加一列别名,把同义词挂在同一个节点上,而不是建多个节点。
疾病表的字段设计如下:
name,alias,desc,cause,prevent,cure_way,easy_get 2型糖尿病,二型糖尿病|Ⅱ型糖尿病,一种以高血糖为特征的代谢性疾病,...,...,...,中老年肥胖人群alias用竖线分隔多个别名。导入时把别名作为一个数组属性挂在节点上,问答阶段做实体识别时,别名和主名一起进词典,命中任意一个都能落到同一个节点。这一步看起来不起眼,但它决定了后面问答的召回率,很多 Demo 答不出来不是查询写错了,而是用户说的那个词根本没进图。
关系表就简单得多,统一两列:
# relation/disease_symptom.csv disease,symptom 2型糖尿病,多饮 2型糖尿病,多尿 2型糖尿病,体重下降 高血压,头晕 高血压,头痛四张关系表格式完全一致,这也意味着导入脚本可以写成一个函数,通过参数传表名、起点标签、终点标签和关系类型,不用为每张表复制粘贴一遍。
3.2 先建约束再导数据,顺序不能反
导入脚本分两个文件,先跑schema.cypher,再跑load.cypher。这个顺序反过来的话,你会得到一张满是重复节点的图,而且事后清理非常麻烦。
// build/schema.cypher // 唯一约束:既是去重手段,也是 MERGE 走索引的前提 CREATE CONSTRAINT disease_name IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT symptom_name IF NOT EXISTS FOR (s:Symptom) REQUIRE s.name IS UNIQUE; CREATE CONSTRAINT drug_name IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT department_name IF NOT EXISTS FOR (d:Department) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT check_name IF NOT EXISTS FOR (c:Check) REQUIRE c.name IS UNIQUE; CREATE CONSTRAINT food_name IF NOT EXISTS FOR (f:Food) REQUIRE f.name IS UNIQUE; // 给科室名建普通索引,分诊类查询会频繁按名字反查 CREATE INDEX disease_desc IF NOT EXISTS FOR (d:Disease) ON (d.desc);IF NOT EXISTS在 Neo4j 4.x 和 5.x 上都支持,重复执行不会报错,这一点对初始化脚本很重要——你得允许自己反复跑。约束建好之后,MERGE才会走索引,否则每次 MERGE 都是全表扫描,几万条关系导进去要等很久,中途还可能因为内存不够失败。
导入部分:
// build/load.cypher // 1) 先导实体,再导关系。实体必须先存在,否则关系两端会 MERGE 出空节点 LOAD CSV WITH HEADERS FROM 'file:///disease.csv' AS row CALL { WITH row MERGE (d:Disease {name: trim(row.name)}) SET d.desc = trim(row.desc), d.cause = trim(row.cause), d.prevent = trim(row.prevent), d.cure_way = trim(row.cure_way), d.easy_get = trim(row.easy_get), // 别名拆成数组,方便实体识别时统一取用 d.alias = split(coalesce(row.alias, ''), '|') } IN TRANSACTIONS OF 5000 ROWS; // 2) 导关系:两端都用 MERGE,保证不会重复建边 LOAD CSV WITH HEADERS FROM 'file:///relation/disease_symptom.csv' AS row CALL { WITH row MATCH (d:Disease {name: trim(row.disease)}) MATCH (s:Symptom {name: trim(row.symptom)}) MERGE (d)-[:HAS_SYMPTOM]->(s) } IN TRANSACTIONS OF 5000 ROWS;几个参数值得说明。file:///指向的是 Neo4j 安装目录下的import文件夹,不是项目目录,第一次跑大概率会因为路径不对报Couldn't load the external resource,把 CSV 拷进 import 目录或者改dbms.directories.import配置都行。IN TRANSACTIONS OF 5000 ROWS是把大文件切成小事务,避免一次性吃满内存,这个子句在 4.4 及以上版本可用,更低版本去掉它、改成手动分批。
第二段用的是MATCH而不是MERGE,这是有意的。如果关系表里某个症状名在实体表里拼错了,MATCH会直接返回空、这一行被跳过,你能从日志或计数差异里发现问题;换成MERGE就会默默创建一个只有名字、没有任何其他属性的野节点,图看着能用,数据其实已经脏了。宁可在导入阶段漏几条,也不要悄悄造脏数据。
3.3 导入后的三项体检:孤立节点、空关系、计数对账
数据导完不能直接拿来查,得先做一遍体检。这一步很多人跳过,然后在问答阶段被“某些病查不到症状”折磨半天。我在build/validate.cypher里固定放三条检查:
// 体检 1:孤立节点——没有任何关系的节点,说明关系表里漏了或者名字对不上 MATCH (n) WHERE NOT (n)--() RETURN labels(n)[0] AS label, n.name AS name LIMIT 30; // 体检 2:关系计数对账——和图里实际的关系数比,判断导入是否完整 MATCH ()-[r]->() RETURN type(r) AS rel, count(r) AS cnt ORDER BY cnt DESC; // 体检 3:缺失关键属性的疾病——看完病却没科室,分诊类问题会答不出来 MATCH (d:Disease) WHERE NOT (d)-[:BELONGS_TO]->(:Department) RETURN d.name AS disease_without_department LIMIT 30;第一条查孤立节点是最有用的。正常情况下,除了极少数只被引用而从没建过关系的实体,图里不该有孤立点;一旦查出来一堆症状是孤立的,基本可以断定关系表里的症状名和实体表里的写法不一致,比如实体表写“恶心”,关系表写“恶心呕吐”。这时候拿两边的名字做一次差集就能定位。
第二条是计数对账,wc -l数一下 CSV 有多少行,减去表头,应该和 Cypher 返回的数量一致;不一致就说明有行被跳过了,八成是MATCH没匹配上。
第三条针对分诊场景。科室是这个项目里最容易被忽略的一类节点,因为它的数量少,看起来可有可无,但只要你想回答“挂什么科”,它就是唯一落点。所以在导入阶段就把缺失情况查出来,比等到用户问的时候才发现要划算得多。
4. 问答核心链路拆解:一句话怎么变成一条 Cypher
4.1 实体识别:正向最大匹配为什么够用
问答链路的第一个环节是从用户的话里抠出实体。这里没必要上 BERT 或者任何模型,一个词典加正向最大匹配就能覆盖绝大多数情况,理由是医疗实体在问句里的形态非常稳定,用户不会说“我那个头有点晕晕的”,而是“头晕”。真正需要模型的是长尾表达,但那是二期的事。
# qa/dictionary.py class EntityDict: """把六类实体的主名和别名拍平成一个词典,供最大匹配使用""" def __init__(self, entities_by_type): # entities_by_type: {"Disease": {"高血压": ["高血压病"]}, ...} self.type_of = {} # 词 -> 实体类型 for label, mapping in entities_by_type.items(): for name, aliases in mapping.items(): self.type_of[name] = label for alias in aliases: if alias: self.type_of[alias] = label # 最长词的长度决定滑动窗口上界,避免每次从整句开始试 self.max_len = max(len(w) for w in self.type_of) if self.type_of else 1 def match(self, text): """正向最大匹配,返回 [(词, 类型, 起始位置), ...]""" hits, i, n = [], 0, len(text) while i < n: matched = None # 从最长可能窗口往短滑,命中即止,保证“恶心呕吐”不会被切成“恶心” for size in range(min(self.max_len, n - i), 0, -1): cand = text[i:i + size] if cand in self.type_of: matched = cand break if matched: hits.append((matched, self.type_of[matched], i)) i += len(matched) # 命中后跳过整个词,避免重叠切分 else: i += 1 # 未命中就前进一个字符 return hits这段代码的关键在两处。一是max_len,它把窗口上界收窄到词典里最长的那个词,而不是每次拿整句去试,问句一长性能差异就很明显。二是命中后i += len(matched)而不是i += 1,这是防止“恶心呕吐”被切成“恶心”加“呕吐”两个实体——在症状交集查询里,这会导致本该命中一种病的两个症状被当成两种病处理,结果全错。
如果实体规模上到十万级,纯 Python 的逐字符扫描会开始吃力,这时候换成 Aho-Corasick 自动机是常规做法,原理一样,只是把“逐个起点试窗口”变成“一遍扫描命中所有模式”。但几千条医学词条的规模,上面这段代码在普通笔记本上单次匹配是毫秒级,没必要提前优化。
匹配完还要处理一个情况:一句话里出现了多个同类实体,比如“头晕恶心”。这时候不能只取第一个,得把它们都传给查询层做交集。
4.2 意图分类:关键词规则比模型更可控
拿到实体之后,得知道用户到底想问什么。同样是“高血压”,问“有什么症状”和问“吃什么药”要走完全不同的 Cypher。这一步用关键词规则表,不训练分类器。
# qa/intent.py # 意图名 -> (触发关键词, 需要的实体类型) INTENT_RULES = [ ("disease_symptom", ["症状", "表现", "什么感觉", "有哪些不适"], "Disease"), ("symptom_disease", ["可能是什么病", "会是什么病", "怀疑", "是什么原因"], "Symptom"), ("disease_drug", ["吃什么药", "用什么药", "怎么治", "治疗", "用药"], "Disease"), ("disease_department", ["挂什么科", "哪个科", "看什么科", "科室"], "Disease"), ("disease_check", ["做什么检查", "查什么", "检查项目"], "Disease"), ("disease_prevent", ["怎么预防", "注意什么", "忌口", "饮食"], "Disease"), ("disease_complication", ["并发症", "会引发", "还会导致"], "Disease"), ] def classify(text, hits): """先按关键词定意图,再用实体类型做二次校验""" for intent, keywords, need_type in INTENT_RULES: if any(kw in text for kw in keywords): # 意图需要的实体类型必须真的被识别到,否则视为未命中 if any(t == need_type for _, t, _ in hits): return intent # 兜底:只说了病名没说要问什么,默认按症状查 if any(t == "Disease" for _, t, _ in hits): return "disease_symptom" return None规则表按顺序匹配,先命中先返回,所以顺序本身是有语义的:disease_department必须排在disease_symptom前面,因为“头晕挂什么科”同时包含“头晕”这个症状词和分诊意图,但如果只按实体匹配,很容易被当成症状查询。这种“排序即优先级”的设计是规则方案唯一需要小心的地方,好在规则只有七八条,维护成本很低。
classify里的二次校验也很重要。用户问“感冒了吃什么药”,如果词典里没有“感冒”这个词,只命中了一个“药”字,那么意图是 drug 但实体不存在,硬查下去只会返回空。加上实体类型校验之后,这种情况会走兜底分支,返回一句“没听懂,换个说法”,比返回一个空列表体面得多。
4.3 模板映射与结果拼装:最后一百米
意图和实体都有了,接下来把它们塞进 Cypher 模板。模板用参数化查询,绝不拼字符串,一方面是防注入,另一方面是让 Neo4j 能复用执行计划。
# qa/template.py CYPHER_TPL = { "disease_symptom": """ MATCH (d:Disease {name: $name})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS answer LIMIT $limit """, "disease_drug": """ MATCH (d:Disease {name: $name})-[:RECOMMAND_DRUG]->(r:Drug) RETURN r.name AS answer LIMIT $limit """, "disease_department": """ MATCH (d:Disease {name: $name})-[:BELONGS_TO]->(dep:Department) RETURN dep.name AS answer LIMIT $limit """, # 多症状反查疾病:按命中症状数排序,命中越多越可能是目标病 "symptom_disease": """ MATCH (s:Symptom) WHERE s.name IN $names MATCH (s)<-[:HAS_SYMPTOM]-(d:Disease) OPTIONAL MATCH (d)-[:HAS_SYMPTOM]->(s2:Symptom) RETURN d.name AS answer, count(s2) AS degree ORDER BY degree DESC LIMIT $limit """, } # 每个意图对应的自然语言外壳,answer 里是查询结果列表 RENDER_TPL = { "disease_symptom": "「{entity}」的常见症状包括:{items}。", "disease_drug": "「{entity}」常用药物有:{items},具体用药请遵医嘱。", "disease_department": "「{entity}」一般建议挂:{items}。", "symptom_disease": "同时出现{entity},可能相关的疾病有:{items}。", }symptom_disease这条模板值得单独说。它没有把实体写死在花括号里,而是用WHERE s.name IN $names接收一个数组,因为用户往往一次报好几个症状。排序用的是该疾病的症状总数degree,而不是简单的匹配数量——在只有两三个症状命中的情况下,症状总数少的病反而更“吻合”,这个排序策略比按名称字典序靠谱得多。
OPTIONAL MATCH在这里不是可有可无的。如果某个疾病在关系表里一条症状都没有(数据缺失),普通MATCH会让整行消失,用户就永远看不到这个病。用OPTIONAL MATCH之后,它的degree为 0,排在最后但至少还在结果里。
执行和拼装:
# qa/query.py from neo4j import GraphDatabase import config _driver = GraphDatabase.driver(config.NEO4J_URI, auth=config.NEO4J_AUTH) def run(cypher, params): with _driver.session(database=config.NEO4J_DB) as session: return [r["answer"] for r in session.run(cypher, **params)] # qa/answer.py def build(intent, entity_text, rows): if not rows: return f"暂时没有查到与「{entity_text}」相关的信息,可以换个说法试试。" # 结果太多时截断,避免一次甩出三十个药名,用户根本读不完 items = "、".join(rows[:8]) return RENDER_TPL[intent].format(entity=entity_text, items=items)driver做成模块级的单例很重要。Neo4j 的驱动内部维护连接池,每次问答新建一个 driver 会让连接数快速堆积,问几十次之后就开始超时。这个很容易忽略,因为本地测试时问不了几次,上线才暴露。
拼装阶段截断到 8 条也是踩过坑之后的决定。最初版本把全部结果拼上去,问一次“感冒有什么症状”,返回了二十多个症状名,读起来像在念词典。截断之后反而更像人话。
5. 避坑与排查:跑起来之后最容易出问题的几处
5.1 导入阶段:乱码、重复节点和静默跳过
现象一:Cypher 里trim(row.name)之后,节点名还是带一堆问号或者方块。
原因是 CSV 用 Excel 编辑过,存成了 GBK 编码,Neo4j 按 UTF-8 读就乱码了。解决办法是导入前统一转一次:iconv -f GBK -t UTF-8 disease.csv -o disease_utf8.csv,或者用编辑器另存为“CSV UTF-8”。判断方法很简单,导入后随便MATCH (d:Disease) RETURN d.name LIMIT 5,看到乱码就说明是这个问题。
现象二:同一个疾病在图上出现了两次,属性还不一样。
八成是没建唯一约束就跑了导入,或者约束建了但MERGE用的键和约束的键不一致。比如约束建在d.name上,导入时写MERGE (d:Disease {dname: row.name}),约束完全不生效。检查方法是SHOW CONSTRAINTS看一眼,再MATCH (d:Disease) RETURN d.name, count(*) ORDER BY count(*) DESC LIMIT 5,有 count 大于 1 的就是重复了。清理重复节点在 Cypher 里挺麻烦,与其事后收拾,不如初始化时就把schema.cypher跑一遍。
现象三:关系表几千行,导进去只有几百条边,还不报错。
这里就是前面提到的MATCH静默跳过。定位方法是把关系表和实体表的名字做差集:把实体表的名字导出来,关系表两列的名字也导出来,用comm -23或者一段小脚本比对,通常能立刻看到“反酸”对“反酸烧心”这类写法差异。修完数据重新跑,计数就对上了。
5.2 查询阶段:笛卡尔积、深度爆炸和索引失效
现象四:一条查询跑了几十秒还没返回,CPU 打满。
最典型的写法问题是在同一条MATCH里放了两个没有关联的模式。比如:
// 错误写法:两个 MATCH 之间没有共享变量,会生成笛卡尔积 MATCH (d:Disease), (s:Symptom) WHERE d.name = '高血压' AND s.name = '头晕' RETURN d, s;如果疾病有一万条、症状有两千条,这条语句会先生成一千万对组合再过滤。正确写法是让关系参与匹配,把两个变量连起来:
// 正确写法:先锚定一个,再顺着关系找另一个 MATCH (d:Disease {name: '高血压'})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name;判断有没有笛卡尔积,看执行计划里的CartesianProduct算子就行,EXPLAIN一下立刻见分晓。
现象五:MATCH (d:Disease {name: $name})明明有索引,还是很慢。
如果约束建在d.name上,这条应该有索引可用,除非你在模板里写成了MATCH (d:Disease) WHERE d.name =~ '.*' + $name + '.*'。正则匹配和CONTAINS都会让索引失效,退化成全表扫描。项目里的模板全部是精确匹配,模糊匹配的需求放在 Python 层用实体识别解决——先把用户的模糊表达映射到标准词,再拿标准词去图里精确查。这个分工比在图库里做模糊查询高效得多。
5.3 问答层:实体歧义、空结果和答非所问
现象六:用户问“胃疼吃什么药”,系统返回了“胃病”的症状。
这是实体识别把“胃疼”匹配到了“胃”,而“胃”又是一个器官类节点,类型判断和意图期望不符,最后走了兜底分支。解决办法是词典里补充口语别名——“胃疼”作为“胃痛”的别名挂进词典,同时把单字词(“胃”“肝”“肾”)从实体词典里移出去,避免它们抢在长词前面命中。最大匹配算法如果写成了最短优先,这个问题会非常普遍,所以前一小节里那个从长到短的循环方向不能改。
现象七:明明图里有数据,问答就是返回空。
这类问题一半以上出在参数名上。模板里写的是$name,代码里传的是{"entity": name},Neo4j 收到一个用不到的参数,$name被当成 null,MATCH自然匹配不到任何东西,还不报错。排查手段是在query.run()里把cypher和params一起打进日志,看一眼就能发现。从那以后我每次加新模板,都强制先手动跑一遍带完整参数的 Cypher,确认能出结果再接进问答链路。
6. 二次开发:扩病种、换模板、接前端的三条改造路径
第一条路是扩数据。这是成本最低、收益最直接的改造。往data/disease.csv里加行,往关系表里加两列,重跑load.cypher就行,代码一行不动。要注意的是重跑之前得想清楚新增数据会不会和已有节点冲突:比如新加一个“糖尿病肾病”,它和“2型糖尿病”是并发症关系,得同时在ACOMPANY_WITH里补一条,否则这个病在图里是孤立的。我给自己的规矩是每加一类新实体,先在validate.cypher里加一条对应的缺失检查,跑通了再往下走。
第二条路是扩意图。现在只有七种意图,想加“这个病会不会遗传”“孕妇能不能吃这个药”这类,只需要三步:在INTENT_RULES里加一行关键词,在CYPHER_TPL里加一条查询,在RENDER_TPL里加一句自然语言外壳。加完之后拿十个真实问句试一遍,重点看两件事——新意图会不会抢走老意图的问句(规则是顺序匹配的,顺序很关键),以及新模板在没有数据时返回什么(空列表必须有兜底文案,不能直接把None拼进字符串)。
第三条路是换接口层。项目自带的是一个最小 HTTP 入口,想接微信、接网页、接命令行都能接,因为qa.query.ask()本身就是个纯函数式的入口,输入字符串,输出字符串加结构化数据。如果要做前端对话界面,建议把返回结构从字符串改成字典:
# 建议的返回结构:前端可以据此渲染不同的消息气泡 { "intent": "disease_department", "entities": [{"text": "头晕", "type": "Symptom", "pos": 2}], "answer": "「高血压」一般建议挂:心血管内科。", "raw": [{"answer": "心血管内科"}], "elapsed_ms": 12 }带上entities和intent之后,前端可以在答案下面显示“我理解到你在问症状对应的科室”,用户能立刻看出系统是理解错了还是数据没有,这个反馈对调试词表特别有用。elapsed_ms则是给自己看的,一旦某类意图的耗时开始明显高于其他,多半是模板里又混进了模糊匹配。
还有一个容易被忽略的改造点:把词典加载从每次请求都做改成进程启动时加载一次。词表几千条的时候,每次问答都重新构建EntityDict会让首字延迟多出几十毫秒,看起来不多,但对话场景里这个延迟是叠加的。这个改动只有三行代码,收益却很明显。
写完这套东西之后我才明白,知识图谱项目的难点从来不是图数据库本身,而是数据规整和意图边界这两件很土的事。从那以后我每次动词表或者加意图,都强制走一遍“实体识别 → 意图分类 → 手工 Cypher → 接问答”这条链路,跳过任何一步都会在后面以“答非所问”的形式还回来。希望帮到你。
本文还有配套的精品资源,点击获取