ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

医疗知识图谱问答系统代码详解:从意图识别到Cypher查询

医疗知识图谱问答系统代码详解:从意图识别到Cypher查询

讲实话,前几篇我们把医疗知识图谱的构建思路、本体设计和数据导入都捋了一遍,这一篇开始进入真正的代码世界。上一篇我们分析了项目的基础架构和配置加载,这一次我准备把问答系统里最核心的那条链路彻底拆开,从用户输入一句话开始,到系统返回答案为止,中间每个环节的代码是怎么写的、为什么这么写、有哪些容易踩的坑,我都想放在这一篇里说清楚。

不夸张地说,医疗问答系统和普通百科问答不一样,它要求实体识别更准、关系约束更严、答案来源可追溯。代码写得糙一点,用户问"头疼吃什么药",系统回一句"未找到相关结果",整个系统就废了。所以我这篇的分析重点不是"代码能跑通",而是"代码怎么保证在医疗场景下尽量不出错"。我会按照问答管线逐层拆解,包括意图识别、实体链接、查询模版组装、图谱查询执行、结果格式化这几个关键环节。即使你目前对知识图谱的代码还比较陌生,只要跟着这篇把逻辑链路理清楚,再回头看那几百行核心代码,你会发现其实远没有想象中那么复杂。

1. 先摸清代码仓库的骨架:问答系统启动后发生了什么

拿到一套问答系统的代码,千万别一头扎进某个具体函数里死磕。我的习惯是先画出一条调用链,搞清楚用户输入一句话后,系统内部到底经历了多少道工序。这套医疗问答系统的代码结构不算大,但阶段划分很清晰,基本可以拆成五个环节:输入预处理、意图识别、实体链接、查询语句组装、图谱执行与答案生成。

1.1 从入口函数到答案返回:一锤子买卖还是流水线作业

很多入门级的问答系统demo都会写成一个大函数,从输入到输出全塞在一起,看起来省事,但后续维护会很痛苦。这套代码选的是流水线方式,每个环节独立成函数,主流程里依次调用,好处是每一段都可以单独测试、单独替换。

从代码主入口看,调用顺序大致是这样的:

def chat(self, question): # 1. 问句预处理 preprocessed = self.preprocess(question) # 2. 判断用户意图 intent = self.intent_recognition(preprocessed) # 3. 抽取问句中的医疗实体 entities = self.entity_linking(preprocessed) # 4. 根据意图和实体拼接查询模版 cypher = self.query_template(intent, entities) # 5. 去图谱里执行查询 results = self.graph_query(cypher) # 6. 把结果转成自然语言答案 answer = self.answer_generator(results) return answer

我拿到代码后干的第一件事,就是把每个函数名抄在纸上,然后用箭头连起来。连完之后整条链路就清楚了。我不建议直接去读那些工具类代码,什么文件读取、日志初始化、配置加载,这些早晚会看,但第一遍分析的时候应该聚焦在主链路上,你先理解了主干,其他枝叶后面自然会找到位置。

这里有个小判断:这套代码为什么把每个环节拆成独立方法,而不是直接在chat里写一大段?因为医疗问答场景里的每一步都可能要单独调整。比如意图识别规则不准,你要去改intent_recognition,不会牵连到实体链接;实体词典扩充了,你只需要动entity_linking。如果全揉在一起,改一个地方担心崩三个功能。这也是我工作中判断一段代码是否值得借鉴的重要标准,模块边界清不清楚。

1.2 我拿到代码后先做的静态检查:不跑程序也能发现的问题

程序还没跑起来之前,我习惯先做一次"静态体检",主要看三件事:配置文件加载路径是否正确、图谱数据库连接参数是否写死、外部依赖是否齐全。

这套代码里有一个让我印象深刻的细节,它把所有查询模版放在一个JSON配置文件中,而不是硬编码在Python里。这意味着如果你以后想新增一种问法,不需要改Python代码,只需要在JSON里加一条规则就行。这个设计我非常认同,它把"程序逻辑"和"业务配置"做了分离。医疗问答里的问法规则会持续演化,如果每次加规则都要改代码重新部署,那效率就太低了。

配置文件里大致长这样:

{ "query_templates": { "disease_medicine": { "category": "疾病用药", "description": "查询治疗某疾病的药物", "cypher_template": "MATCH (d:Disease)-[:HAS_DRUG]->(m:Medicine) WHERE d.name = $disease RETURN m.name", "intent_rule": ["治疗", "吃什么药", "用药", "用什么药"] }, "disease_symptom": { "category": "疾病症状", "description": "查询某疾病的临床症状", "cypher_template": "MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:Symptom) WHERE d.name = $disease RETURN s.name", "intent_rule": ["症状", "表现", "临床"] } } }

