简介:面向Python毕业设计及知识图谱问答方向的开发者,这款医疗知识图谱问答系统以Python实现,完整覆盖从医疗数据清洗、实体关系建模到基于Neo4j的图谱存储与问答匹配的闭环流程。压缩包共28个文件、约15.85MB,内含Python源码、医疗JSON图谱数据、TXT词典与说明文档、XML配置及界面演示图等。系统模块划分清晰,涉及数据预处理、问句分类、实体识别、答案搜索与图谱查询等典型功能,并附带疾病、症状、药品等结构化字典和基础数据文件,便于直接运行调试或二次改造。已有625人学习下载,适合作为本科毕业设计或课程项目的完整参考,既能快速验证NLP与知识图谱组合技术栈,也可基于其架构扩展新科室或新功能。
1. 医疗知识图谱问答系统:这个毕设题目到底在做什么
如果你在毕业设计选题列表里看到「基于Python实现的医疗知识图谱的知识问答系统」,第一反应多半是「这又是一个包装过的数据库课设」。实际把它拆开看,它做的是三件事:用爬虫或公开数据集整理医患问答数据,用Neo4j这类图数据库存成「疾病-症状-科室-药品」的实体关系网,再写一个能接收自然语言问题、解析意图、查图谱并返回答案的问答接口。整套东西跑通之后,你输入「头疼两天该挂什么科」,系统能告诉你「神经内科或普通内科,伴随发热建议先发热门诊」,而不是像传统关键词搜索那样甩给你一堆网页。
这个方向值得做,原因在于它同时覆盖了Python后端、数据处理、图数据库、NLP规则抽取这四个毕业设计里高频考察的点,而且每个点都有肉眼可见的交付物:neo4j里能看得见的图、能跑通的问答demo、能写进论文的准确率数据。相比之下,纯爬虫或纯Web CRUD的题目在答辩时很难撑满20分钟,而这个题目每个模块都能展开讲。技术栈上不挑机器,Windows笔记本就能跑,数据量不大,瓶颈基本都在内存和Neo4j的配置上。
适合的人群有两类:一是Python基础尚可、但没接触过图数据库和NLP的学生,这个题目能让你在一个月内把Neo4j的Cypher查询、HanLP或jieba分词、Flask接口串成一条线;二是已经在做Web开发、想往知识图谱方向靠的从业者,可以用同样的骨架接工业知识图谱的场景。本文按我自己的实现路径来写,从数据准备、图谱构建、问答链路到部署排错,每一步都给可复现的命令和参数,最后落在几个最容易翻车的细节上。
2. 数据从哪来:构建医疗知识图谱的数据采集与清洗
2.1 数据源选型:为什么别一上来就爬网站
医疗知识图谱的数据来源通常有三条路:公开数据集、爬虫抓取、手工整理。公开数据集里最常用的是CMeKG(中文医学知识图谱)的发布包,但完整版需要申请,而且schema和你的问答需求不一定对得上。爬虫抓取一般指向寻医问药网、39健康网这类站点,能拿到疾病描述、症状、科室、药品、饮食建议等字段,但反爬、页面改版、编码乱码会让你在数据上耗掉两周。手工整理只适合做几十条demo,撑不起问答系统的召回量。
我一般建议的时间分配是:60%时间花在公开数据集的schema对齐和清洗上,30%时间写一个轻量爬虫补齐缺失实体,10%留给人肉校对种子数据。推荐先找一份「症状-疾病-科室-药品」四元组的CSV,格式类似疾病名称、症状描述、所属科室、常用药物、检查项目、推荐食物,字段宁可多不可少,因为知识图谱在构建阶段删字段容易,后面想补字段要重新跑全量导入。
选择数据源时还要考虑许可证问题。用于毕业设计或学习,CMeKG和开放爬取数据的合规风险相对低,但如果要写进论文并公开代码仓库,建议在README里注明数据来源和用途限制。爬虫抓取时robots协议和对方网站的条款要过一眼,别给答辩埋雷。
2.2 一个可复用的清洗脚本:去重、对齐、补空值
拿到原始CSV后不要直接导入Neo4j,一定要先清洗。常见脏数据包括:同一疾病多种写法(「2型糖尿病」和「II型糖尿病」)、症状字段里混入标点和英文、药品字段为空、科室名称带括号备注。下面的脚本做三件事:统一疾病名和科室名的映射、拆分症状字段、丢弃药品和科室双空的记录。
import pandas as pd import re df = pd.read_csv('medical_raw.csv', encoding='utf-8-sig') print(f"原始数据量: {len(df)}") # 1. 疾病名映射表:手工维护常见别名 disease_map = { '2型糖尿病': '2型糖尿病', 'II型糖尿病': '2型糖尿病', '糖尿病2型': '2型糖尿病', } df['disease'] = df['disease'].map(lambda x: disease_map.get(x.strip(), x.strip())) # 2. 科室名去括号备注 dept_pattern = re.compile(r'[((].*?[))]') df['department'] = df['department'].astype(str).map(lambda x: dept_pattern.sub('', x).strip()) # 3. 症状拆分:按中文顿号、逗号切分 def split_symptoms(text): if pd.isna(text) or not str(text).strip(): return [] return [s.strip() for s in re.split(r'[、,,;;]', str(text)) if s.strip()] df['symptom_list'] = df['symptom'].map(split_symptoms) # 4. 过滤无效记录 valid = df[df['drug'].notna() & df['department'].notna()].copy() print(f"清洗后有效数据: {len(valid)}") # 5. 导出为图谱导入用长表 records = [] for _, row in valid.iterrows(): for sym in row['symptom_list']: records.append({ 'disease': row['disease'], 'symptom': sym, 'department': row['department'], 'drug': row['drug'], 'check': row.get('check_item', '') }) long_df = pd.DataFrame(records) long_df.to_csv('medical_clean.csv', index=False, encoding='utf-8-sig')逻辑说明:第一步做实体对齐,别名映射表虽然要手工维护,但它是后续图谱实体唯一性的基础,漏了这一步Neo4j里会出现两个「2型糖尿病」节点。第三步把症状拆成列表再展开成长表,是为了后续导入时每条「疾病-症状」关系单独成行,避免在Cypher里做字符串拆分。第四步过滤掉药品和科室为空的记录,是因为问答系统里「该挂什么科」和「吃什么药」是两个核心意图,缺了这两个字段的实体对问答毫无贡献。
参数说明:编码用utf-8-sig而不是utf-8,是因为Windows下Excel另存的CSV默认带BOM头,省编码问题;症状拆分用正则[、,,;;]覆盖中英文分隔符,实测数据里混用顿号和逗号的情况很常见;dropna条件用notna而不是非空字符串,避免把空字符串当有效值导入。
2.3 数据量级怎么定:多少实体关系才够答辩
问答系统的效果和数据量不是线性关系。实体从500条涨到2000条,问答准确率提升明显;从5000条往上,边际收益就很低了,因为医疗问答的常见意图就那么几十类。我的建议是清洗后保留至少2000个疾病实体、8000条以上症状关系、1000条以上药品关联,这个量级足以支撑论文里「覆盖30类常见疾病问答」的描述,而且Neo4j导入只需十几秒,查询响应在毫秒级。
不要盲目追求数据量,有一个知乎上常讲但实际血泪的教训:实体越多,实体对齐的坑越多。比如「头疼」「头痛」「头部疼痛」在数据里是三套写法,如果不做归一化,用户问「头痛」时图谱里只有「头疼」节点,规则匹配失败直接返回空。解决方式是在清洗阶段维护一个症状同义词表,或者导入后用Cypher做一次别名节点合并。后面第5章我会专门讲这个坑。
3. Neo4j图建模与导入:把CSV变成能查询的知识图谱
3.1 图模型设计:节点、关系、属性的取舍
医疗知识图谱的schema设计直接决定问答系统的查询复杂度。我用的模型是四类节点和五类关系,这是医疗问答里最稳定的一套设计。
节点:疾病(Disease)、症状(Symptom)、科室(Department)、药品(Drug)。每个节点带一个name属性作为唯一标识,Disease额外带description、check_item、treat_principle等描述属性,Symptom带body_part等可选属性。
关系:Disease-[:HAS_SYMPTOM]->Symptom、Disease-[:BELONGS_TO]->Department、Disease-[:RECOMMEND_DRUG]->Drug、Symptom-[:DEPARTMENT_HINT]->Department(症状指向可能科室,用于反向推导)、Disease-[:NEED_CHECK]->CheckItem(检查项目)。
这套设计的好处是问答意图和关系一一对应:问「头疼挂什么科」实际是查Symptom节点出发的DEPARTMENT_HINT或Disease的BELONGS_TO;问「糖尿病吃什么药」查RECOMMEND_DRUG;问「这个病有什么症状」查HAS_SYMPTOM。另一种常见设计是把科室作为Disease的属性而不是节点,但这样就没法回答「神经内科看什么病」这类反向问题,所以我还是建议科室独立成节点。
属性命名统一用英文小写下划线,避免中文属性名在Cypher里需要反引号包裹的麻烦。name属性上建唯一约束,这是后续所有实体对齐和导入幂等性的基础。
3.2 用Cypher批量导入:LOAD CSV的正确姿势
Neo4j导入CSV有两条路:neo4j-admin import用于全量初始导入(要求数据库停止服务),LOAD CSV用于在运行状态下增量导入。毕业设计场景数据量小,我建议直接用LOAD CSV,方便反复清洗重导。前提是CSV文件要放到Neo4j的import目录下,默认是安装目录的import/文件夹。
// 1. 建立唯一约束,保证实体幂等 CREATE CONSTRAINT disease_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT symptom_unique IF NOT EXISTS FOR (s:Symptom) REQUIRE s.name IS UNIQUE; CREATE CONSTRAINT dept_unique IF NOT EXISTS FOR (d:Department) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT drug_unique IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; // 2. 导入疾病节点 LOAD CSV WITH HEADERS FROM 'file:///medical_clean.csv' AS row MERGE (d:Disease {name: row.disease}) ON CREATE SET d.check_item = row.check, d.description = row.description; // 3. 导入症状节点并建立关系 LOAD CSV WITH HEADERS FROM 'file:///medical_clean.csv' AS row MERGE (s:Symptom {name: row.symptom}) MERGE (d:Disease {name: row.disease}) MERGE (d)-[:HAS_SYMPTOM]->(s);逻辑说明:MERGE而不是CREATE是导入脚本里最重要的决定。MERGE先按唯一约束查重,节点存在就不重复创建,只更新缺失属性。CONSTRAINT用IF NOT EXISTS是为了脚本可重复执行,否则第二次跑会报约束已存在。ON CREATE SET只在节点首次创建时写入属性,避免后续重复导入覆盖已有描述。
参数说明:LOAD CSV WITH HEADERS要求CSV首行是列名,列名的拼写要和row变量里的key完全一致,注意大小写。文件路径用file:///medical_clean.csv,斜杠是三个,相对于Neo4j的import目录。如果CSV里有中文,确保文件编码是UTF-8且无BOM,Neo4j对BOM的处理在不同版本里不一致。
导入科室和药品的关系同理,把MERGE的节点类型换成Department和Drug即可。全量导入完,用MATCH (n) RETURN count(n)统计节点总数,和清洗后的CSV行数做交叉验证,如果数量差太大,说明MERGE把别名实体合并了,大概率是清洗没到位。
3.3 索引与查询验证:先确认图是「活」的再写问答
导入后不要急着写问答,先用几个Cypher查询验证图谱的连通性和查询性能。
// 验证1:某疾病的完整信息 MATCH (d:Disease {name: '2型糖尿病'})-[r]->(n) RETURN type(r) AS relation, n.name AS target; // 验证2:某症状可能关联的疾病和科室 MATCH (s:Symptom {name: '头痛'})<-[:HAS_SYMPTOM]-(d:Disease) OPTIONAL MATCH (d)-[:BELONGS_TO]->(dept:Department) RETURN d.name AS disease, dept.name AS department LIMIT 20; // 验证3:从科室反向找疾病 MATCH (dept:Department {name: '神经内科'})<-[:BELONGS_TO]-(d:Disease) RETURN d.name AS disease LIMIT 50; // 验证4:执行计划检查是否走索引 EXPLAIN MATCH (d:Disease {name: '2型糖尿病'}) RETURN d;逻辑说明:验证1用可变长路径的写法检查节点有没有挂上关系,如果返回空,说明导入脚本里MERGE的name值不匹配,通常是清洗阶段别名没对齐。验证2是问答系统里最核心的查询模式「症状反查疾病与科室」,跑通这个就解决了50%的问答需求。验证3用于处理「科室实体在问题里出现」的场景,是很多规则问答系统忽略的反向查询。
参数说明:EXPLAIN不会真正执行查询,只是显示执行计划,看有没有用上NodeIndexSeek。如果返回结果里出现NodeByLabelScan,说明name约束没有生效,检查约束是否创建成功。LIMIT 20在验证阶段加上能防止意外的大结果集把浏览器卡死。
4. 问答链路实现:从自然语言到Cypher的转换器
4.1 意图识别与实体抽取:基于规则的实现够不够用
问答系统的核心是把用户输入的自然语言映射成Cypher查询。两条技术路线:一是基于规则模板,用jieba分词或HanLP做词法分析,匹配关键词和句式模板;二是基于意图分类模型(BERT等),训练一个分类器识别「求科室」「求药品」「求症状」等意图。毕业设计场景里,基于规则完全够用,原因有两点:医疗问答的句式高度模板化,用户问法集中在「XX怎么办」「XX挂什么科」「XX吃什么药」这几十种模式;规则系统的行为可解释,答辩时你可以一条条演示匹配逻辑,而模型方案一旦出错很难向评委解释。
规则系统的核心是维护一个「意图字典」和「实体词典」。意图字典把触发词映射到意图类型:出现「挂什么科」「哪个科室」「看什么病」映射到DEPARTMENT意图;出现「吃什么药」「用什么药」「药品」映射到DRUG意图;出现「什么症状」「有哪些表现」映射到SYMPTOM意图。实体词典就是从Neo4j里导出的所有实体名列表,用最大匹配法从用户问题里切出实体。
import jieba from py2neo import Graph class MedicalQA: def __init__(self, uri="bolt://localhost:7687", user="neo4j", pwd="password"): self.graph = Graph(uri, auth=(user, pwd)) self.entities = self._load_entities() self.intent_patterns = { 'department': ['挂什么科', '哪个科室', '什么科', '看什么病', '就诊'], 'drug': ['吃什么药', '用药', '药品', '药物', '药'], 'symptom': ['什么症状', '有哪些表现', '症状', '表现'], } def _load_entities(self): # 从图谱中一次性拉取所有实体名 query = "MATCH (n) RETURN labels(n)[0] AS type, n.name AS name" data = self.graph.run(query).data() entities = {'Disease': [], 'Symptom': [], 'Department': [], 'Drug': []} for row in data: entities[row['type']].append(row['name']) return entities def extract_entities(self, question): # 最大匹配:按实体长度降序匹配,避免短词截断长词 found = {'Disease': None, 'Symptom': None} for ent_type in ['Disease', 'Symptom', 'Department']: candidates = sorted(self.entities[ent_type], key=len, reverse=True) for ent in candidates: if ent in question: found[ent_type] = ent break return found def parse_intent(self, question): for intent, patterns in self.intent_patterns.items(): for p in patterns: if p in question: return intent return 'default'逻辑说明:实体抽取用最大匹配而不是jieba分词,是因为jieba的分词结果对专有名词(如「2型糖尿病」)很不可靠,常切成「2型」「糖尿病」。直接从图谱实体列表里按长度降序匹配,能保证「2型糖尿病」优先被整体匹配而不是被「糖尿病」抢走。意图识别是简单的包含匹配,胜在可控,你可以给每个意图增加负例排除词,避免「我没有药了」这种句子被误判为DRUG意图。
参数说明:py2neo的Graph初始化用bolt协议,端口是7687,和Neo4j浏览器的7474端口不同。_load_entities一次性把所有实体加载到内存,2000个实体内存占用不到5MB,但如果数据量到几万,建议改为查询时用Cypher的WHERE n.name CONTAINS实时匹配。
4.2 组装Cypher查询:把意图和实体拼成可执行语句
拿到意图和实体后,下一步是根据组合关系生成查询。这是整个系统里最容易出错的环节,因为用户问题里可能同时出现疾病和症状,也可能只出现症状。查询模板要覆盖下面这些情况。
def build_query(self, intent, entities): disease = entities.get('Disease') symptom = entities.get('Symptom') dept = entities.get('Department') if intent == 'department': if disease: return (f"MATCH (d:Disease {{name:'{disease}'}})-[:BELONGS_TO]->(dept:Department) " f"RETURN dept.name AS answer") if symptom: return (f"MATCH (s:Symptom {{name:'{symptom}'}})<-[:HAS_SYMPTOM]-(d:Disease)-[:BELONGS_TO]->(dept:Department) " f"RETURN dept.name AS answer, collect(d.name) AS diseases LIMIT 5") if dept: return (f"MATCH (dept:Department {{name:'{dept}'}})<-[:BELONGS_TO]-(d:Disease) " f"RETURN collect(d.name) AS answer LIMIT 10") elif intent == 'drug': if disease: return (f"MATCH (d:Disease {{name:'{disease}'}})-[:RECOMMEND_DRUG]->(drug:Drug) " f"RETURN collect(drug.name) AS answer") if symptom: return (f"MATCH (s:Symptom {{name:'{symptom}'}})<-[:HAS_SYMPTOM]-(d:Disease)-[:RECOMMEND_DRUG]->(drug:Drug) " f"RETURN drug.name AS answer, d.name AS disease LIMIT 5") elif intent == 'symptom': if disease: return (f"MATCH (d:Disease {{name:'{disease}'}})-[:HAS_SYMPTOM]->(s:Symptom) " f"RETURN collect(s.name) AS answer") return None逻辑说明:每个return的模板都按「实体类型→意图」的组合拆开,症状查科室时用了一次两跳查询,先由症状找疾病、再由疾病找科室,同时返回疾病列表作为追溯依据。症状查药品同理。调用方拿到build_query返回的Cypher字符串后,用py2neo执行并把结果格式化。
参数说明:这里直接把实体名拼进Cypher有注入风险,但因为是本地demo且实体名来自图谱自身,风险可控。如果你要部署成对外服务,务必改用参数化查询——py2neo的graph.run(query, name=entity)会把实体名作为参数传参,而不是拼接字符串。
4.3 搭一个Flask接口:让问答系统可被HTTP调用
知识问答系统最后要有一个可演示的入口,Flask是最轻的方案。一个/qa的POST接口接收JSON格式的问题,返回答案文本。我习惯把MedicalQA类实例化一次,放在模块级别而不是每次请求都重建,因为_entities加载和Neo4j连接池复用能省掉大量重复开销。
from flask import Flask, request, jsonify app = Flask(__name__) qa = MedicalQA() # 模块级单例,避免每次请求重建实体词典 @app.route('/qa', methods=['POST']) def qa_endpoint(): data = request.get_json() question = data.get('question', '') if not question.strip(): return jsonify({'answer': '问题不能为空'}), 400 intent = qa.parse_intent(question) entities = qa.extract_entities(question) cypher = qa.build_query(intent, entities) if not cypher: return jsonify({'answer': '暂未找到相关答案,试试换个问法'}) results = qa.graph.run(cypher).data() if not results: return jsonify({'answer': '图谱中暂无此疾病的完整信息'}) answer = qa.format_answer(intent, results) return jsonify({'answer': answer, 'intent': intent, 'entities': entities}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)逻辑说明:返回的JSON里除了answer还带intent和entities字段,这在实际调试时非常有用——前端或Postman调用后能直接看到系统理解成了什么意图、抽到了什么实体,而不是只能看到一个答案字符串。format_answer方法把查询结果拼成自然语言,比如把collect返回的药品列表用顿号连接后拼上「建议在医生指导下使用」。
参数说明:host='0.0.0.0'表示监听所有网卡地址,这样答辩时可以在另一台电脑用http://你的IP:5000/qa调接口;debug=True开发时用,但答辩演示时建议关掉,否则出错会弹出Werkzeug的调试页面。
5. 避坑与排查:医疗知识图谱问答系统最常见的5个翻车点
5.1 Neo4j导入中文乱码:现象、原因、解决
现象:LOAD CSV导入后,节点name属性显示为ç³å°¿ç之类乱码,或者浏览器里中文正常但查询条件匹配不到。
原因:CSV文件编码不是UTF-8。最常见是Excel另存时用了ANSI(GBK),或者带BOM头。Neo4j在Linux下默认按UTF-8读取,遇到GBK编码直接解码错误。
解决:清洗脚本导出时统一用encoding='utf-8-sig',这是最省心的方案。如果文件已经生成,用VS Code或Notepad++把编码转为UTF-8。注意转换后重新确认CSV列名没有乱码,列名乱码会让row.疾病这种引用直接失效。
5.2 实体对齐失败导致MERGE出重复节点:现象、原因、解决
现象:导入后查询某疾病,发现返回多条同名记录,count(Disease)远超预期的疾病数。
原因:CSV里同一疾病有不同写法,比如「头痛」和「头疼」、「2型糖尿病」和「II型糖尿病」。MERGE只在name属性完全一致时才合并,任何字符差异都会创建新节点。
解决:在清洗脚本里维护同义词映射表,或者用Neo4j里apoc.merge.node配合归一化函数做模糊合并。更实用的做法是在导入前跑一遍所有实体名的distinct列表,人肉扫一遍,把明显同义的字词手工写到disease_map和symptom_map里。这个活枯燥但值,因为这直接决定问答的召回率。
5.3 问答返回空结果但图谱里明明有数据:现象、原因、解决
现象:用户输入「头疼挂什么科」,系统返回「未找到相关答案」,但在Neo4j浏览器里手动查「头疼」这个症状节点是有关系的。
原因:实体抽取阶段把「头疼」匹配成了「头痛」或者没匹配上。最大匹配算法是按实体列表长度降序遍历的,如果图谱里只有「头部疼痛」而没有「头疼」,天然匹配不上。
解决:维护一个问法别名表,把高频口语词映射到图谱实体名。在extract_entities返回前加一层映射:entity = alias_map.get(matched, matched)。另一个办法是模糊匹配,用difflib的get_close_matches找相似度大于0.8的实体名,但要注意「痛」和「疼」的拼音相近但字不同,difflib的字符相似度对中文字符不太友好,更适合的做法是把别名表做厚。
5.4 Neo4j连接失败,bolt协议端口不通:现象、原因、解决
现象:py2neo初始化时报ConnectionError: Cannot connect to host localhost:7687,或bolt://握手失败。
原因:三件事最常出问题——Neo4j服务没启动、neo4j.conf里没开启Bolt监听、防火墙拦了7687端口。Windows下还常见Neo4j安装为桌面版,Bolt默认监听localhost,如果你改了server.bolt.listen_address为0.0.0.0但没重启服务。
解决:先访问http://localhost:7474确认Neo4j浏览器能打开,再看配置文件neo4j.conf里的server.bolt.listen_address=0.0.0.0:7687是否被注释。改完配置要重启Neo4j服务,bin/neo4j restart。用Python端graph = Graph("bolt://localhost:7687", auth=("neo4j","password"))逐项排查,注意密码改过不要用默认neo4j。
5.5 实体抽取把「糖尿病」和「2型糖尿病」同时命中:现象、原因、解决
现象:问题「2型糖尿病吃什么药」返回的答案包含所有糖尿病亚型的药品,而预期只返回2型糖尿病。
原因:extract_entities只取第一个命中的实体,但在意图匹配阶段,Cypher里可能同时用了两个实体导致查询路径发散。另一个情况是jieba分词把「2型糖尿病」切成「2型」「糖尿病」,图谱里两个实体的关系交叉查询产生噪音。
解决:最大匹配按长度降序处理,确保长实体优先被选中,然后在extract_entities里加一个「长实体命中后跳过所有被包含的短实体」的过滤。具体做法是维护一个已命中实体集合,后续匹配前先检查该实体是否是已命中实体的子串。这个逻辑放在build_query前做过滤,能避免很多歧义。
6. 进阶技巧:把问答准确率从「能用」做到「答辩能吹」
6.1 加一层问题改写:把口语问法映射到图谱标准实体
规则问答系统的天花板不在Neo4j查询,而在实体识别的召回率。一个实测有效的技巧是维护「口语问法→标准实体名」的映射表,并在extract_entities之前做一次问题改写。比如「脑袋疼」改写为「头痛」、「血压高」改写为「高血压」、「拉肚子」改写为「腹泻」。这个映射表不用一开始就建全,运行一段后把未命中的问题收集起来,手动补充映射,两周后准确率能明显涨一截。
question_rewrite = { '脑袋疼': '头痛', '头很疼': '头痛', '头疼': '头痛', '血压高': '高血压', '血糖高': '高血糖', '拉肚子': '腹泻', } def rewrite_question(question): for k, v in question_rewrite.items(): if k in question: question = question.replace(k, v) return question逻辑说明:问题改写发生在实体抽取之前,把口语词先归一化到图谱里的标准实体名,这样最大匹配阶段就能直接命中。注意替换顺序,短的别名词要放在后面替换,否则「头很疼」可能先被「头痛」脚本替换成不存在的「头很痛」。实际运行时把没命中的用户问题存到日志里,每周看一次补充映射表,这是投入产出比最高的优化手段。
6.2 用答案溯源增强答辩演示的说服力
答辩演示时,评委最常问的问题是「你怎么证明这个答案不是写死的」。除了展示Neo4j里的图之外,还可以在API返回结构里增加path字段,返回Cypher查询命中的完整路径。py2neo的graph.run(cypher).data()能拿到所有节点和关系,把它格式化成一条可读的路径字符串,前端展示为「2型糖尿病 → 推荐药物 → 二甲双胍」,直观且可信。
def format_answer(self, intent, results): if intent == 'drug' and results: drugs = list(set([r['answer'] for r in results])) diseases = list(set([r.get('disease', '') for r in results if r.get('disease')])) base = f"「{diseases}」常用的药物包括:{ '、'.join(filter(None, drugs)) }" return base + "。具体用药请遵医嘱,本结果仅供学习参考。" # 其他意图类似逻辑说明:format_answer里加了一个免责声明,这在医疗问答场景下不仅符合主流价值观,也是答辩时自我保护的话术。答案聚合用set去重,是因为同一个疾病关联的药品可能通过多条路径返回,不去重会显得系统很「傻」。
6.3 离线评估脚本:用30条测试题量化系统效果
问答系统的效果要有数字支撑,不能只靠口头演示。我习惯维护一份30条左右的测试集,覆盖每个意图的正例和反例,跑一遍脚本统计准确率和召回率。这个数据可以直接写进毕业论文的测试章节。
test_cases = [ {"question": "头痛挂什么科", "expected_intent": "department", "expected_entity": "头痛"}, {"question": "2型糖尿病吃什么药", "expected_intent": "drug", "expected_entity": "2型糖尿病"}, {"question": "高血压有哪些症状", "expected_intent": "symptom", "expected_entity": "高血压"}, ] def evaluate(qa, cases): hit = 0 for case in cases: intent = qa.parse_intent(case['question']) entities = qa.extract_entities(qa.rewrite_question(case['question'])) if intent == case['expected_intent'] and entities.get('Disease') or entities.get('Symptom'): hit += 1 print(f"准确率: {hit}/{len(cases)}") evaluate(qa, test_cases)逻辑说明:评估脚本不是只看最终答案对错,而是拆成意图命中和实体命中两个维度分别统计,这样能定位系统瓶颈是出在意图识别还是实体抽取。30条测试集里建议包含5条反例,比如「我今天头不疼了」这种不含明确意图的句子,意图识别应该返回default而不是误判为symptom。
我在做这个题目时最深的感受是:知识图谱问答系统的难度不在算法而在数据工程。实体对齐、同义词表、问题改写,这些看起来不「高级」的工作占了我六成时间,但恰恰是它们决定了答辩时演示效果是惊艳还是翻车。如果你按这条路径走,把数据清洗做扎实、把规则匹配做厚、留下一个可解释的评估数字,这个毕设就能稳稳落地。希望帮到你。
本文还有配套的精品资源,点击获取