尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

知识图谱+向量检索:医疗问答系统的混合检索架构设计

知识图谱+向量检索:医疗问答系统的混合检索架构设计 简介基于知识图谱与向量检索的医疗诊断问答系统完整工程源码及配套文档面向计算机、人工智能、自动化等专业的高校学生与开发者尤其适合作为毕业设计、课程大作业或企业级问答系统的参考实现。压缩包共188个文件大小约23.61MB内含71个Python源码文件覆盖模型训练、意图识别、向量检索与图谱查询等核心模块并有7个CSV训练/测试数据集、8个Markdown与7个TXT说明文档、4个PDF参考手册以及JSON配置、PKL序列化模型、BAT一键启动脚本等辅助文件目录划分明确便于按功能快速查阅。项目为作者个人毕设答辩评审达98分代码经调试可稳定运行已有75人学习下载。读者可获得完整源码、模型文件、启动脚本及详细文档不仅能直接复现问答流程也可基于现有结构二次开发适合从入门到进阶的医疗NLP实践。1. 医疗问答不识字义再好的大模型也白搭医疗诊断问答系统最容易被低估的环节不是模型而是“检索”。很多人搭出来的 demo 看起来逻辑通顺一问“头痛伴随恶心三天”系统就抓瞎——因为用户问的是症状组合和病程而知识库里存的是孤立实体。只靠向量相似度召回召回回来的可能是“头痛”或“恶心”各自的文档拼不出“偏头痛发作期”的判断链路。这个标题里真正值钱的设计是把知识图谱和向量检索组合成一条混合检索链路图谱负责约束实体关系和推理路径向量负责召回开放表述和长尾症状。两者一横一纵才撑得起诊断问答这种对准确率和可解释性都要求极高的场景。源码和文档是交付载体真正要理解的是这套检索架构怎么设计、参数怎么调、数据怎么清洗。这篇文面向的是已经会写 Python、可能跑过 RAG demo但没系统做过医疗垂直问答的工程师。我会按数据建模、图谱查询、混合检索、系统实现四个层面拆开讲最后落到评测和 badcase 回填这些都是能直接抄走的方案。2. 拆分“检索 推理”双通道为什么单靠图谱或单靠向量都不够2.1 知识图谱的强项是约束弱项是模糊匹配医疗问答一个典型的问题是“胸闷气短一个月稍微活动就加重有没有可能是心衰”这句话里没有出现任何标准医学名词。如果用 Cypher 去直接匹配实体比如MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE s.name 胸闷能命中的只有“胸闷”两个字气短、活动加重、病程一个月这些信息全部丢失。但反过来如果只做向量检索把整句话编码成一个向量医疗实体之间的隐含关系又容易被淹没。比如“胸闷”和“胸痛”在向量空间里距离很近但对于心绞痛和肺栓塞的鉴别诊断这两个症状的含义完全不同。向量不知道“放射痛”“大汗淋漓”这些词绑在一起时意味着什么。提示知识图谱不适合接用户的原始口语适合接经过信息抽取后的结构化事实。向量检索不适合单独做医疗诊断适合做候选召回和症状扩展。所以常见的可靠做法是双通道并行一条通道把用户问题里的医学实体症状、检查、药物抽出来去图谱里查实体关系另一条通道把完整问题做向量化去症状库和疾病文档库里做相似度召回。最后用一个重排模块把两路结果合并。这个方案既保留了图谱的推理和解释能力又补上了向量召回对开放表述的覆盖。2.2 数据建模按诊断逻辑组织节点而不是按学科分类医疗知识图谱的建模核心不是“把数据放进图里”而是“让图结构能支撑诊断推理”。我见过不少项目把疾病、症状、检查、药物四类节点建出来但关系只做了 HAS_SYMPTOM 这一种查询时发现根本回答不了“什么病会导致发热和皮疹交替出现”这种问题。我一般会按诊断链来设计关系类型至少要覆盖以下这些关系类型含义示例HAS_SYMPTOM疾病表现出某症状上呼吸道感染 → HAS_SYMPTOM → 流涕RISK_FACTOR该因素增加患病概率冠心病 → RISK_FACTOR → 高血压病史DIFFERENTIAL与该疾病需要鉴别诊断心绞痛 → DIFFERENTIAL → 心肌梗死NEEDS_EXAM确诊需要做的检查肺炎 → NEEDS_EXAM → 胸部 CTCONTRAINDICATED_WITH药物与疾病禁忌布洛芬 → CONTRAINDICATED_WITH → 消化道出血CONTRADICTS症状互斥辅助排除高热 → CONTRADICTS → 畏寒仅举例节点本身不要只存 name 属性。医疗场景里症状节点要带部位、性质、放射方向、加重/缓解因素、持续时间五个维度。比如“胸痛”这个症状节点属性应该长这样name: 胸痛 部位: 胸骨后 性质: 压榨样 放射: 左肩背部 加重因素: 活动情绪激动 缓解因素: 休息含服硝酸甘油建图时把维度拆成属性查询时用限定词匹配比把所有描述塞进一句话里再让图谱去匹配要可靠得多。这份数据建模会直接影响后面向量检索时的 embedding 质量——KG 里的实体描述写得干净向量化之后才会在语义空间里区分度够高。2.3 信息抽取从用户口语里掰出医学实体用户不会按教科书说话。“我最近老觉得心口堵得慌晚上躺平了更明显”这句话里的实体是“胸闷”心口堵得慌、“夜间加重”躺平了更明显。抽取这一步常见的方案是构建一个自定义的实体识别模块可以基于开源中文医学 NER 模型再叠加规则修正。伪代码思路是2.3.1 症状维度拆解symptom_patterns { 部位: [胸骨后, 左胸, 上腹部, 右下腹], 性质: [压榨样, 针刺样, 烧灼样, 胀痛, 钝痛], 放射: [左肩, 左臂内侧, 下颌, 后背], 加重因素: [活动后, 夜间平卧, 情绪激动后, 进食后], 缓解因素: [休息后, 含服硝酸甘油后, 弯腰后] } def extract_symptom_dimensions(text): result {} for dim, patterns in symptom_patterns.items(): for p in patterns: if p in text: result[dim] p break return result这里的逻辑是分层枚举。部位、性质、放射方向这些维度的取值集合相对固定用规则能覆盖大部分情况。规则匹配不到的词再交给 NER 模型去抽取最后做一次实体链接映射到知识图谱里的标准节点 id 上。不要迷信模型医疗口语的实体边界非常模糊“心口”“胸口”“心前区”指的是同一个部位但 embedding 完全不同规则词典在这个场景下比模型更可控。2.3.2 时间和病程抽取病程信息“三天”“一个月”“一年多”对急慢性判断影响很大但在 text2cypher 里经常被忽略。我一般用正则直接抽取并把结果拼进图谱查询条件(?Pduration\d)\s*(天|周|个月|年)抽到 duration 之后查询图谱时把这个值作为辅助条件拼接比如“急性发热咳嗽”优先考虑呼吸道感染“慢性低热盗汗”则要往结核方向偏移。这个细节很多人不做但恰恰是区分“常见病”和“疑难病”的突破口。3. 图谱查询不只是执行 Cypher如何把实体的属性和关系拼成诊断路径3.1 Neo4j 环境中图谱检索的代码骨架用 Python 操作 Neo4j常见的是用neo4j官方驱动。查询诊断路径的核心不是一句简单的MATCH而是把多个维度的抽取结果拼成动态 Cypher。下面这段代码是从用户输入里取出实体和属性再去图谱里查询候选疾病from neo4j import GraphDatabase class MedicalKGQuery: def __init__(self, uri, user, password, database): self.driver GraphDatabase.driver(uri, auth(user, password), databasedatabase) def query_candidates(self, symptom_ids, duration_categoryNone): cypher MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE s.id IN $symptom_ids WITH d, COUNT(DISTINCT s) AS matched_count WHERE matched_count 2 OPTIONAL MATCH (d)-[:NEEDS_EXAM]-(e:Exam) OPTIONAL MATCH (d)-[:DIFFERENTIAL]-(diff:Disease) RETURN d.name AS disease, matched_count, COLLECT(DISTINCT e.name)[..3] AS exams, COLLECT(DISTINCT diff.name)[..3] AS differentials ORDER BY matched_count DESC LIMIT 10 with self.driver.session() as session: result session.run(cypher, symptom_idssymptom_ids) return [record.data() for record in result]matched_count表示命中的症状数这是最直观的排序权重至少两个症状重叠才会进入候选。OPTIONAL MATCH用OPTIONAL的原因是不是每个疾病节点都挂了 DIFFERENTIAL 关系如果不加这个词会丢掉那些没有鉴别诊断信息的候选疾病。duration_category参数在这里先保留实际使用时把它拼进 WHERE 条件里用于限定急性/慢性分组。提示Cypher 里COLLECT(DISTINCT ...)[..3]这种写法会截断列表避免返回结果太大。如果你发现图谱检索总是超时优先检查 WHERE 条件里有没有用到索引属性以及是不是把整个库的节点都扫了一遍。3.2 属性约束优先于模糊匹配医疗图谱查询常犯的错是期望 Cypher 支持“模糊”。比如WHERE s.name CONTAINS 痛这种写法会扫全库性能极其糟糕。正确做法是给 Symptom 节点建一个索引然后用精确 id 去匹配。前面抽取模块的作用就是把用户口语映射到标准节点 id图谱查询只做精确匹配。建议这样建索引CREATE INDEX symptom_name_idx FOR (s:Symptom) ON (s.name); CREATE INDEX symptom_id_idx FOR (s:Symptom) ON (s.id);查询时用s.id IN $symptom_ids走索引比用CONTAINS正则快一个数量级。这也说明一个设计原则图谱负责“查准”向量负责“查全”不要在图谱层做模糊。如果你发现自己频繁用CONTAINS或~正则匹配说明前面的实体抽取和标准化没做到位该补的是 NER不是改查询。3.3 把图谱路径变成可解释的答案诊断问答的答案不能只给一个疾病名。用户想知道的是“为什么是这个病”这在可解释性上比通用 RAG 要求更高。图谱天然适合解释因为路径本身就是推理链疑似诊断: 不稳定型心绞痛 推理路径: 胸闷(胸骨后) 压榨样 放射至左肩 活动后加重 休息后缓解 → 符合心绞痛典型表现 需排除: 心肌梗死症状相似需查肌钙蛋白、心电图这段解释在多轮对话里很有用。系统给答案时把路径连同证据一起返回前端渲染成卡片。即使最后判断不准用户也能看到这个结论是怎么推出来的而不是面对一个大模型生成的黑盒子。4. 向量检索的正确打开方式实体文档化、查询改写与混合召回4.1 医学文本向量化的第一步是改写不是直接编码不少人直接用sentence-transformers的通用中文模型把疾病描述拿去编码。这么做出来的向量检索最典型的问题是两个症状描述高度相似但医学意义大相径庭。比如“心前区刺痛与呼吸相关”和“心前区压榨样痛活动诱发”向量距离可能很近但前者指向胸膜炎/肋间神经痛后者指向心绞痛。所以这个标题里的“向量检索”在我理解里不是把原始文本扔进 embedding 模型而是先把文本整理成“图谱文档”再去做向量化。具体做法是把每个疾病节点的图谱路径症状组合 检查 鉴别诊断渲染成文档再把文档切块做 embedding查询时把用户问题也做同样的症状维度拆解先拼成结构化描述再向量化代码可以写成这样from openai import OpenAI # 这里以 OpenAI 接口为例实际项目可以用开源模型替换 base_url client OpenAI(base_urlhttp://localhost:9997/v1, api_keyEMPTY) def render_disease_doc(disease_node): parts [] parts.append(f疾病{disease_node[name]}) for s in disease_node[symptoms]: parts.append(f症状{s[name]}{s.get(性质, )}部位{s.get(部位, )}加重因素{s.get(加重因素, )}) for e in disease_node[exams]: parts.append(f检查{e[name]}) for d in disease_node[differentials]: parts.append(f鉴别{d[name]}) return \n.join(parts)注意到这里做了很关键的一步把图谱里结构化的属性重新渲染成自然语言而不是直接用图数据库的 JSON dump。这样做的原因是embedding 模型吃的是自然语言不是属性列表。{部位: 胸骨后, 性质: 压榨样}这段 JSON 编码出来的向量语义密度远不如“部位胸骨后压榨样”这句自然语言。日常提到的“GraphRAG”本质就是这一步把图结构转成带语义的文档再做向量检索。真正落地时这步的选择会直接影响召回质量。4.2 分块长度决定召回粒度症状组合是自然边界医疗文档不适合固定 512 字切块。常见做法是以“疾病 主诉 伴随症状”为逻辑单元切成块每个块大致 100~200 字中文覆盖一个疾病的完整临床表述。下面这个表格说明不同分块策略的适用场景分块策略适用场景缺点固定 256 字符快速验证语料均匀容易切断症状组合召回碎片化按疾病节点渲染整块诊断问答图谱驱动疾病较长时上下文超窗按症状组合切块症状 → 疾病匹配需要先有图谱实体边界按段落/句子切块通用 RAG 文档问答医学实体跨句时召回遗漏实际项目中我更推荐按疾病节点渲染文档然后叠一层按句子切块的后备索引。在线查询时图谱通道给候选疾病向量通道给候选症状块两路结果合并后在重排阶段做最终决策。这样既不会因为过长的文档超出模型窗口也不会因为句子碎片丢失疾病全景。4.3 混合检索的权重RRF 合并比线性加权更稳重排阶段一个值得直接上手的技巧是用倒数排名融合RRF替代线性加权。线性加权的问题在于两路分数分布差异很大图谱的matched_count是个位数向量的相似度是 0~1 之间的小数直接乘权相加没有可比性。RRF 只看排名不看分数天然消除量纲差异def rrf_fusion(two_ranked_lists, k60): scores {} for ranked_list in two_ranked_lists: for rank, doc_id in enumerate(ranked_list): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) return sorted(scores.items(), keylambda x: x[1], reverseTrue) # candidate_kg: 图谱通道返回的疾病列表(已排序) # candidate_vec: 向量通道返回的疾病列表(已排序) final_rank rrf_fusion([candidate_kg, candidate_vec])k的值取 60 时对排名前几的文档区分度最好这是 RRF 论文里给出的经验值。如果你希望图谱通道更占主导就把图谱列表里排在第一的文档权重通过复制多份列表的方式增强。这个技巧比调线性权重稳定得多因为它不要求两路分数分布一致。关于“混合检索”这个词本身在这个项目里的具体含义是图谱的精确关系约束 向量的语义模糊召回各取所长。医疗场景里可能没有完美的单独方案只有把两路结果融合起来才能同时保证记忆性和泛化性。这也是我把这章单独展开的原因它是整个系统的核心。4.4 本地方案用轻量向量库做离线验证生产环境用 Milvus、Elasticsearch 或阿里云 OpenSearch 向量检索版这类专业组件都没问题但早期验证阶段建议先用轻量方案把链路跑通不要一上来就搭分布式向量库。我一般用chromadb或sqlite-vec做本地验证几百个疾病文档完全够用pip install chromadbimport chromadb client chromadb.Client() collection client.get_or_create_collection(medical_docs) # docs: 每个元素是上面 render_disease_doc 渲染好的字符串 for i, doc in enumerate(medical_docs): collection.add( ids[str(i)], documents[doc], metadatas[{disease_id: disease_ids[i]}] )sqlite-vec的做法也类似都是嵌入式向量索引零部署成本。这里不纠结选型因为前期目标是验证分块策略是否合理、查询改写后召回率是否提升、RRF 融合是否真的比单路好。等这些结论出来再迁移到生产级向量库也不迟。5. 工程折叠从源码结构到问答系统的可维护闭环5.1 源码目录设计把“喂数据、建索引、查问答”分成三条独立链路拿到这种项目的源码第一件事不是看代码而是看目录结构。一个值得复用的项目目录往往是这样的medical_qa/ ├── data/ # 原始数据、清洗后的实体表格 ├── kg_builder/ # 知识图谱构建脚本实体抽取、关系构建、Neo4j 导入 ├── rag_index/ # 向量索引构建文档渲染、切块、embedding、写入向量库 ├── api/ # FastAPI 服务问答接口、检索接口 ├── query/ # 图谱查询、向量召回、混合重排 ├── eval/ # 评测集、评测脚本 └── docs/ # 详细文档说明这三个链路要解耦。kg_builder和rag_index是离线任务跑完把产物落库api是在线服务只读已建好的索引和图谱。这样做的考量是诊断问答系统里任何一次数据更新图谱和向量库两侧的描述必须一致否则会出现“图谱说这个病要查心电图向量库召回的心绞痛文档里没提心电图”这种矛盾。如果你拿到的源码把建索引和查问答耦合在一个脚本里重构时优先拆开。5.2 问答主流程的代码骨架先召回、再重排、最后生成问答复用的是“检索增强生成”的老结构召回 → 重排 → 生成但医疗场景要把“生成”拆成两步——先出结构化候选再做自然语言收尾。def answer_question(user_query: str): # 1. 抽取医学实体和维度 entities extract_medical_entities(user_query) # 返回 dict # 2. 图谱召回 kg_candidates kg_query.query_candidates(entities[symptom_ids]) # 3. 查询改写向量召回 rewritten rewrite_for_vector(user_query, entities) vector_candidates vector_search.search(rewritten, top_k20) # 4. RRF 重排 merged rrf_fusion([kg_candidates, vector_candidates]) # 5. 取前5个候选拼接提示词给 LLM 生成 prompt build_prompt(user_query, merged[:5]) final_answer llm_generate(prompt) return final_answer注意第 3 步的rewrite_for_vector这个函数非常关键。它的作用是把抽取到的症状维度 原文拼成一段“医学化描述”再做向量检索。原因是用户口的“胸口闷”和医学文档里的“胸闷”embedding 距离没有想象中那么近不做改写召回效果差一大截。第 5 步用 LLM 做自然语言生成时提示词里要把每条候选的图谱路径放进去同时要求模型“如果候选不足以确诊请列出需要追问的问题”。这句话能让系统在信息不足时主动追问而不是硬给一个结论。医疗对话系统最重要的安全设计之一就是“不确定时问清楚”。5.3 可落地的评测方法模拟就诊记录构建评估集这套系统上线前怎么验证常见做法是构造“模拟病例”评测集每条包含三部分主诉用户描述、正确答案疾病、回溯路径图谱证据链。评测时把主诉送到系统检查输出里有没有命中正确答案以及给的诊断路径是否在证据链范围内。def evaluate(test_cases): hits_at_5 0 for case in test_cases: result answer_question(case[chief_complaint]) top_diseases extract_disease_names(result) if case[expected_disease] in top_diseases: hits_at_5 1 return hits_at_5 / len(test_cases)构建评估集的方式不一定要从真实病历里拿公开的医学百科中大量症状描述的“主诉化改写”完全可以作为素材。将“下面哪种说法符合 xxx”改写成“我最近xxx是不是xxx病”一百条数据打底就能把系统的水位测出来。提示评测的维度里必须有“召回是否可解释”这一项不能只看疾病名对不对。医疗场景里答对但理由错比答错还危险因为它会误导用户心情。5.4 给文档说明定一个模板架构图之外你还需要“冷启动手册”标题里提到了“详细文档说明”这也是一个能拉开项目质量的部分。好文档最重要的不是开源协议说明和依赖安装列表而是冷启动手册拿到这套源码后从空白环境到能跑通一个问答需要哪些数据、跑哪些脚本、按什么顺序执行。我建议至少要包含四段内容数据准备实体表格的字段格式、症状维度的标准枚举、低质量数据怎么过滤图谱导入清洗后的数据如何灌入 Neo4j关系去重策略索引建立脚本向量索引构建文档渲染模板、embedding 模型选择、分块大小与重叠、索引存储位置评测跑法评估集的格式、评测脚本入口、通过标准Hits5 至少 0.7 以上写文档时不要写“运行 main.py”这种一句话带过的步骤要写清楚每个脚本的输入输出以及执行失败时常见报错的对照表。诊断问答系统最怕的是“数据难看没人管模型背锅”的脏活。把这些边界写出来比贴一百页 API 文档更有价值。5.5 关于诊断边界系统是辅助不是医生最后这点必须在工程的每个出口反复强调这类系统的定位是预问诊和导诊辅助不是确诊工具。从代码层面落地这个原则最简单的方法是——答案里加一条固定提醒。在build_prompt里把这个要求写成硬性指令“回答末尾必须包含本回答仅供参考不能代替医生面诊。若症状持续或加重请及时就医。”这不是产品文案是系统的安全底线。堵住这条剩下的优化工作才有意义。本文还有配套的精品资源点击获取
返回列表