我推荐你在读代码时,优先把这种"配置与逻辑分离"的部分找出来,它往往就是骨架里最值得学习的点。后端代码只是把这些配置读取进来,然后根据用户输入去选中合适的模版,逻辑本身非常简单。但正因为简单,它才稳定。

2. 问句解析是重头戏:意图识别和实体抽取到底怎么写

问答系统的第一道关卡,就是弄明白用户到底在问什么。医疗场景里,用户输入千奇百怪,有说"感冒了能吃什么药",也有说"高血压有哪些并发症",更有直接甩一句"帮我看看这个药能不能跟那个药一起吃"的。如果系统连问什么都分不清,后面的图谱查询就是无根之木。

2.1 基于规则模板的意图识别:不是所有场景都需要上深度学习

我见过很多初学者一上来就打算用BERT做意图分类,这个想法本身没有错,但对于一个学习性质的项目,甚至对于很多中小规模的垂直场景,基于规则模板的方式往往更实用、更可控。这套代码采用的即基于trigger word的匹配方式,思路非常朴素:问句中出现某些关键词,就判定为某种意图。

代码逻辑大概是这样:

def intent_recognition(self, question): for intent, config in self.query_templates.items(): for keyword in config["intent_rule"]: if keyword in question: return intent return "unmatched"

真的就这么简单。比如问句里包含"吃什么药",就走疾病用药的意图;包含"症状",就走疾病症状的意图。为什么这种"简单到没技术含量"的方案在医疗问答里反而够用?因为医疗问题相对聚焦,问法虽然多样,但核心的功能诉求是有限的。通常一个医疗图谱问答系统支持的问法不会超过20种,每种问法找几个代表性的trigger词,覆盖面就相当可观了。

用规则匹配还有一层好处,就是可解释性极强。哪天系统分错了类,你一眼就能看出是哪个关键词没触发,或者哪个关键词触发了错误的意图。换深度学习模型,你还得去翻attention权重,在医疗这种对错误容忍度极低的场景,可解释性有时候比准确率还重要。

2.2 医疗实体链接:词典匹配背后的AC自动机逻辑

意图识别只是告诉系统"问题属于哪个大类",真正决定查询质量上限的,是实体链接。也就是说,你得把用户问句里的"感冒"识别成知识图谱里那个Disease节点。这一步做不好,后面全白搭。

这套代码的实体链接用的不是命名实体识别模型,而是词典匹配。代码先把所有实体名称装载到一个数据结构里,然后对问句做匹配。为了提高多实体匹配的效率,我看到它用了AC自动机算法。这个算法对于做过字符串匹配的朋友来说应该不陌生,它可以在一遍扫描中对多个模式串同时匹配,避免了逐个实体遍历的低效。

如果不做任何优化,最笨的方法是:遍历词典中的每个实体名称,到问句里去查找是否存在。假设词典有1万个词,问句长度平均10个字,那每次查询就是1万次字符串查找,系统必然卡顿。引入AC自动机后,复杂度显著下降,因为它在预处理阶段构建了fail指针,匹配过程只需要线性扫描问句文本即可。

class EntityLinker: def __init__(self): self.actree = ahocorasick.Automaton() def build_index(self, entity_dict): # 把所有实体名称加入AC自动机 for entity, entity_type in entity_dict.items(): self.actree.add_word(entity, (entity_type, entity)) self.actree.make_automaton() def extract_entities(self, question): entities = [] for end_index, (entity_type, entity) in self.actree.iter(question): start_index = end_index - len(entity) + 1 entities.append((entity, entity_type, start_index)) return entities

这里我要提醒一句,AC自动机匹配返回的结果里,小实体可能会被大实体覆盖。比如图谱里同时存在"感冒"和"感冒颗粒",用户问"感冒颗粒能不能吃",AC自动机会同时匹配出这两个词。如果代码不做长度优先的过滤,很可能把"感冒"也当成一个疾病实体输出,从而干扰查询。

这套代码有没有做这个处理?我在代码里看到了一个deduplicate函数,它的策略简单粗暴但有效:按实体长度排序,较长的实体保留,完全落在长实体区间内的短实体丢弃。这个细节看似不起眼,实际非常影响查询准确率,你后面自己写实体链接时也应该带上这一步。

