☰
医疗知识图谱问答系统代码架构与模块协作解析
2026/10/16 15:00:22 网站建设 项目流程

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 results

3.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 features

4.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 提升准确率的三个发力点

根据我的实操经验,提升这套系统准确率性价比最高的三个方向是:

  1. 扩充和清洗实体词典。这是投入产出比最高的,词典质量直接决定实体识别上限。
  2. 增加意图训练样本。每个意图至少准备两百条以上样本,覆盖不同问法。
  3. 优化答案模板。模板的自然程度直接影响用户体验,多准备变体、多打磨措辞。

7.3 性能优化的注意事项

词典匹配在问题很长时可能变慢。优化思路是先做一次快速预筛,用简单的关键词判断问题是否包含任何医疗实体,如果不包含就直接走兜底回复,跳过后续所有模块。这个预筛能挡掉相当一部分无关问题,整体响应速度提升明显。

另外,图谱查询如果频繁访问数据库,建议加一层缓存,把高频查询的结果缓存起来。医疗问答的查询重复率其实很高,“高血压症状”这类问题每天可能被问几百次。

8. 我对这套代码结构的整体评价

捋完这套代码,我最大的感受是:它不追求技术上的先进,但追求工程上的可靠。没有用最前沿的模型,没有炫技的架构,但每个模块都职责清晰、可调试、可替换。对于想入门知识图谱问答的人来说,这是一套很好的学习材料,因为它的每一行代码你都能看懂、能改。

我自己在复现和改造的过程中,最大的收获不是学会了某个具体技术,而是理解了“在垂直领域做问答,稳定性和可解释性比模型参数量重要得多”。这个认知后来在我做其他领域的问答系统时一直受用。

如果你正在读这套代码,我的建议是别急着改,先按调用链把数据流走一遍,在关键节点打上日志,看着数据一步步变形,比读十遍代码都管用。等你对数据流了然于胸了,再动手改,会发现顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询