
简介本资源为基于RAG与大模型技术的医疗问答系统完整项目工程面向计算机、人工智能相关专业的毕业设计、课程设计、大作业、工程实训及学科竞赛参赛者。项目利用DiseaseKG数据集与Neo4j构建知识图谱结合BERT命名实体识别与34b大模型意图识别通过精确知识检索与问答生成提升医疗咨询性能着力解决大模型在医疗领域应用的可靠性问题。压缩包共75个文件约84.65MB涵盖Python源码、Jupyter Notebook实验记录、JSON与CSV数据集、YAML配置、PNG/JPG界面截图及Markdown说明文档覆盖知识图谱构建、模型微调、NER训练与Web交互等模块。已有157人学习关注。项目代码经过测试运行功能完整可实现复现复刻设计报告亦可借鉴适合在此基础上扩展开发新功能遇到使用问题可联系作者获取解答与学习资料支持。1. 医疗问答系统为什么不能只靠大模型硬答从一次“编造药品剂量”说起医疗问答系统这个方向很多同学第一反应是“接个大模型 API 不就行了”。我一开始也这么想直到测试时问了一句“二甲双胍肾功能不全患者怎么调整剂量”模型张口就给了一个看似合理、实则和说明书对不上的答案。这就是纯生成式方案在医疗场景的致命伤它会用流畅的语言把错误信息包装得很可信。RAG检索增强生成要解决的核心问题就是让模型在回答前先去权威资料里找依据把“凭记忆答”变成“看着资料答”。再叠加 Neo4j 存医学知识图谱、BERT 做语义召回整套系统既能覆盖非结构化指南又能利用结构化的疾病-症状-药品关系。这篇笔记面向正在做毕设、课设或实训项目的同学把从数据准备到检索、生成、评测的完整链路拆开讲重点放在能复现的命令、参数和踩过的坑上。2. 医疗 RAG 的检索层怎么搭BERT 向量召回与 Neo4j 图谱召回的分工2.1 为什么医疗场景需要双路召回单靠向量检索有个绕不开的问题它对“精确关系”不敏感。比如问“高血压合并糖尿病首选哪类降压药”向量库可能召回一堆讲高血压的段落但真正关键的“合并糖尿病”这个约束条件容易被稀释。而 Neo4j 里的知识图谱天然擅长处理这种多跳关系疾病节点连到并发症节点再连到推荐药物节点一条 Cypher 就能把约束条件卡死。我一般把检索层设计成两路并行BERT 向量召回负责从指南、说明书、论文摘要这类长文本里捞语义相近的段落Neo4j 图谱召回负责处理实体关系明确的结构化查询。两路结果合并后再做一次重排序送进大模型做最终生成。这样既保留了 RAG 对非结构化知识的覆盖又补上了图谱对精确关系的把控。选 BERT 而不是直接用大模型做 embedding主要考虑是本地部署成本和推理延迟。医疗问答往往要求响应在几秒内BERT 类模型在消费级显卡上就能跑而且中文医疗领域有现成的预训练权重可以微调。如果项目对语义精度要求更高也可以换成 bge 或 m3e 这类中文 embedding 模型接口是兼容的。2.2 用 BERT 构建向量索引的最小步骤先装依赖这里用 sentence-transformers 加载 BERT 类模型做句向量比裸用 transformers 省事pip install sentence-transformers faiss-cpu neo4j langchain如果你的数据量大faiss-cpu 可以换成 faiss-gpu但医疗问答的知识库通常几万到几十万条CPU 版够用。接下来是构建索引的脚本from sentence_transformers import SentenceTransformer import faiss import numpy as np import json # 加载中文医疗领域微调过的 BERT 句向量模型 # 如果没有领域微调权重先用通用中文模型跑通流程 model SentenceTransformer(shibing624/text2vec-base-chinese) # 读取预处理好的知识片段每行一个 JSON含 text 和 source 字段 with open(medical_chunks.jsonl, r, encodingutf-8) as f: chunks [json.loads(line) for line in f] texts [c[text] for c in chunks] # 批量编码batch_size 根据显存调整CPU 上 32 比较稳 embeddings model.encode(texts, batch_size32, normalize_embeddingsTrue) embeddings np.array(embeddings).astype(float32) # 用内积索引因为向量已归一化内积等价于余弦相似度 dimension embeddings.shape[1] index faiss.IndexFlatIP(dimension) index.add(embeddings) faiss.write_index(index, medical_faiss.index) # 保存原始文本检索时按索引位置取回 with open(medical_texts.json, w, encodingutf-8) as f: json.dump(texts, f, ensure_asciiFalse)这段代码的关键参数有三个normalize_embeddingsTrue保证向量归一化这样内积检索等价于余弦相似度batch_size32是 CPU 上的保守值显存充足可以调到 128IndexFlatIP是精确检索数据量超过百万级再考虑换 IVF 索引医疗问答一般到不了这个量级。检索时把 query 用同一个模型编码然后取 top-kdef vector_search(query, top_k5): q_emb model.encode([query], normalize_embeddingsTrue).astype(float32) scores, indices index.search(q_emb, top_k) results [] for score, idx in zip(scores[0], indices[0]): results.append({text: texts[idx], score: float(score)}) return resultstop_k 不要设太大医疗场景下召回太多无关段落反而会干扰大模型判断。我一般先用 5 做基线再根据评测结果调整到 3 或 8。2.3 Neo4j 图谱召回从疾病到药品的多跳查询Neo4j 的安装和配置网上教程很多这里不展开。重点讲医疗知识图谱的 schema 设计和查询。一个最小可用的医疗图谱至少要有这几类节点和关系节点类型属性示例关系类型关系方向Diseasename, icd_codeHAS_SYMPTOMDisease → SymptomSymptomname——Drugname, dosageTREATSDrug → DiseaseComplicationnameCOMPLICATESDisease → Complication导入数据可以用 LOAD CSV也可以用 Python 的 neo4j driver 批量写入。下面是一个按疾病名查推荐药物的 CypherMATCH (d:Disease {name: $disease_name})-[:HAS_COMPLICATION]-(c:Complication) MATCH (drug:Drug)-[:TREATS]-(d) WHERE NOT (drug)-[:CONTRAINDICATED]-(c) RETURN drug.name AS drug_name, drug.dosage AS dosage LIMIT 10这条查询的逻辑是先找到疾病及其并发症再找能治疗该疾病的药物同时排除对并发症有禁忌的药物。$disease_name是参数化查询避免拼接字符串。LIMIT 10防止返回过多结果。Python 侧调用from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, your_password)) def graph_search(disease_name): with driver.session() as session: result session.run( MATCH (d:Disease {name: $name})-[:HAS_COMPLICATION]-(c:Complication) MATCH (drug:Drug)-[:TREATS]-(d) WHERE NOT (drug)-[:CONTRAINDICATED]-(c) RETURN drug.name AS drug_name, drug.dosage AS dosage LIMIT 10 , namedisease_name ) return [record.data() for record in result]注意 Neo4j 默认密码必须改auth里的密码要和安装时设置的一致。如果连接报错先检查 7687 端口是否被占用以及 Neo4j 服务是否启动。2.4 两路结果怎么合并向量召回返回的是文本段落图谱召回返回的是结构化记录。合并时我一般把图谱结果转成自然语言描述再和向量结果拼在一起送进大模型。比如图谱返回{drug_name: 二甲双胍, dosage: 0.5g 每日两次}就转成“推荐药物二甲双胍用法用量0.5g 每日两次”。这样大模型看到的上下文格式统一生成时不容易漏掉关键信息。合并后的上下文长度要控制医疗问答的 prompt 一般不超过 2000 token。如果超了优先保留图谱结果因为结构化信息密度更高。3. 大模型生成层怎么调prompt 模板、上下文截断与幻觉抑制3.1 医疗问答的 prompt 模板设计大模型在医疗场景下最容易犯的错是“过度推断”。你给它一段资料它会自动补全资料里没写的剂量、疗程、禁忌。抑制这个问题的核心手段是 prompt 里明确约束只允许基于给定上下文回答上下文没有的信息必须说“资料中未提及”。我常用的模板结构是这样的PROMPT_TEMPLATE 你是一个严谨的医疗问答助手。请严格根据以下参考资料回答用户问题。 参考资料 {context} 用户问题{question} 回答要求 1. 只使用参考资料中明确出现的信息不要自行推断或补充。 2. 如果参考资料不足以回答问题直接回复“根据现有资料无法回答该问题”。 3. 涉及药品用法用量时必须原文引用参考资料中的表述。 4. 回答末尾列出你引用的资料编号。 回答这个模板里{context}是检索层合并后的上下文{question}是用户原始问题。要求 4 让模型列出引用编号方便人工核查。实际测试中加了引用编号要求后模型编造信息的概率明显下降因为它知道会被追溯。3.2 上下文截断策略与 token 预算检索回来的上下文经常超长直接塞给大模型要么报错要么被截断。我一般按优先级做截断图谱结果 向量检索 top-1 向量检索 top-2 ...。具体做法是给每段上下文算 token 数累加到预算上限为止。import tiktoken def truncate_context(chunks, max_tokens1800): enc tiktoken.get_encoding(cl100k_base) selected [] total 0 for chunk in chunks: tokens len(enc.encode(chunk)) if total tokens max_tokens: break selected.append(chunk) total tokens return \n\n.join(selected)max_tokens1800是给生成留了余量因为大模型的总上下文窗口还要减去 prompt 模板本身和输出长度。如果用的是国产大模型tokenizer 可能不同需要换成对应的编码器。截断顺序按检索得分从高到低保证最相关的信息优先进入上下文。3.3 用 few-shot 示例锚定回答格式医疗问答对格式一致性要求高比如药品剂量必须带单位、疾病名称必须用规范术语。在 prompt 里加一两个 few-shot 示例比单纯写规则更有效FEW_SHOT 示例1 问题高血压患者日常需要注意什么 回答根据资料[1]高血压患者应限制钠盐摄入每日不超过5g根据资料[2]建议每周进行150分钟中等强度有氧运动。资料中未提及具体药物调整方案。 示例2 问题阿司匹林能用于儿童退烧吗 回答根据资料[1]阿司匹林可能引起瑞氏综合征不推荐用于18岁以下儿童退烧。资料中未提及替代药物。 示例要选有代表性的覆盖“能回答”和“不能回答”两种情况。示例里的引用编号格式要和实际输出一致否则模型会混淆。3.4 生成参数怎么设温度temperature在医疗问答里我一般设 0.1 到 0.3太高会增加随机性太低又会让回答过于死板。top_p 设 0.9避免采样到低概率的奇怪表述。max_tokens 根据场景定简短问答 256 够用需要详细解释的设 512。如果用的是本地部署的开源大模型比如通过 Ollama 跑 7B 或 13B 模型生成速度会明显慢于 API。这时候可以把 max_tokens 压到 256并开启流式输出让用户先看到部分结果。4. 避坑与排查医疗 RAG 落地时最容易翻车的五个地方4.1 检索召回率看着高但生成答案还是错现象离线评测时 recall5 有 0.85但人工看生成答案关键信息还是漏。原因召回率高不代表召回的段落里包含答案。医疗知识经常分散在多段里比如药品剂量在一段、禁忌在另一段top-5 只召回了其中一段。解决把评测粒度从“段落级召回”改成“答案要素级召回”。具体做法是人工标注每个问题的答案要素如药品名、剂量、禁忌然后检查 top-k 段落里是否覆盖了所有要素。如果覆盖不全要么增大 top_k要么在检索后加一步“要素补全”——用图谱查询补齐缺失的结构化信息。4.2 Neo4j 查询返回空结果但数据明明导入了现象Cypher 查询在 Neo4j Browser 里能跑出结果Python 调用返回空列表。原因最常见的是参数名不匹配。Cypher 里写$disease_namePython 传的是namedisease_name参数名对不上就查不到。另一个原因是 Neo4j 的标签或属性名大小写敏感导入时用了Disease查询时写成disease就匹配不到。解决先在 Neo4j Browser 里用硬编码值跑通查询确认语法和标签名无误再改成参数化查询。Python 侧打印实际执行的 Cypher 和参数逐字比对。4.3 BERT 向量检索在长文本上效果骤降现象短问题检索准确长问题超过 50 字召回的相关段落明显变少。原因BERT 类模型有最大输入长度限制通常是 512 token。长问题被截断后关键信息可能丢失。另外句向量模型对长文本的语义压缩能力有限问题越长向量表示越模糊。解决对长问题先做一次“查询改写”用大模型把用户问题拆成几个短查询分别检索后再合并结果。或者换用支持长文本的 embedding 模型比如 bge-large 系列最大长度能到 8192。如果不想换模型就在预处理阶段把长问题截成多个短句分别编码后取平均向量。4.4 大模型无视 prompt 约束仍然编造信息现象prompt 里明确写了“资料中没有的不要编”模型还是给出了资料里没有的剂量。原因大模型的指令遵循能力参差不齐尤其是参数量小的模型。另外如果检索上下文本身包含矛盾信息模型会倾向于“调和”而不是指出矛盾。解决第一换指令遵循能力更强的模型7B 以下的小模型在医疗场景下确实容易失控。第二在 prompt 里把约束条件放在问题之后而不是之前因为模型对靠近生成位置的指令更敏感。第三加一道后处理校验用规则或小模型检查生成答案里的药品名、剂量是否在检索上下文中出现过没出现就标记为“待人工复核”。4.5 本地部署大模型时显存不够现象加载 13B 模型时报 CUDA out of memory但显卡显存明明有 24G。原因模型权重加载后推理时还需要额外显存存 KV cache。13B 模型 FP16 权重约 26G24G 显存根本不够。另外如果同时跑了 BERT 向量模型和 Neo4j显存会被进一步挤占。解决用 4-bit 量化加载大模型显存占用能降到 8G 左右。如果量化后还是不够就把 BERT 向量模型放到 CPU 上跑Neo4j 本身不占显存。Ollama 默认会做量化直接用它拉模型比较省心。5. 把评测做扎实医疗 RAG 的离线验证与线上兜底5.1 离线评测集怎么构建医疗 RAG 的评测不能只看 BLEU 或 ROUGE这些指标和事实准确性相关性太弱。我一般构建三层评测集第一层是“事实问答”问题有唯一确定答案比如“二甲双胍的常用起始剂量是多少”。评测指标是答案要素命中率。第二层是“关系推理”需要多跳才能回答比如“高血压合并糖尿病患者首选哪类降压药”。评测指标是图谱路径召回率和生成答案的准确率。第三层是“拒答测试”问题在知识库中无答案比如“某罕见病的最新基因疗法”。评测指标是正确拒答率模型应该说“资料中未提及”而不是编造。每层至少 50 条人工标注答案要素。评测脚本自动跑检索和生成输出每条的命中情况。5.2 线上兜底策略再好的 RAG 系统也会有检索不到的时候。线上部署时我一般加两个兜底一是置信度阈值。如果向量检索的最高分低于 0.6或者图谱查询返回空就直接走“无法回答”分支不调大模型生成。这样虽然会拒答一些本可以回答的问题但避免了编造风险。二是人工复核入口。对于涉及药品剂量、禁忌、手术方案的问题生成答案后标记为“待复核”不直接展示给用户。复核通过后才进入缓存下次同样问题直接返回缓存结果。5.3 一个容易被忽略的技巧把评测结果反哺检索评测不只是验收手段还能用来优化检索。具体做法是把评测中“检索到了但生成错了”的案例挑出来分析是上下文截断丢了信息还是 prompt 没约束住。如果是截断问题就调整截断优先级如果是 prompt 问题就加对应的 few-shot 示例。迭代几轮后系统的事实准确率会有明显提升。我自己的习惯是每次改完检索或 prompt都跑一遍完整评测集记录准确率变化。没有这个习惯改着改着就不知道哪版更好了。医疗问答这个方向宁可迭代慢一点也不要为了追求回答率牺牲准确性。希望帮到你。本文还有配套的精品资源点击获取