2.3 把一条真实问句走完整个解析链

光说理论没意思,我把实际运行的过程模拟一遍。假设用户输入:"高血压患者吃什么药比较好?"

第一步,预处理环节把句子清洗一下,去掉语气词、疑问词,得到核心句"高血压吃什么药"。注意,这里并没有用复杂的分词组件,而是简单做了停用词过滤,因为后续的实体匹配并不依赖分词结果。这一点很关键,医疗实体名称通常是专业术语,很多是无法被常规分词器识别的复合词,你不能让分词错误连锁影响实体抽取。

第二步,意图识别遍历模版配置,发现"吃什么药"命中疾病用药的trigger词,于是判定意图为disease_medicine。

第三步,实体链接在问句里用AC自动机遍历,匹配出"高血压"这个实体,类型为Disease。

第四步,根据意图和实体,拼接出这样一条Cypher查询:

MATCH (d:Disease)-[:HAS_DRUG]->(m:Medicine) WHERE d.name = "高血压" RETURN m.name

看到这里你会发现,整条链路的关键就在于"意图识别和实体链接得到的参数能不能对上图谱的结构"。如果实体类型是Disease,而查询模版里第一个节点也是Disease,那查询就能顺利执行;如果实体链接把"高血压"识别成了Drug,那这条查询就会返回空结果。这也是我一直强调的,实体类型和查询模版之间的对齐关系,是这类系统最容易出错但最难排查的地方。

3. 从问句到Cypher:查询模版的组装逻辑值得细品

很多刚接触知识图谱问答的朋友都会问一个问题:为什么要用模版拼Cypher,而不是让模型直接生成Cypher语句?我在看这套代码的时候,也认真想过这个设计取舍。这里面的门道,其实比表面看起来更多。

3.1 为什么选择模版组装而非自然语言生成Cypher

大模型可以直接把"高血压吃什么药"转成Cypher查询语句,看起来非常智能。但放到这个项目的定位里,模版组装是更稳的方案。原因有三点。

第一,可控性。模版是预先定义好的,每个模版对应一类固定问法,查询语句的结构是确定的。这意味着系统只会按照你允许的方式去查图谱,不会生成出乎意料的查询。医疗场景里,查询结果错可以接受,但查询过程不可控是一个更大的隐患。

第二,可调试性。模版方案几乎没有随机性,同一个问题不管问多少次,生成的Cypher永远一样。这对系统维护人员来说是极大的福音。如果你用的是模型直接生成Cypher,某天线上出了一个异常查询结果,你得先复现,然后去猜是哪次生成语句走了样,整个过程非常折磨。

第三,资源成本。跑一个微调过的生成模型需要的显存、推理延迟,不是一个教学项目或者中小团队能轻松承受的。模版方案在CPU机器上也能跑得很流畅。

这套代码把模版配置放在JSON里,配合意图识别返回的意图名称,去选中对应的模版,然后用参数把模版里的占位符替换掉。这种"数据驱动配置"的做法我特别推荐,你以后在生产环境里改一版问法规则,不需要重新发版,改配置即可。

3.2 Cypher拼接的细节:参数化查询和注入隐患

模版拼Cypher,最忌讳的就是把用户输入直接硬拼进查询语句。假设用户问的是某个实体包含特殊字符,或者有人恶意构造查询注入,后果很严重。这套代码里我看到它没有直接把实体字符串嵌入Cypher语句,而是采用了参数化查询的方式。

def build_query(self, intent, entities): template_config = self.query_templates[intent] cypher_template = template_config["cypher_template"] entity_name = self._get_main_entity(entities) # 提取主实体 return self.driver.query(cypher_template, {"disease": entity_name})

注意这里的用法,把实体名称作为参数传进查询方法,由驱动的参数化机制去处理转义问题,而不是手动拼字符串。这是我在任何涉及数据库查询的项目里都会反复强调的一条底线:能参数化就参数化,能不拼字符串就不拼字符串。

也许有读者会觉得,医疗问答系统又不是公网服务,拼一下字符串有什么关系?我见过太多demo项目因为偷懒硬拼接,结果实体名称里带个引号就直接把查询语句搞挂了。你维护的是知识图谱里的真实数据,实体名称里出现引号、括号的概率并不低,比如某些药品名称会带有商品名括号注释,一拼字符串,查询就崩了。使用参数化,一步到位规避风险。

