1. 从零读懂医疗问答系统的代码骨架
很多人在跑通一个知识图谱问答Demo之后,最大的困惑不是“它能不能跑”,而是“它到底怎么跑起来的”。我当初拿到这套基于医疗知识图谱的问答系统代码时,第一反应也是懵的——文件一大堆,实体识别、意图分类、图谱查询、答案生成各占一块,彼此之间靠什么串起来?后来我花了两天时间把整个调用链从头到尾捋了一遍,才算真正看明白。
这篇内容就是把我梳理这套代码的完整思路分享出来,重点讲清楚三件事:整个系统的模块是怎么划分的、每个模块的核心代码在干什么、以及模块之间是怎么协作完成一次问答的。适合已经跑通过Demo、想进一步读懂代码逻辑的人,也适合准备自己动手改一套垂直领域问答系统的朋友。读完之后,你应该能对着代码说出“用户输入一句话之后,数据在系统里到底经历了什么”。
先说结论性的判断:这套系统的代码结构是典型的管道式架构,用户问题从进入系统到输出答案,依次经过预处理、实体识别、意图分类、图谱查询、答案组装五个阶段。每个阶段是一个相对独立的模块,模块之间通过约定好的数据结构传递信息。理解了这个管道模型,再看代码就不会迷路。
2. 系统整体架构与模块划分思路
2.1 为什么采用管道式架构而不是端到端模型
这套系统最核心的设计决策,是选择了“管道式”而不是“端到端”。所谓端到端,就是训练一个大模型,输入问题直接输出答案;管道式则是把问答拆成多个步骤,每个步骤各司其职。
为什么选管道式?我分析下来有三个现实原因。第一,医疗领域对准确性要求极高,端到端模型容易产生看似合理实则错误的答案,而管道式每一步都可追溯、可干预。第二,知识图谱本身就是结构化的,用管道式能充分发挥图谱查询的精确性优势,把“理解问题”和“查询知识”分开处理。第三,开发和调试成本低,哪个环节出问题就改哪个环节,不用重新训练整个模型。
提示:如果你要做的是开放域的闲聊问答,管道式可能过于笨重;但只要涉及垂直领域的精确问答,管道式几乎是首选方案。
2.2 五大模块的职责边界
把代码目录打开,模块划分大致是这样的:
| 模块 | 核心职责 | 输入 | 输出 |
|---|---|---|---|
| 预处理模块 | 清洗、分词、归一化 | 原始问题字符串 | 标准化词序列 |
| 实体识别模块 | 找出问题中的医疗实体 | 词序列 | 实体列表及类型 |
| 意图分类模块 | 判断用户想问什么 | 词序列+实体 | 意图标签 |
| 图谱查询模块 | 按意图和实体查图谱 | 实体+意图 | 候选答案集 |
| 答案组装模块 | 生成自然语言答案 | 候选答案集 | 最终答案字符串 |
这个划分的关键在于职责单一。比如实体识别只管“问题里提到了哪些病、药、症状”,不管用户想问什么;意图分类只管“用户是想问病因还是问用药”,不管具体实体是什么。两者结合,才能定位到图谱中的具体查询路径。
2.3 模块间的数据契约
模块能协作,靠的是约定好的数据结构。我梳理了一下,核心的传递对象主要有三个:
- 实体对象:包含实体文本、实体类型(疾病/药物/症状/检查)、在问题中的位置。
- 意图对象:包含意图标签(如“询问病因”“询问治疗方式”)、置信度。
- 查询结果对象:包含图谱返回的三元组列表、每条结果的来源。
这三个对象在模块间流转,就像接力棒一样。我踩过的一个坑是:早期版本里实体识别返回的是纯字符串列表,没有类型信息,导致意图分类模块无法区分“头痛”是症状还是疾病,经常误判。后来加上类型字段才解决。
3. 实体识别模块的代码细节拆解
3.1 基于词典加规则的双通道识别
医疗实体识别的难点在于:疾病名、药物名往往很长,而且有大量别名。这套代码采用的是词典匹配为主、规则补充为辅的双通道方案。
词典通道的核心逻辑是构建一个前缀树(Trie),把所有已知的疾病、药物、症状词条塞进去,然后对问题做最大正向匹配。为什么用Trie而不是简单的字符串包含判断?因为Trie的匹配复杂度是O(n),n是问题长度,跟词典大小无关。如果词典有十万词条,用遍历方式匹配会慢到无法接受。
规则通道主要处理词典覆盖不到的情况,比如“XX病引起的XX症状”这种嵌套结构,用正则表达式抽取。代码里有一段处理“高血压引起的头晕”的逻辑,就是先用词典匹配到“高血压”和“头晕”,再用规则判断两者是因果关系。
# 词典匹配的核心逻辑示意 def match_by_dict(self, text): results = [] i = 0 while i < len(text): matched = self.trie.search_longest(text[i:]) if matched: results.append(Entity(matched, self.get_type(matched), i)) i += len(matched) else: i += 1 return results3.2 实体类型判定与歧义消解
同一个词在不同语境下可能是不同类型。比如“感冒”既可以是疾病,也可以指症状表现。代码里用了两个策略消解歧义:
第一是上下文窗口。看实体前后各3个词,如果出现“得了”“患有”这类词,判定为疾病;如果出现“有点”“感觉”这类词,判定为症状。
第二是优先级规则。当上下文无法判断时,按“疾病 > 药物 > 症状 > 检查”的优先级取类型。这个优先级是根据医疗问答的实际分布统计出来的,因为用户问疾病相关问题的比例最高。
注意:歧义消解没有百分百准确的方案,实际项目中要留一个人工修正的接口,把高频错误case收集起来迭代词典和规则。
3.3 未登录词的处理经验
词典再全也有覆盖不到的词,尤其是新出现的疾病名或药物商品名。这套代码对未登录词的处理比较保守——直接跳过,不强行识别。我一开始觉得这样会漏掉很多实体,后来想明白了:在医疗领域,识别错误比识别不到更危险。漏掉一个实体,系统最多答不上来;识别错了,可能给出完全错误的医疗建议。
如果你要改进这一点,我的建议是引入一个轻量的序列标注模型作为兜底,但一定要设置高置信度阈值,宁可漏报不可误报。
4. 意图分类模块的实现逻辑
4.1 意图体系的定义方式
意图分类的第一步是定义清楚有哪些意图。这套代码里定义了大约二十种医疗问答意图,我把它归成几大类:
- 疾病属性类:病因、症状、并发症、易感人群、治愈率
- 治疗相关类:治疗方式、常用药物、手术方案、预后
- 药物相关类:适应症、用法用量、副作用、禁忌
- 检查相关类:检查项目、检查意义、正常值范围
- 就医指导类:挂什么科、去哪看、注意事项
意图定义的关键是粒度适中。太粗了,比如只分“问疾病”和“问药物”,查询时无法定位到具体属性;太细了,比如把“病因”拆成“遗传因素”“环境因素”“感染因素”,分类器根本学不出来。
4.2 分类器的选型与训练
代码里用的是朴素贝叶斯加关键词特征的方案。为什么不用深度学习?我分析下来,主要是训练数据量的问题。这套系统的意图训练样本只有几千条,用BERT这类模型容易过拟合,反而不如朴素贝叶斯稳定。
特征工程上做了三件事:第一,把问题分词后的词袋作为基础特征;第二,加入实体类型作为特征,比如“问题中包含药物实体”对判断“询问药物副作用”意图很有帮助;第三,加入疑问词特征,比如“为什么”强烈指向病因类意图。
# 特征构造示意 def extract_features(self, words, entities): features = {} for w in words: features['word_%s' % w] = True for e in entities: features['entity_type_%s' % e.type] = True for qw in ['为什么', '怎么', '什么', '哪些']: if qw in words: features['question_word_%s' % qw] = True return features4.3 意图与实体的联合校验
单独看意图分类结果可能出错,比如“高血压吃什么药”被分成了“询问疾病症状”。代码里加了一层联合校验:如果意图是“询问症状”,但问题里只有药物实体没有疾病实体,就判定为分类错误,回退到“询问药物适应症”意图。
这个校验逻辑看起来简单,但实际效果很好。我实测下来,联合校验能把意图准确率提升五到八个百分点。原因在于医疗问题的意图和实体类型之间有很强的关联性,利用这种关联做约束,比单纯依赖分类器更可靠。
5. 图谱查询与答案生成的核心环节
5.1 从意图到查询模板的映射
意图分类给出标签之后,下一步是把它翻译成图谱查询语句。这套代码用的是模板映射方案:每个意图对应一个预定义的查询模板,模板里留出实体槽位。
比如“询问疾病病因”意图对应的模板是:查询(疾病实体, 病因, ?)。系统把实体识别得到的疾病名填进槽位,就得到一条完整的查询。
为什么用模板而不是让模型自动生成查询?因为图谱查询语言(如Cypher)的语法容错率极低,一个标点错误就查不出结果。模板方案虽然灵活性差,但稳定性极高,在意图数量有限的情况下是最务实的选择。
5.2 多跳查询的处理策略
有些问题需要多跳查询才能回答。比如“高血压患者不能吃哪些药”,需要先查到高血压的禁忌药物,再查这些药物的详细信息。代码里对多跳查询的处理是分步执行:先执行第一跳拿到中间结果,再把中间结果作为第二跳的输入。
分步执行的好处是每一步都可调试。我遇到过第二跳查不出结果的情况,通过打印中间结果发现是第一跳返回了空集,问题出在实体识别把“高血压”识别成了症状而不是疾病。如果是一步到位的复杂查询,这种问题很难定位。
5.3 答案组装的自然语言化
图谱查询返回的是三元组,比如(高血压, 病因, 遗传因素),但用户期待的是自然语言答案。答案组装模块做的就是“三元组转自然语言”的工作。
代码里用了模板填充加同义词替换的方案。每个意图对应一组答案模板,比如病因类意图的模板是“{疾病}的常见病因包括:{病因列表}”。病因列表用顿号连接,如果超过五项就截断并加“等”。
提示:答案模板要准备多个变体,随机选择,避免所有回答都是同一个句式,那样用户体验很机械。
5.4 置信度过滤与兜底回复
不是每次查询都能得到可靠答案。代码里设置了两道过滤:第一道是图谱查询结果为空时,返回兜底话术“抱歉,我暂时没有找到相关信息”;第二道是查询结果置信度低于阈值时,返回“我找到了一些相关信息,但不确定是否准确,建议咨询专业医生”。
这个兜底机制在医疗场景里特别重要。我始终坚持一个原则:医疗问答系统宁可说“不知道”,也不能给出可能有害的错误信息。
6. 实操中踩过的坑与排查技巧
6.1 实体识别漏识别导致查询为空
这是最常见的问题。用户问“糖尿病能吃西瓜吗”,系统只识别出“糖尿病”,没识别出“西瓜”,因为西瓜不在医疗词典里。结果查询变成“查询(糖尿病, 饮食禁忌, ?)”,返回一堆结果但跟西瓜无关。
排查方法:在实体识别模块加日志,打印每个问题的识别结果,人工抽查漏识别情况。解决思路有两个:一是扩充词典,把常见食物加进去;二是对查询为空的情况做二次识别,放宽匹配条件。
6.2 意图分类混淆导致答非所问
“冠心病怎么检查”和“冠心病怎么治疗”只差一个词,但意图完全不同。早期版本经常把这两个搞混。后来我在特征里强化了“检查”“治疗”这类关键词的权重,并增加了“检查类词汇”和“治疗类词汇”两个特征组,混淆率明显下降。
6.3 图谱数据质量问题
图谱查询查不到结果,有时候不是代码问题,而是图谱本身缺数据。我遇到过“查询某种罕见病的并发症”返回空,排查半天发现图谱里根本没录入这种病。所以调试时一定要先确认图谱里有没有对应数据,别一上来就怀疑代码。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 查询结果为空 | 实体漏识别 | 检查实体识别日志 |
| 查询结果为空 | 图谱缺数据 | 直接查图谱验证 |
| 答案答非所问 | 意图分类错误 | 检查意图标签 |
| 答案不完整 | 模板槽位缺失 | 检查模板填充逻辑 |
| 系统响应慢 | 词典匹配效率低 | 检查Trie实现 |
6.4 多轮对话的上下文丢失
这套系统主要是单轮问答,但用户经常会追问。比如先问“高血压吃什么药”,再问“那副作用呢”。第二问里没有实体,系统就懵了。代码里对这种情况的处理是继承上一轮的实体,但实现得比较粗糙,只在意图分类时做了简单继承。如果要做得更好,需要引入对话状态跟踪模块,把历史实体和意图都维护起来。
7. 代码改造与扩展的实用建议
7.1 换一个垂直领域要改哪些地方
如果你想把这套系统从医疗换成法律、金融或其他领域,需要改动的核心有三处:第一是实体词典,换成目标领域的术语表;第二是意图体系,重新定义领域内的常见问题类型;第三是查询模板,根据新图谱的schema重写模板。
不用改的是:管道式架构、实体识别的Trie逻辑、意图分类的框架、答案组装的模板机制。这也是管道式架构的优势——换领域只需要换零件,不用换发动机。
7.2 提升准确率的三个发力点
根据我的实操经验,提升这套系统准确率性价比最高的三个方向是:
- 扩充和清洗实体词典。这是投入产出比最高的,词典质量直接决定实体识别上限。
- 增加意图训练样本。每个意图至少准备两百条以上样本,覆盖不同问法。
- 优化答案模板。模板的自然程度直接影响用户体验,多准备变体、多打磨措辞。
7.3 性能优化的注意事项
词典匹配在问题很长时可能变慢。优化思路是先做一次快速预筛,用简单的关键词判断问题是否包含任何医疗实体,如果不包含就直接走兜底回复,跳过后续所有模块。这个预筛能挡掉相当一部分无关问题,整体响应速度提升明显。
另外,图谱查询如果频繁访问数据库,建议加一层缓存,把高频查询的结果缓存起来。医疗问答的查询重复率其实很高,“高血压症状”这类问题每天可能被问几百次。
8. 我对这套代码结构的整体评价
捋完这套代码,我最大的感受是:它不追求技术上的先进,但追求工程上的可靠。没有用最前沿的模型,没有炫技的架构,但每个模块都职责清晰、可调试、可替换。对于想入门知识图谱问答的人来说,这是一套很好的学习材料,因为它的每一行代码你都能看懂、能改。
我自己在复现和改造的过程中,最大的收获不是学会了某个具体技术,而是理解了“在垂直领域做问答,稳定性和可解释性比模型参数量重要得多”。这个认知后来在我做其他领域的问答系统时一直受用。
如果你正在读这套代码,我的建议是别急着改,先按调用链把数据流走一遍,在关键节点打上日志,看着数据一步步变形,比读十遍代码都管用。等你对数据流了然于胸了,再动手改,会发现顺畅很多。