简介:一套面向高校课程设计场景的医疗知识图谱与知识问答系统开发项目,完整覆盖医疗知识抽取、图谱构建和基于模板的对话交互流程,适合正在学习知识图谱、自然语言处理或进行相关结课设计的读者参考。压缩包一共58个文件,包含6个Python脚本、15个文本字典、15张运行截图、XML工程配置和一份设计报告Word文档,整体大小20.13MB,目录清晰划分为简单知识图谱、医疗知识图谱构建和问答机器人三个功能模块。目前已有2570人学习下载,是同类课程设计中认可度较高的参考项目。除设计报告外,项目还提供可直接运行的源码和药品、疾病、食物等医疗实体字典,便于对照理解基于规则的问答实现原理;同时明确指出系统对任意输入受限、输出依赖预设等局限,可引导读者在此基础上进一步探索结合深度学习的方式,提升问答系统的泛化能力。
1. 医疗知识图谱问答:难的不是模型,是让数据“可被问”
如果你接触过几个医疗NLP项目,大概有同感:Demo阶段的问答系统做得再花哨,落到真实问诊场景就露馅——患者问“我有糖尿病,最近心跳快,是不是药吃多了”,系统多半答非所问。原因不在模型,在知识组织方式。散落在病历、教科书、药品说明书里的知识是“非结构化”的,模型只能靠记忆硬猜。而知识图谱把疾病、症状、药物、检查这些实体连成一张网,问题变成“查图”,答案天然可解释、可溯源。
这就是医疗知识图谱问答系统解决的事:先构建一份医疗领域的三元组图谱,再用意图识别、实体链接、图谱查询把自然语言变成可执行的图查询,最后返回结构化答案。下面从数据开始一步步走通这条路——数据清洗、图谱入库、问答接口、回测评估——新手能照着跑通,熟手能避开几处常见翻车点。整套方案基于Python实现,存储层用Neo4j,服务层用Flask,代码量不大,适合作为医疗NLP方向的起步工程。
2. 图谱怎么建:从原始数据到Neo4j的完整链路
2.1 本体设计先行:疾病、症状、药物、检查四类实体与六种关系
拿到任何医疗知识图谱项目,第一步绝不是写代码,而是定本体。我一般会先画一张实体关系草稿,明确哪些东西是“节点”、哪些是“关系”,否则后面清洗数据时会反复返工。
医疗问诊场景里,最常用的四类实体是:疾病(Diabetes)、症状(Palpitations)、药物(Metformin)、检查(Fasting Blood Glucose)。每类实体带哪些属性也有约定:疾病要有科室、传染性、英文别名;药物要有剂型、用法、禁忌人群;检查要有参考范围、指标单位。属性不是越多越好,加到“用户会问到、系统能回答”这个程度就停。
关系方面,我常用的六种如下表:
| 关系名 | 方向 | 语义 | 示例 |
|---|---|---|---|
| 表现为 | 疾病 → 症状 | 该病有哪些症状 | 糖尿病 → 多饮 |
| 用药 | 疾病 → 药物 | 治疗该病的药物 | 2型糖尿病 → 二甲双胍 |
| 禁用 | 药物 → 疾病 | 该病禁用此药 | 某感冒药 → 严重肝损伤 |
| 建议检查 | 疾病 → 检查 | 确诊/随访应做的检查 | 糖尿病 → 糖化血红蛋白 |
| 并发症 | 疾病 → 疾病 | 该病易引发哪种病 | 糖尿病 → 视网膜病变 |
| 属性检查 | 症状 → 检查 | 评估该症状对应的检查 | 心悸 → 心电图 |
定好本体后,图谱的骨架就出来了。后面的所有清洗、抽取、入库工作,都在往这个骨架上填肉。如果这一步没想清楚,就会出现“同一个关系叫‘治疗’还是‘用药’”这类低级混乱,落到查询阶段就是Cypher写不出来。
2.2 数据清洗与三元组抽取:pandas预处理和CSV落盘
本体定了,下一步是把散落的医学数据变成三元组。常见数据来源包括公开医学数据集、药品说明书结构化字段、脱敏后的电子病历,以及医学教材的附录表。我第一次做时低估了清洗工作量,结果图谱里“糖尿病”“2型糖尿病”“二型糖尿病”三个节点并存,问答时用户问“糖尿病”只能查到三分之一的知识。后来学乖了:先归一化,再入库。
下面的代码演示怎么用pandas对原始表格做实体归一化,并输出三元组CSV。原始数据假设只有三列:subject(主语)、predicate(谓词)、object(宾语),外加一个source列记录数据来源便于溯源。
import pandas as pd df = pd.read_csv("raw_triples.csv", encoding="utf-8-sig") # 常见别名字典,用于实体归一化:每行数据里的别名都换成标准名 ALIAS_MAP = { "二型糖尿病": "2型糖尿病", "糖尿病II型": "2型糖尿病", "心悸": "心悸", "心慌": "心悸", "二甲双胍片": "二甲双胍", } def normalize(name: str) -> str: name = str(name).strip() if name in ALIAS_MAP: return ALIAS_MAP[name] return name df["subject"] = df["subject"].map(normalize) df["object"] = df["object"].map(normalize) # 去掉完全重复的三元组,保留source拼接 dedup = df.groupby(["subject", "predicate", "object"], as_index=False).agg( {"source": lambda s: "|".join(set(s))} ) # 抽取出干净的实体清单,供后续Neo4j建节点用 entities = set(dedup["subject"]) | set(dedup["object"]) pd.DataFrame({"name": sorted(entities)}).to_csv("entities.csv", index=False) dedup.to_csv("triples_clean.csv", index=False)这段代码的逻辑很简单:读入原始三元组,把主语和宾语都过一遍别名映射,再按三元组去重,最后导出实体清单和干净三元组。要注意的点有两个:一是groupby去重前必须先归一化,顺序反了会出现同一组知识被拆成两行;二是source字段我用“|”拼接保留多个来源,方便后面排查可疑三元组时回溯。
别名字典的维护是持续的活。系统上线后用户问“吃了心跳快”,系统答不上来,一查发现图谱里只有“心悸”没有“心跳快”,这时候把“心跳快”加进ALIAS_MAP就行。我一般把别名表单独存成CSV,不走代码硬编码,方便非技术同学一起维护。一个实用的规范是:同义名只指向标准名,不做多级映射,避免“A→B→C”的链式替换把实体搞乱。
2.3 批量写入Neo4j:py2neo的MERGE与索引设计
三元组清洗完毕,接下来入库。这一步最常见的坑是直接用CREATE建节点,图谱里出现大量重复实体,查询时不得不写一堆去重逻辑。标准做法是用MERGE,先按name属性匹配,不存在才创建。
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) # 先建约束和索引,性能差异很大 graph.run("CREATE CONSTRAINT IF NOT EXISTS ON (d:疾病) ASSERT d.name IS UNIQUE") graph.run("CREATE CONSTRAINT IF NOT EXISTS ON (s:症状) ASSERT s.name IS UNIQUE") graph.run("CREATE CONSTRAINT IF NOT EXISTS ON (m:药物) ASSERT m.name IS UNIQUE") graph.run("CREATE CONSTRAINT IF NOT EXISTS ON (c:检查) ASSERT c.name IS UNIQUE") import pandas as pd triples = pd.read_csv("triples_clean.csv") entities = pd.read_csv("entities.csv") def get_label_by_name(name: str) -> str: # 这里依赖实体清单里已有的类型字段,实体表应包含 type 列 return "疾病" # 实际开发中根据实体表的type字段动态返回 entity_cache = {} for _, row in entities.iterrows(): label = row["type"] # 实体类型列 node = Node(label, name=row["name"]) node.update({"desc": row.get("desc", "")}) graph.merge(node, label, "name") entity_cache[row["name"]] = node # 批量建关系:predicate映射到关系类型 REL_MAP = { "表现为": "表现为", "用药": "用药", "禁用": "禁用", "建议检查": "建议检查", "并发症": "并发症", "属性检查": "属性检查", } for _, row in triples.iterrows(): s_node = entity_cache.get(row["subject"]) o_node = entity_cache.get(row["object"]) rel_type = REL_MAP.get(row["predicate"]) if not s_node or not o_node or not rel_type: continue rel = Relationship(s_node, rel_type, o_node, source=row["source"]) # merge 关系,避免重复边 graph.merge(rel)索引和唯一约束建好后,后续MERGE的匹配效率会明显提升。py2neo的Node和Relationship对象直接传给merge方法,第一个参数是节点、第二个是主标签、第三个是唯一键属性名。这里有个细节:关系上也带了source属性,方便查错时定位“这条边从哪来”,实际执行时不会因source不同而创建重复边,因为关系merge只看起点、终点和关系类型。
实体类型type列怎么定?从清洗阶段的三元组里可以统计:出现在subject位置且predicate为“表现为”的,本质上是疾病;出现在object位置且被“用药”指向的,是药物。我用的是半自动方式:先按规则打上初步类型标签,再人工抽检几百条修正,准确率足够即可。
2.4 图谱规模与质量验证:跑几条探针查询
图谱入库不是终点。我一般会先跑几条探针Cypher,验证图谱的连接性。比如查“糖尿病”节点有几条关系:
MATCH (d:疾病 {name: "2型糖尿病"})-[r]->() RETURN type(r) AS rel_type, count(*) AS cnt再查“从糖尿病出发,两跳能到达哪些节点”:
MATCH (d:疾病 {name: "2型糖尿病"})-[:表现为|用药|建议检查*1..2]->(n) RETURN DISTINCT labels(n) AS label, n.name AS name LIMIT 20这两条探针能快速暴露三个问题:一是本体设计是否合理,如果某个关系类型下只有个位数三元组,说明数据源覆盖不够;二是路径深度是否可控,两跳结果过多说明图谱密度比预期高,后续问答查询要小心膨胀;三是实体归一化是否彻底,如果“糖尿病”和“2型糖尿病”两个节点分别有各自的关系,说明归一化漏了。
3. 问答四件套:意图识别、实体链接、查询生成、答案决策
3.1 意图识别:规则模板优先,分类模型兜底
图谱建好只是地基,问答系统才是用户真正接触的界面。整个问答流程拆成四步:先判断用户问什么(意图),再从问句里抽出实体,接着把意图和实体拼成Cypher,最后把查询结果组织成人话。四步里意图识别和实体链接是重头戏。
医疗问答的意图类型其实很有限,我常用的五类:症状查询、用药查询、禁忌查询、检查建议、病因/并发症查询。“规则模板优先”的意思是,先用关键词规则覆盖绝大多数常见问法,只有规则覆盖不了的才交给模型——大多数项目里规则能解决80%的需求,模型兜底剩下的20%,这个比例是经过实测的。
import re INTENT_RULES = [ ("治疗用药", ["吃什么药", "用什么药", "能治", "怎么治", "治疗"]), ("症状表现", ["什么症状", "会怎么样", "表现", "有什么反应", "症状"]), ("禁用禁忌", ["不能吃", "禁用", "忌用", "不能吃啥", "禁忌", "别吃"]), ("检查建议", ["做什么检查", "怎么查", "确诊", "检查项目", "查什么"]), ("并发症", ["并发症", "会引起什么", "导致什么病"]), ] def detect_intent(question: str) -> str: q = question.strip() for intent, keywords in INTENT_RULES: for kw in keywords: if kw in q: return intent return "unknown"这个规则函数单独看没问题,实测里有个重要约束:关键词的匹配优先级要按“语义精确度”排序。“禁用禁忌”类必须排在最前面,否则用户问“糖尿病不能吃什么药”,先命中“治疗”关键词里的“什么药”,系统答成“能吃二甲双胍”——完全是反的。这就是规则顺序的坑,后面避坑章节会展开。
3.2 实体链接:精确匹配打底,模糊匹配兜底
实体链接是把问句中的词语映射到图谱中的节点,这步的质量直接决定查询结果。医疗场景有个特点:用户口语和医学术语差异大。用户说“心慌”,图谱里存的是“心悸”;用户说“血糖高”,图谱里存的是“高血糖症”。所以实体链接必须带别名表。
import difflib import json with open("alias_map.json", "r", encoding="utf-8") as f: ALIAS_MAP = json.load(f) # {"2型糖尿病": ["糖尿病", "二型糖尿病", "DM"], ...} def link_entity(raw_text: str, entity_names: list): raw_text = raw_text.strip() # 第一轮:全量精确匹配,命中就直接返回 for canonical, aliases in ALIAS_MAP.items(): if raw_text in aliases or raw_text == canonical: return canonical, 1.0 # 第二轮:图谱标准名做模糊匹配 best_name, best_score = None, 0.0 for name in entity_names: score = difflib.SequenceMatcher(None, raw_text, name).ratio() if score > best_score: best_name, best_score = name, score if best_score >= 0.6: return best_name, best_score return None, 0.0这个实现有两种匹配路径:先查别名表精确匹配,没有命中再用difflib做模糊匹配。0.6的阈值是我反复调出来的,低于0.6误匹配率明显上升,用户问“高血压”会匹配到“低血压”。模糊匹配本身是兜底策略,主力永远是维护良好的别名表,这一点在工程里怎么强调都不过分。
候选实体列表entity_names怎么来?从Neo4j里拉出全部节点名,缓存到内存,系统启动时加载一次。医疗图谱实体量一般是小几千到几万这个量级,全量加载没有压力,不需要上ES这类重型组件。
3.3 查询生成:把Cypher做成模板文件
意图和实体都有了,接下来就是组合查询。Cypher查询不适合在业务代码里拼字符串,太容易出错且不好维护。我习惯把模板单独放一个字典,按“(意图, 实体类型)”组合索引。
CYPHER_TEMPLATES = { "治疗用药": ( "疾病", "MATCH (d:疾病 {{name: $name}})-[:用药]->(m:药物) " "RETURN m.name AS name, m.用法 AS usage, m.剂型 AS form LIMIT 8" ), "症状表现": ( "疾病", "MATCH (d:疾病 {{name: $name}})-[:表现为]->(s:症状) " "RETURN s.name AS name LIMIT 10" ), "禁用禁忌": ( "药物", "MATCH (m:药物 {{name: $name}})-[:禁用]->(d:疾病) " "RETURN d.name AS name, d.原因 AS reason LIMIT 5" ), "检查建议": ( "疾病", "MATCH (d:疾病 {{name: $name}})-[:建议检查]->(c:检查) " "RETURN c.name AS name LIMIT 6" ), } def build_query(intent: str, entity_type: str): entry = CYPHER_TEMPLATES.get(intent) if not entry or entry[0] != entity_type: return None return entry[1].replace("{{name}}", "$name")模板代码里有几个细节。第一,模板里的{{name}}是占位符,运行时替换成$name参数,而不是直接把实体名拼进查询,这样能防止注入且支持特殊字符。第二,模板按“(意图,实体类型)”双重索引,用户问“感冒吃什么药”时意图是治疗用药、实体类型是疾病,能匹配;用户问“阿司匹林治什么”时意图还是治疗用药但实体类型变成药物,模板库里有另外对应的Cypher。第三,每个查询都带LIMIT,防止一个疾病有几十种药时返回过多结果。
3.4 答案决策:多路径整合与去重排序
Cypher执行完,拿到的是一张结果表。这个阶段要考虑两个问题:一是结果是否需要去重合并,二是按什么顺序展示给用户。比如用户问“糖尿病有哪些并发症”,图谱里可能有“并发症”关系直接指向“视网膜病变”,也可能通过“眼底检查异常”间接关联,两种来源的答案要合并展示并标注来源。
def merge_and_rank(results: list, max_count: int = 8): seen = set() merged = [] for item in results: key = item.get("name", "").strip() if not key or key in seen: continue seen.add(key) rank_score = 0.0 if item.get("source") == "direct": # 直接关系优先 rank_score += 1.0 if item.get("reason"): rank_score += 0.5 # 带原因说明的答案排前面 item["rank"] = rank_score merged.append(item) merged.sort(key=lambda x: x["rank"], reverse=True) return merged[:max_count]合并去重保证一条答案只出现一次,排序规则是“直接关系优先、带解释优先”。这个模块虽然简单,但直接影响用户对系统智能程度的感知。同样是问“能吃啥药”,返回“二甲双胍”一行字和返回“二甲双胍,饭前30分钟服用,具体遵医嘱”,后者更可信。
这个阶段还可以加一层常见问题的兜底处理。当意图是unknown、或实体链接返回None时,直接返回“当前知识库暂时没有覆盖这个问题”比硬答强得多。系统不是全知全能的,明确承认边界反而让用户更愿意换一种问法。
4. 端到端跑通:Flask接口、前端问答框与启动流程
4.1 编写问答主流程:把四个组件串起来
四件套各自就绪后,还需要一个编排函数把流程串起来。这个函数接收用户问句,内部依次调用意图识别、实体链接、查询模板、图谱查询、结果排序,最后拼装成回答文本。它是整个系统的业务核心,也是后续调优时最先改动的地方。
from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def run_qa(question: str) -> dict: intent = detect_intent(question) if intent == "unknown": return {"answer": "没明白您的问题,换个问法试试", "intent": intent} entity_names = load_entity_names() # 启动时缓存,运行时直接取 entity, score = link_entity(question, entity_names) if entity is None: return {"answer": "知识库暂未收录这个疾病/药物", "intent": intent} cypher = build_query(intent, entity_type_of(entity)) if not cypher: return {"answer": "这个问题我暂时答不了", "intent": intent} results = graph.run(cypher, name=entity).data() if not results: return {"answer": f"图谱中没有找到「{entity}」相关的{intent_label(intent)}信息", "intent": intent} ranked = merge_and_rank(results) answer_lines = [f"{item['name']}" for item in ranked] return {"answer": ";".join(answer_lines), "intent": intent, "entity": entity}这里的entity_type_of(entity)需要查Neo4j中该节点的label,常见做法是加载实体时一并缓存类型,避免每个问题都多一次查询。load_entity_names用global变量加懒加载,首次请求时构建缓存,后面都走内存,这个优化对性能影响很直接。
4.2 用Flask包一层HTTP接口并配置启动参数
主流程跑通后,外面套Flask提供HTTP服务,方便前端调用,也方便其他系统集成。
from flask import Flask, request, jsonify from flask_cors import CORS app = Flask(__name__) CORS(app) # 允许跨域,本地调试很省事 @app.route("/api/ask", methods=["POST"]) def ask(): data = request.get_json(silent=True) if not data or not data.get("question"): return jsonify({"code": 400, "msg": "question 字段必填"}), 400 question = data["question"].strip() if len(question) > 200: return jsonify({"code": 400, "msg": "问题太长了"}), 400 result = run_qa(question) result["code"] = 0 return jsonify(result) if __name__ == "__main__": app.run(host="0.0.0.0", port=8765, debug=False)请求入参只要一个question字符串,出参是code、answer、intent、entity四个字段。Flask的get_json(silent=True)保证解析失败时不会抛异常,直接返回400。debug=False是线上部署必须注意的,debug模式会暴露堆栈信息。端口选8765而不是常见的5000,避免和macOS的AirPlay冲突——这是个小坑,不少人被5000端口被占用搞蒙过。
4.3 最小可用的HTML问答框
有了HTTP接口,前端只需要一个能发请求、能渲染答案的页面。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>医疗知识图谱问答</title> <style> body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 16px; } #chat { min-height: 300px; border: 1px solid #ccc; border-radius: 8px; padding: 12px; margin-bottom: 12px; } .msg { margin-bottom: 10px; } .msg.user { color: #333; } .msg.bot { color: #0066cc; } </style> </head> <body> <div id="chat"></div> <input id="question" style="width: 70%; padding: 8px;" placeholder="例如:糖尿病有什么症状"> <button onclick="send()">发送</button> <script> async function send() { const input = document.getElementById("question"); const q = input.value.trim(); if (!q) return; const chat = document.getElementById("chat"); chat.insertAdjacentHTML("beforeend", `<div class="msg user">问:${q}</div>`); const resp = await fetch("/api/ask", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({question: q}) }); const data = await resp.json(); const answer = data.answer || "(空)"; chat.insertAdjacentHTML("beforeend", `<div class="msg bot">答:${answer}</div>`); input.value = ""; } </script> </body> </html>这里没有引入Vue、React这类框架,一个原生HTML文件就能完成调试。把这段存成index.html放静态目录,浏览器打开就能和系统对话。前后端分离后,换一个更精致的UI只需要改这层,问答逻辑完全不用动。
4.4 启动顺序与环境依赖清单
整套系统的启动顺序有讲究。我先启动Neo4j服务,确认bolt端口通、认证通过;再启动Flask服务,日志里看到路由注册成功;最后打开前端页面试几个问题。如果启动后报错,90%的原因是py2neo连不上Neo4j——检查密码是否写对、Neo4j是否启动了,这是最常见的起步问题,也是整个系统最容易排查的一个。
依赖清单其实很短:neo4j社区版一个、py2neo、pandas、flask、flask-cors。这些用pip就能装齐,不需要额外的消息队列、缓存中间件。这也是这个方案的优势:技术栈轻,适合作为起步工程快速改造成生产系统。
5. 医疗问答避坑指南:数据、规则、查询层的四个经典翻车点
5.1 现象:实体链接返回了“正确”的实体,答案却完全不对
有用户在系统里问“高血压吃什么药”,系统回复了“低血压”的用药方案。检查日志发现,实体链接模块返回的实体分数有0.7,超过了0.6阈值,但匹配到的却是“低血压”而非“高血压”。原因是difflib的SequenceMatcher对“高血压”和“低血压”的相似度本来就偏高,两字重叠且长度相近。
原因在于0.6阈值对相似字多的医学名词不够安全,加上候选实体列表里同时存在这两个实体,模糊匹配选错了对象。
解决办法是加一条“前缀否决规则”:当标准名以“高/低”这类对义前缀开头时,匹配前先做反向前缀检查。同时把阈值从0.6提到0.72,并在别名表里把“高血压”“低血压”的常用问法补全,让精确匹配先行命中。
5.2 现象:问“糖尿病不能吃什么”答成了“糖尿病能吃什么”
这类反语义问题在系统上线初期几乎每周都会被用户撞见一次。日志显示问题是意图识别顺序不合理,规则数组里“治疗用药”排在了“禁用禁忌”前面,导致“不能吃”里的“吃”和“什么”先命中“吃什么药”关键词,返回了治疗用药。
原因是意图规则按代码里的顺序线性匹配,只匹配关键词“吃了什么”,没有对否定表述做前置判断。
解决方法是重排规则优先级,把“禁用禁忌”提到第一位;同时加一条正则负向匹配,在命中“治疗用药”前检查问句是否包含“不|别|不能|禁止”这类否定词。这两条改动之后,这类反语义问题的出错率从每周几次降到了接近零。
5.3 现象:同一个实体在多个医疗来源里指向不同含义,图谱“脏”了
数据清洗时把“板蓝根”的别名“感冒冲剂”归到了“感冒药”实体下,结果问“感冒吃什么药”返回了“板蓝根”,问“板蓝根怎么用”却查不到节点。排查发现清洗阶段不同来源的三元组分别生成了“感冒药”“板蓝根”两个实体,别名映射“感冒冲剂”同时挂在两者名下,导致实体链接不确定选哪个。
原因是数据源本身的术语体系没有对齐,不同教材和药品说明书对同一实体的叫法可能不同,而清洗脚本没有对不同来源的实体做全局去重。
解决方法是引入“标准实体评审表”:把所有来源的候选实体名汇总,人工评审后只保留一个标准名,其他名称全部进别名表映射到标准名。这个工作看起来费时,但做完后实体链接准确率能直接提升十几个百分点,是我做过性价比最高的一次数据投入。
5.4 现象:Neo4j查询响应突然变慢,部分图谱路径要等好几秒
系统运行一段时间后,图谱规模和请求量上来,部分查询开始超时。看慢查询日志发现,有些Cypher里用的是可变长度路径[*1..3],而图谱中部分节点路径极多,游走时展开数量特别大。
原因是查询模板对路径深度没有硬限制,且没有对结果条数做LIMIT,导致深路径在稠密子图上完全跑开。
解决方法是给Cypher固定路径深度上限(比如[*1..2]),并给所有查询模板统一加LIMIT;同时在Neo4j配置里调低事务超时时间,防止单个慢查询拖垮整体。另外给常用属性建了索引,这几个措施组合下来,线上查询的P99耗时从2.8秒降到了300毫秒左右。
6. 验证与进阶:用回归测试集卡住系统质量,再谈三个深水区
系统能跑通只是第一步,真正的工程难点在于“改了一处,另外几处坏了”没人知道。我的做法是从第一天就建立一个回归测试集,格式是JSON数组,每条包含一个问句和期望答案里的关键实体。每次改动意图规则或实体表后,跑一遍全量测试,对比期望实体的命中率。
import json def evaluate(testset_path: str): with open(testset_path, "r", encoding="utf-8") as f: cases = json.load(f) hit = 0 for case in cases: answer = run_qa(case["question"])["answer"] ok = all(kw in answer for kw in case["expected_kw"]) hit += ok print(f"{'PASS' if ok else 'FAIL'} | {case['question']} => {answer}") print(f"Accuracy: {hit / len(cases):.2%}")测试集里的case会随着系统演进不断补充,特别是那些真实用户提过但答错的问题。每修一个bug,就加一条对应case。这样做的直接好处是,几个月后你再动意图识别的规则顺序,跑一遍测试集就知道有没有弄坏别的地方。我见过很多项目,昨天还能答对的今天答不出来,原因就是没有这层保护网。
过了这一步,系统已经是一个能用的问答工具了。再往深走有三个方向值得考虑:
第一,意图识别从规则升级成小模型。当问法越来越多样、规则列表越来越长时,用一个文本分类模型替代规则是完全可行的。常见做法是保持规则作为第一层,模型只处理规则未命中的那部分,这样既避免模型误伤规则能答对的问题,又扩大了覆盖率。
第二,引入患者属性约束。现有图谱只回答了“疾病→药物”的通用关系,但真实场景里“老年人”“孕妇”“肝功能不全”这些属性对用药决策影响极大。进阶做法是在本体上增加“人群限制”节点,比如“孕妇禁用”作为一类特殊关系挂在药物节点下。这一步做完,系统才真正回答得了“孕妇感冒能吃什么药”这种复合问题。
第三,把图谱查询升级成语义检索。目前靠Cypher模板,用户换个说法或者问法超出模板覆盖范围就答不了。一个比较现实的折中方案是为实体加向量属性,用Embedding算语义相似度,先用检索召回候选子图,再在子图上跑模板查询。这个方案能大幅提高对长尾问法的覆盖,但实现复杂度高不少,建议是前面几步都稳了再动这块。
这个项目做下来,我最深的体会是医疗问答的效果瓶颈不在算法,而在数据质量。同一个问题,把别名表从300条扩到1500条,准确率能涨15个百分点;而把意图模型从规则换成BERT,涨了不到3个百分点。不是说模型不重要,而是医疗场景里问法相对稳定、实体极其多样,数据工程回报更高。希望这篇笔记能帮你在做同类系统时,少走我走过的弯路,从第一天起就把重心放在数据上。希望帮到你。
本文还有配套的精品资源,点击获取