3.3 关系方向是查询质量的隐蔽决定因素

关于模版设计,还有一个特别容易被忽视的点:关系方向。知识图谱的边是有方向的,你写(d:Disease)-[:HAS_DRUG]->(m:Medicine)和写(m:Medicine)-[:HAS_DRUG]->(d:Disease),语义完全不同。

我在这套代码的模版配置里注意到一个细节,每种模板的cypher都经过人工校验,确保关系的方向和语义一致。比如疾病用药,语义是"疾病拥有药物",所以方向从Disease指向Medicine;疾病症状,语义是"疾病表现出症状",所以方向从Disease指向Symptom。

这是一种"谓词方向的规范化"。实际建图谱的时候,我们约定关系名和方向都遵循一套统一标准,这样查询模版才能稳定复用。如果你建的图谱方向混乱,比如这次往左指,下次往右指,那么模版表就会变得无比冗余,因为同一个语义可能对应多种方向写法。所以我的建议是:在建图阶段就要把方向作为一等公民对待,宁可多花时间规范,也别让下游查询为混乱的方向买单。

4. 图谱查询与答案生成:Neo4j连接和结果后处理

意图识别、实体链接、模版组装这前三步做得再好,图谱查询这一步如果效率低、结果处理不干净,用户体验照样上不去。这部分的代码不多,但我认为很值得展开说说。

4.1 连接Neo4j的几种姿势:驱动连接和事务处理

这套代码用的是Neo4j官方Python驱动,连接参数从配置文件读取。驱动连接的写法,大致是这样的套路:

from neo4j import GraphDatabase class Neo4jClient: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def query(self, cypher, parameters=None): with self.driver.session() as session: result = session.run(cypher, parameters) return [record for record in result]

官方驱动的使用没有什么花哨的地方,但有几个细节值得注意。第一,连接实例应该是全局复用的。有的初学者习惯每次查询都新建一个driver,这会导致每次查询都经历完整的认证握手流程,查询延迟会高得离谱。正确做法是程序启动时创建一次driver,后续所有查询都复用它。

第二,使用session上下文管理器,确保会话被正确释放。你不需要手动去close,上下文管理器会自动处理。如果你使用grafana或者一些中间件来连接Neo4j,它们的连接池管理方式是另一套逻辑,但学习阶段用官方驱动是理解底层原理的最佳路径。

这套代码在查询失败时有一个简单的异常捕获,把错误信息打到日志后返回空列表。这个策略面对生产环境可能不够精细,但对学习项目完全够用。真正生产环境中,你要区分超时、连接拒绝、认证失败、语法错误等情况,分别做不同的告警或降级策略。

4.2 从Cypher结果到自然语言答案:格式化输出的细节

查询结果是一堆记录,直接原样返回给用户肯定是不能用的。比如查"高血压吃什么药",图谱返回的可能是一串药品名称列表,你需要把它拼成一句通顺的自然语言。

这套代码的答案生成逻辑并不复杂,思路是:根据意图类型选择回答模板。如果是疾病用药,就把药物名称列表拼成"根据知识图谱,治疗高血压的常用药物包括:硝苯地平、氨氯地平、……";如果是疾病症状,就拼成"高血压的常见症状包括:头晕、头痛、……"。

def answer_generator(self, intent, entities, results): if not results: return "小知识图谱暂时没有找到相关答案,换个问法试试吧。" if intent == "disease_medicine": medicines = [record["m.name"] for record in results] entity = entities[0][0] return f"根据知识图谱,治疗{entity}的常见药物包括:" + "、".join(medicines) elif intent == "disease_symptom": symptoms = [record["s.name"] for record in results] entity = entities[0][0] return f"根据知识图谱,{entity}的常见症状包括:" + "、".join(symptoms)

虽然这段代码看起来只是简单的字符串拼接,但里面体现了一个重要思想:答案生成和检索分离。查询语句负责把候选结果找出来,答案生成层负责决定用户看到什么样的表达方式。这样设计的好处是,你以后想调整提示语风格,比如更口语化、更委婉,只要修改answer_generator,不需要动图谱查询逻辑。

这里我还要强调一个容易踩坑的问题:Cypher返回的字段别名。如果你在Cypher里写RETURN m.name,在Python里读取记录时要用record["m.name"],这个带点的键名很容易写错。更稳妥的做法是在Cypher里用AS设置别名,比如RETURN m.name AS name,然后Python那边用record["name"]读取。我看到这套代码里就有一部分模版没有设置别名,直接读取带点键名,确实能跑通,但可读性略差。你自己写代码时,我推荐显式使用别名。

