
简介面向医疗知识图谱入门、课程设计及小型医疗问答系统实践者的一套简易项目源码围绕 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。判断某个概念该不该做成独立节点标准只有一条——它会不会被单独查询、被单独展示。科室如果只是疾病的一个属性那「哪些病挂心内科」这种反向问题就得全表扫做成节点之后一条反向关系就解决了。节点标签业务主键关键属性教学场景量级Diseasedisease_idname、alias、icd_hint、summary200500Symptomsymptom_idname、alias、body_part300800Drugdrug_idname、alias、drug_class、usage_hint100300Departmentdept_idname、alias2040Checkcheck_idname、alias、category50150关系一共定义六种全部从「被描述的主体」指向「描述内容」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上至少要有两个属性weight01表示这个症状对该病的典型程度感冒对应「发热」的权重高「胸痛」的权重低和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_AUTHneo4j/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, summarysymptom.csv症状节点symptom_id, name, alias, body_partdrug.csv药品节点drug_id, name, alias, drug_classdept.csv科室节点dept_id, name, aliasrel_disease_symptom.csv疾病-症状disease_id, symptom_id, weight, sourcerel_disease_drug.csv疾病-药品disease_id, drug_id, first_line, noterel_disease_dept.csv疾病-科室disease_id, dept_idrel_drug_contra.csv药品禁忌drug_id, disease_id, level字段命名统一用小写下划线不要混用驼峰否则 Cypher 里一会儿diseaseId一会儿disease_id写错一个字母就是空结果而且不报错。weight列必须全部是 01 之间的小数导入前用 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_drug4.2 实体抽取词典加最大正向匹配医疗领域的实体抽取用别名词典做最大正向匹配性价比远高于上模型。原因很实在症状词大多是两到六个字的固定短语词典覆盖得住而通用分词会把「心慌胸闷」切成两个词反而丢了原有的语义边界。所以我一般把别名表整理成 JSON标准名映射到别名数组启动时反转成「别名 → 标准名」的哈希表。import json def load_alias(pathalias.json): 把一个标准名对应多个别名的字典反转成 别名-标准名 的查找表 with open(path, encodingutf-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_len8): 最大正向匹配每个位置尽量吃最长的词命中后跳过已消费长度 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_len8是按最长症状词定的词表里有更长的就调大但不要超过 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, threshold0.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在模块级初始化不要每个请求都新建连接这是压测时最常见的性能瓶颈来源。返回统一走jsonifycode字段让前端能区分「无结果」和「低置信度」做不同的 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.85Top1 命中率首条答案包含正确疾病或药品≥ 70%Top3 命中率前三条包含正确答案≥ 88%平均响应时间单次问答端到端耗时≤ 300 ms图谱覆盖率测试问题在图谱内有答案的比例≥ 80%跑这套指标写个脚本就行几十行 Python 循环调用接口、比对结果、输出表格不要手工点。有一个细节值得注意Top1 命中率通常比 Top3 低不少这不丢人反而可以在答辩时主动讲——「首条命中七成前三条接近九成说明排序还有优化空间主要卡在权重数据的覆盖度上」。主动指出短板并给出改进路径比硬撑一个漂亮数字可信得多老师一般也就顺着这个话题往下问主动权在你手里。演示脚本建议提前写死三分钟讲完第一分钟问「发烧咳嗽可能是什么病」展示多候选和支撑理由第二分钟问「高血压挂什么科」和「糖尿病会引起哪些并发症」展示一跳和两跳的区别这里一定要点出「两跳是图数据库相对关系表的核心优势」第三分钟故意问一个图谱里没有的病展示兜底话术说明系统不会硬答。三个场景刚好覆盖正常链路、图特性、异常处理比漫无目的地演示十分钟有效。从那以后我每次带这类项目都会在第一周就把测试集建起来哪怕只有三十条也强制走一遍指标脚本再改代码。没有度量的时候所有优化都是凭感觉改到最后自己都不知道是变好还是变坏。这套东西从数据模型到问答接口都是可以整份拿去改的把疾病和症状换成你自己的领域词链路一行不用动希望帮到你。本文还有配套的精品资源点击获取