简介一套面向高校课程设计场景的医疗知识图谱与知识问答系统开发项目完整覆盖医疗知识抽取、图谱构建和基于模板的对话交互流程适合正在学习知识图谱、自然语言处理或进行相关结课设计的读者参考。压缩包一共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, encodingutf-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_indexFalse).agg( {source: lambda s: |.join(set(s))} ) # 抽取出干净的实体清单供后续Neo4j建节点用 entities set(dedup[subject]) | set(dedup[object]) pd.DataFrame({name: sorted(entities)}).to_csv(entities.csv, indexFalse) dedup.to_csv(triples_clean.csv, indexFalse)这段代码的逻辑很简单读入原始三元组把主语和宾语都过一遍别名映射再按三元组去重最后导出实体清单和干净三元组。要注意的点有两个一是groupby去重前必须先归一化顺序反了会出现同一组知识被拆成两行二是source字段我用“|”拼接保留多个来源方便后面排查可疑三元组时回溯。别名字典的维护是持续的活。系统上线后用户问“吃了心跳快”系统答不上来一查发现图谱里只有“心悸”没有“心跳快”这时候把“心跳快”加进ALIAS_MAP就行。我一般把别名表单独存成CSV不走代码硬编码方便非技术同学一起维护。一个实用的规范是同义名只指向标准名不做多级映射避免“A→B→C”的链式替换把实体搞乱。2.3 批量写入Neo4jpy2neo的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, namerow[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, sourcerow[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, encodingutf-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(keylambda x: x[rank], reverseTrue) 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, nameentity).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(silentTrue) 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(host0.0.0.0, port8765, debugFalse)请求入参只要一个question字符串出参是code、answer、intent、entity四个字段。Flask的get_json(silentTrue)保证解析失败时不会抛异常直接返回400。debugFalse是线上部署必须注意的debug模式会暴露堆栈信息。端口选8765而不是常见的5000避免和macOS的AirPlay冲突——这是个小坑不少人被5000端口被占用搞蒙过。4.3 最小可用的HTML问答框有了HTTP接口前端只需要一个能发请求、能渲染答案的页面。!DOCTYPE html html langzh-CN head meta charsetUTF-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 idchat/div input idquestion stylewidth: 70%; padding: 8px; placeholder例如糖尿病有什么症状 button onclicksend()发送/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 classmsg 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 classmsg 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, encodingutf-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(fAccuracy: {hit / len(cases):.2%})测试集里的case会随着系统演进不断补充特别是那些真实用户提过但答错的问题。每修一个bug就加一条对应case。这样做的直接好处是几个月后你再动意图识别的规则顺序跑一遍测试集就知道有没有弄坏别的地方。我见过很多项目昨天还能答对的今天答不出来原因就是没有这层保护网。过了这一步系统已经是一个能用的问答工具了。再往深走有三个方向值得考虑第一意图识别从规则升级成小模型。当问法越来越多样、规则列表越来越长时用一个文本分类模型替代规则是完全可行的。常见做法是保持规则作为第一层模型只处理规则未命中的那部分这样既避免模型误伤规则能答对的问题又扩大了覆盖率。第二引入患者属性约束。现有图谱只回答了“疾病→药物”的通用关系但真实场景里“老年人”“孕妇”“肝功能不全”这些属性对用药决策影响极大。进阶做法是在本体上增加“人群限制”节点比如“孕妇禁用”作为一类特殊关系挂在药物节点下。这一步做完系统才真正回答得了“孕妇感冒能吃什么药”这种复合问题。第三把图谱查询升级成语义检索。目前靠Cypher模板用户换个说法或者问法超出模板覆盖范围就答不了。一个比较现实的折中方案是为实体加向量属性用Embedding算语义相似度先用检索召回候选子图再在子图上跑模板查询。这个方案能大幅提高对长尾问法的覆盖但实现复杂度高不少建议是前面几步都稳了再动这块。这个项目做下来我最深的体会是医疗问答的效果瓶颈不在算法而在数据质量。同一个问题把别名表从300条扩到1500条准确率能涨15个百分点而把意图模型从规则换成BERT涨了不到3个百分点。不是说模型不重要而是医疗场景里问法相对稳定、实体极其多样数据工程回报更高。希望这篇笔记能帮你在做同类系统时少走我走过的弯路从第一天起就把重心放在数据上。希望帮到你。本文还有配套的精品资源点击获取