4.3 空结果的正确处理比答案本身更考验设计

医疗问答系统里,空结果的出现频率比想象中高。用户问"高血压吃什么药",如果图谱里这个疾病节点压根没有挂任何药物关系,查询就返回空列表。这时候,如果系统直接回一句"未找到相关结果",用户就会觉得系统很蠢。

我在这套代码里看到它空结果的处理是返回一句相对温和的兜底语句,同时引导用户换一种问法。但你如果想让系统更完善,可以考虑在检测到空结果时,做一次"近义实体"的尝试。比如用户问的是"高血压",图谱里存的可能是"原发性高血压",那你可以用实体链接阶段的同义词表做一次放宽匹配,再查一次。这种多级查询策略在真实产品中非常常见,第一轮严格查询,第二轮放宽查询,两轮都空了才放弃。这套代码里没做这个二级检索,属于教学项目可以接受的范围,但值得你自己动手扩展。

5. 跑起来之后才能看见的坑:我调试时遇到的四类问题

前面聊的都是代码结构层面的东西,这一节我想换个角度,聊聊真正把项目跑起来之后,我在调试过程中频繁遇到、也最容易让新手不知所措的四类问题。这些问题不看日志根本猜不到原因,希望可以帮大家省去重复的排查时间。

5.1 同义词换代问题:图谱里存的是"药品"还是"药物"?

我第一次用测试数据跑系统的时候,输入"感冒吃什么药",返回一切正常。但当我换成"感冒用什么药",直接返回了空结果。当时我的第一反应是意图识别没命中,后来去翻日志才发现,问题出在词典里没有"什么药"对应"治疗药物"的说法,导致意图走到了unmatched。

排查过程是这样的:先看意图识别结果,确认走到了disease_medicine;再看实体链接结果,确认"感冒"被成功提取;最后把生成的Cypher单独拿出来,放到Neo4j浏览器里手动执行,结果发现查询本身没问题,能返回数据。这一下我确定了,问题出在意图识别的trigger词覆盖不全,后来在配置里补充了"什么药"才算解决。

这个经历告诉我,测试的时候一定不能只测标准问法,要多换几个同义词、近义词甚至口语化表达去试。医疗场景里同一个概念的说法实在太多了,医生口中的"高血压",患者口中可能是"血压高",药品说明书里可能是"EH"。你在扩充词典和trigger词时,要刻意引入口语化变体,而不是只收标准术语。

5.2 关系方向写反导致查询返回空数据

这个问题我有一次排查了很久。当时新增了一个"疾病预防"的查询需求,模版里我写了一条Cypher:

MATCH (d:Disease)-[:PREVENT]->(t:Treatment) WHERE d.name = $name RETURN t.name

手动在Neo4j里执行,返回为空。我以为是数据没建好,跑去检查图谱数据,发现这个查询对应的关系在数据里确实存在,但方向是(Treatment)-[:PREVENT]->(Disease)。

原因就出在建图阶段导入数据时,按照CSV里的方向把预防关系写反了。所以调模版之前,建议先把真实数据里关系的方向和类型完全摸清楚。你可以用一句Cypher去检查:

MATCH (n)-[r]-(m) WHERE n.name = "感冒" RETURN n, r, m

这句查询加上了中括号,不指定方向,会把所有跟"感冒"有关的关系和节点全部返回。看到边的箭头方向,你就知道真实的数据结构了。这类问题和代码逻辑无关,纯粹是数据与查询之间的契约不一致,排查时最容易被忽略。

5.3 实体相互包含造成的优先级混乱

这是实体链接阶段一个特别经典的坑。我往词典里加了一个实体"感冒颗粒",没过多长时间就发现,用户问"感冒颗粒对感冒有没有用"的时候,系统只识别出了"感冒","感冒颗粒"被AC自动机丢掉了,或者更准确地说,被实体去重逻辑误删了。

当时我去看实体链接的输出,发现AC自动机一次性返回了"感冒颗粒"和"感冒"两个实体,但随后执行的去重逻辑是按"保留较长实体"的策略处理的,按理说应该保留"感冒颗粒",可实际的query组装环节只取了第一个实体,又刚好是短的那个,导致实体错位。

解决方案很简单,在实体去重之后增加按实体类型优先级的排序逻辑。比如当问句中同时出现Disease和Medicine实体,如果意图是疾病用药,则优先使用Disease实体作为查询主体。本质上,实体类型和意图的匹配优先级需要显式配置,不能依赖词典插入顺序。这套代码里其实已经留了优先级配置的接口,但默认值不够精细,你在使用时要根据自己图谱的类型分类做适配。

5.4 性能问题:首次查询慢和重复查询没有缓存

Neo4j本身查询性能不差,但在几十万节点的图谱里做全库字符串匹配,第一次查询仍然会明显卡顿。尤其是实体链接阶段,如果词典没有被加载到内存中,每次查询都要重新建立AC自动机,这个时间开销是灾难级的。

我检查了这套代码,发现它把AC自动机构建放在了一个初始化函数里,也就是说只在系统启动时构建一次,这一点做得比较好。但另外一个问题是,它没有做问句级别的缓存。同一个问句,用户短时间内重复问两次,系统会原封不动地再查一遍图谱,再拼一遍答案。

对于学习项目这是可以接受的,但如果你想把系统接到一个真实聊天机器人后端,我建议至少在问答层加一个简单的缓存字典,以问句为key,以答案为value,设置过期时间。这个优化别看简单,对于高频重复问题能省下大量图谱查询的开销,也让系统响应快不少。

6. 从这套代码出发,你能学到什么以及往哪扩展

分析到这儿,这套医疗问答系统的核心代码基本已经拆得差不多了。最后一个部分,我想跳出代码细节,从更高的视角说说这套代码的学习价值,以及如果你打算继续往深处走,还有哪些路可以走。

6.1 这套代码最值得学习的地方

总结下来,我认为它有五个优点:流程清晰、配置分离、结果可解释、步骤可独立测试、技术栈通用。其中"步骤可独立测试"这一点对我的启发最大。主流程里每个环节都是一个独立的函数,输入输出都很明确,这就意味着我可以为每个环节单独写测试用例。比如单独测试意图识别、单独测试实体链接,某一天某一步出了问题,我不用端到端调试,直接把单测跑一遍就能缩小问题范围。

这种模块化思维,比你学会某个特定算法更有迁移价值。将来你换一个领域,不从医疗知识图谱换成法律知识图谱,代码框架依然可以复用,只需要换掉词典、换掉模版配置、换掉数据本体。这也是为什么我一直建议把业务数据与程序逻辑分离,你一旦把点对点的业务细节隔离在配置层,程序主体就有了很高的复用性。

6.2 如果要继续扩展,可以往哪些方向努力

如果你已经把这个系统跑通,又觉得不过瘾,我建议往这三个方向尝试。

第一,给实体链接引入更聪明的语义理解。现在基于词典的匹配只能覆盖词典里已经收录的实体,遇到"高血压"没问题,遇到"血压偏高"这种口语化同义表达就无能为力。你可以引入一个同义词扩展表,把所有实体的别名、缩略语也加入AC自动机的词典,这样覆盖面会有明显提升。

第二,把单意图问答扩展到多轮对话。现在的系统每次独立处理一条问句,不会参考上下文。但如果用户先问"高血压有什么症状",再问"那吃什么药呢",系统就不知道"那"指的是"高血压"。要实现多轮回复,你需要引入一个上下文槽位管理模块,把前几轮识别出的实体暂存下来,在当前问句缺少主实体时自动填充。这个工程改造虽然不算复杂,但很能锻炼系统设计能力。

第三,增加答案溯源。医疗问答的答案最好能给出来源,比如"根据最新的医学指南,高血压的一线用药包括……"。你可以给知识图谱的每个关系增加一个source属性,记录这条知识的来源文献或指南名称,然后在答案生成时附注来源。这一层对使用体验的提升非常明显,也是真实医疗问答产品不可或缺的部分。

6.3 踩过坑之后,我的一点个人执行习惯

代码分析到这里,我自己的最大收获不是什么高深算法,而是建立起了一条稳定的调试流程。拿到一个问答项目,我会先读配置再读主链,对着数据做检查再改代码,每次改完都要做回归测试。看起来机械,但确实能避免大部分低级错误。

我在给这个项目做评测时,习惯准备一份覆盖各意图类型的问句测试集,至少包括标准问法、口语化问法、含多实体的问法、故意刁难的超长问法几类。每次改完代码,把这套测试集跑一遍,逐条比对输出结果,多少低级问题都能在发布前暴露出来。哪怕你今天只学会这一招,我觉得这套代码就算没白看了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进