ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RAG医疗问答系统源码实战:从检索增强到混合检索

RAG医疗问答系统源码实战:从检索增强到混合检索 简介基于RAG与大模型技术的医疗问答系统源码包是一套完整的毕业设计项目面向计算机、人工智能、自动化等专业的学生、老师或从业者可用于期末课程设计、课程大作业及毕业设计。项目聚焦医疗领域问答综合运用检索增强生成RAG、大模型微调如ChatGLM、知识图谱构建与命名实体识别NER等技术覆盖数据预处理、模型训练、推理与前端交互等完整流程具有较高的学习与借鉴价值。资源包共75个文件压缩后84.65MB。其中Python源码py包含模型微调、推理及图谱构建脚本Jupyter Notebookipynb提供逐步解析演示txt/json/csv数据文件为医疗语料及实体关系yaml为训练配置png/jpg为界面及流程图md为文档说明目录模块划分清晰便于按需检索。该项目答辩评审分达98分代码经调试确认可运行。除完整源码与文档外还提供微调运行示例、数据增强工具、NER结果分析及前端登录界面等目前已吸引378人学习下载。适合小白系统学习也可在此基础上修改调整实现更丰富的功能。1. 医疗问答系统为什么绕不开 RAG一份毕设源码的真实边界把一份药品说明书丢给通用大模型它通常能答出适应症和常规剂量但你问“我有高血压吃了这个药之后头晕要不要停”通用模型大概率会给你一段看着像专家共识、实则没法追溯出处的话。医疗问答系统的难点从来不是“模型会不会说话”而是“它说的每句话能不能找到依据”。基于 RAG 与大模型技术的医疗问答系统源码解决的问题正是这个把诊疗指南、药品说明书这类私有文档切成片段、向量化用户提问时先检索再让大模型基于检索结果作答答案可溯源、知识可更新。这套源码适合两类人一类是做毕业设计的学生想找一个真正能跑通、有文档有评估的完整项目另一类是刚接触 RAG 的工程师想看看医疗场景下文档解析、检索、生成这条链路要踩哪些坑。2. 从裸大模型到 RAG 检索增强链路选型与关键参数2.1 为什么医疗问答不能只靠微调先回答一个绕不开的问题医疗问答为什么不用微调我在自己折腾大模型的时候微调和 RAG 都试过说下实际感受。微调的本质是把知识灌进权重里听起来很美但医疗场景有两个硬伤。第一知识更新成本高临床指南、医保目录、药品说明说改就改每更新一次就要重新训练一轮时间成本根本扛不住。第二可解释性差模型答错了你都不知道它是从哪句话推出来的医生和患者都不敢用这种“黑匣子”回答。RAG 走的是另一条路文档在外置知识库里模型只负责“阅读理解”和“组织语言”。知识更新只需要替换文档、重建索引答案可以引用片段编号回看原文这两点恰好命中医疗问答的命门。我见过不少同学把大量精力花在微调调参上最后效果还不如一条完整的 RAG 链路。对这份源码来说设计思路也是典型的新手友好型不做训练只做“检索 生成”显卡要求低部署成本可控。下面是三条路线的对比能帮你理解为什么选 RAG。方案知识更新成本答案可溯源部署成本适用场景裸大模型无更新能力靠模型内建知识无低闲聊、通用问答微调高每次都要重训无高需要训练环境固定话术、私有风格RAG低换文档重建索引即可有可回看片段低普通单卡可跑知识库问答、医疗问答2.2 源码里的 RAG 链路向量库、Embedding 与 Top-K 设定这套系统的主体链路是标准的五段式文档解析 → 文本切分 → Embedding 向量化 → 向量检索 → 大模型生成。不必把它想得多玄核心就是“先查资料再回答”。我拆过不少 RAG 项目凡是效果翻车的多半不是大模型的问题而是前面三步没做好。关键参数通常在源码的配置文件里集中管理。常见做法是放到 config.yaml 或 .env 里方便切换实验。下面这份参数表基本覆盖了链路里最影响效果的几个旋钮照着调就行。参数典型取值作用调试优先级chunk_size256 / 512单次送入检索单元的文本长度高太大语义杂太小上下文碎chunk_overlap32 / 64相邻片段重叠长度防止切分切断语义高与 chunk_size 配合embedding_modelm3e-base / bge-m3中文语义向量模型高决定检索质量下限embedding_dim768 / 1024向量维度取决于模型中入库后改要重建索引top_k5 / 8返回候选片段数量高医疗场景不建议超过 8similarity_metricIP / COSINE向量相似度度量方式低取决于向量模型训练方式我一般会先把 chunk_size 定在 512、overlap 定在 64跑一轮看效果再往下调。整套链路在源码里是分离的模块交换任意一个环节不会牵连其他部分这也是能作为毕设加分的设计。2.3 混检索与重排RAG 效果的分水岭如果你以为 RAG 就是“向量查一查、拼进 prompt”那只能说跑通没问题效果就随缘了。纯向量检索对近义表达很友好比如“血压偏高”和“高血压”能查到同一篇文档但它对药品名、剂量这类精确词反而容易翻车向量表示是语义压缩过的有时会把“阿莫西林胶囊”和“阿莫西林克拉维酸钾片”混淆。我自己的经验是医疗文档里专有名词密度高必须做混合检索。常见做法是 BM25 关键词检索 向量语义检索并行再把两路结果用 RRFReciprocal Rank Fusion融合。BM25 负责精确匹配药名、剂量、检查项向量负责语义召回RRF 把两边的排名合并成一个稳定结果。源码里这层的实现可以单独拎出来用换到法律、金融文档场景也成立。后面第 3 章我会把这三段的代码拆开讲。3. 把源码跑起来医疗文档处理、向量化与检索实现的 Python 细节3.1 医疗文档清洗与切分按章节切还是按窗口切医疗文档的原始形态五花八门有从 PDF 转出来的文本、有 Word 导出的 Markdown、还有带 OCR 噪声的扫描页。切分之前必须先清洗。我见过最典型的翻车现场是页眉页脚、“药品名称XXX”这种重复信息混进片段检索时十条里有四条是页眉。清洗的两条硬规则是去掉连续空白和行号按文档结构切块而不是盲目按字符数切。import re from typing import List def clean_text(raw: str) - str: # 去掉行号和页眉页脚常见噪声保留正文语义 text re.sub(r\n\d\n, \n, raw) text re.sub(r第\s*\d\s*页, , text) text re.sub(r[ \t], , text) text re.sub(r\n{3,}, \n\n, text) return text.strip() def split_document(text: str, chunk_size: int 512, overlap: int 64) - List[str]: # 优先按一级/二级标题切避免把不同适应症切进同一块 segments re.split(r(?\n【|\n\d\.|\n[一二三四五六七八九十]、), text) chunks [] for seg in segments: seg seg.strip() if len(seg) chunk_size: chunks.append(seg) continue # 超过阈值的段落再按重叠窗口切 start 0 while start len(seg): end start chunk_size chunks.append(seg[start:end]) if end len(seg): break start end - overlap return chunks逻辑说明clean_text 里的三个正则分别处理行号、页码和多余空白跑完基本能拿到干净文本。split_document 里re.split的(?...)是零宽断言意思是“在标题前面切一刀但标题不丢”这样切出来的片段天然带小标题检索时命中一看标题就知道出自哪一章。最后一段是不得已的兜底某个小节实在太长才用固定窗口硬切overlap 保证跨窗口的语义不中断。参数说明chunk_size 不要小于 256太小会让单条上下文装不下一个完整适应症描述overlap 一般设成 chunk_size 的 1/8 到 1/4 比较顺。药品说明书我建议优先按“【适应症】【用法用量】【不良反应】”这种结构化标题切源码里我习惯把标题列表做成可配置项换科室文档时不用改代码。3.2 Embedding 模型选型与向量入库切分完成的片段要转成向量。实际项目中我常看到两个选择m3e-base 和 bge-m3。m3e-base 是 768 维模型小、CPU 也能勉强跑适合毕设和轻量部署bge-m3 是 1024 维多语言和长文本能力更强但对显存要求高一点。这套源码走的是务实路线默认 m3e-base后面你要换模型只需改配置里的模型名和维度向量库路径会自动分目录不会污染旧索引。from sentence_transformers import SentenceTransformer import faiss import numpy as np import pickle embedder SentenceTransformer(m3e-base) def build_index(chunks: List[str], index_path: str ./data/index): # 分批编码避免一次性吃满显存 vectors [] batch_size 32 for i in range(0, len(chunks), batch_size): batch chunks[i : i batch_size] vec embedder.encode(batch, normalize_embeddingsTrue) vectors.append(vec) matrix np.concatenate(vectors, axis0) # 内积 归一化等效余弦相似度 index faiss.IndexFlatIP(matrix.shape[1]) index.add(matrix) # 索引和片段元数据分开存档防止索引损坏后无法找回原文 faiss.write_index(index, f{index_path}.faiss) with open(f{index_path}.meta.pkl, wb) as f: pickle.dump(chunks, f)逻辑说明normalize_embeddingsTrue这一步很多人会漏它把向量归一化到单位长度之后用内积IndexFlatIP就能等价于余弦相似度检索分数更稳定。batch_size 设 32 是为了在普通显卡上控制峰值显存你的卡大可以调大卡小就调小。索引文件和片段元数据分开存因为 faiss 索引里只存向量不存原文如果只存索引检索结果没法映射回文本。参数说明m3e-base 的向量维度是 768如果你后面换 bge-m3维度变成 1024旧索引不能复用必须重建。faiss 的 IndexFlatIP 是暴力精确检索数据量在十万级以下性能完全没问题不必上 IVF 或 HNSW 这种索引省得给自己加复杂度。3.3 检索接口实现从 query 到候选上下文检索层是这套源码里最值得单独抽出来复用的部分。用户的 query 进来先做一遍同类清洗然后走双路检索一路用 BM25 做关键词精确匹配一路用向量做语义召回最后用 RRF 合并。这里给一个不带重排的简化实现重排器和生成层分开方便你单独测检索质量。import jieba from rank_bm25 import BM25Okapi from typing import List class HybridRetriever: def __init__(self, chunks: List[str], faiss_index, embedder): self.chunks chunks self.index faiss_index self.embedder embedder # BM25 对中文需要切词这里直接喂 jieba 分词的 token 列表 tokenized [list(jieba.cut(c)) for c in chunks] self.bm25 BM25Okapi(tokenized) def search(self, query: str, top_k: int 5) - List[dict]: q_vec self.embedder.encode([query], normalize_embeddingsTrue) scores, ids self.index.search(q_vec, top_k) dense_hits [(idx, 3.0) for idx in ids[0]] bm25_scores self.bm25.get_scores(list(jieba.cut(query))) bm25_hits sorted( enumerate(bm25_scores), keylambda x: x[1], reverseTrue )[:top_k] bm25_hits [(idx, 2.0) for idx, _ in bm25_hits] # RRF 融合k60 是经验值 fused {} for idx, rank_score in dense_hits bm25_hits: fused[idx] fused.get(idx, 0.0) 1.0 / (60 rank_score) ranked sorted(fused.items(), keylambda x: x[1], reverseTrue) return [ {chunk: self.chunks[idx], score: score, idx: idx} for idx, score in ranked[:top_k] ]逻辑说明dense_hits 的两个分数不是相似度而是在 RRF 里表示“这一路结果的先验权重”向量路给 3.0、BM25 路给 2.0意思是向量语义召回优先级略高。1.0 / (60 rank_score)是 RRF 的核心公式k 值越大两路结果越“平均”k 越小rank 靠前的结果优势越明显。医疗场景我一般把 k 留在 60因为药品名精确匹配和语义召回同样重要不能一边倒。参数说明top_k 是最终返回给生成层的片段数。取 5 是权衡后的结果太大 prompt 会被无关片段淹没让大模型抓到噪声太小又可能漏掉关键依据。如果后面加了重排器这里可以放宽到 10重排器会二次筛掉无关项。4. 生成回答与效果评估Prompt 约束、Hit Rate 与可复现实验4.1 Prompt 模板让大模型只答知识库里的内容检索做得再好生成层用裸 prompt 还是会一本正经地胡说八道。医疗场景的 prompt 必须做三件事限定知识来源、要求引用编号、允许说“不知道”。模板里给一个{context}占位符检索结果按[片段 1] [片段 2]的格式填进去模型在输出里直接引用对应编号方便人工核对。SYSTEM_PROMPT 你是一名基于知识库回答问题的医疗问答助手。 规则 1. 只依据 context 中提供的片段回答禁止使用你自己的知识。 2 回答必须标注引用的片段编号例如 [1][2]。 3. 如果 context 中没有足够信息回答未在现有资料中检索到相关内容建议线下就医。 4. 不要输出诊断、不要给出用药剂量建议只做资料性整理。 context {context} /context def build_prompt(query: str, hits: List[dict]) - str: context \n.join( f[{i 1}] {hit[chunk]} for i, hit in enumerate(hits) ) return SYSTEM_PROMPT.format(contextcontext) f\n用户问题{query}逻辑说明规则 1 是防幻觉的第一道闸规则 2 把回答变成可核验的规则 3 给了兜底话术规则 4 是医疗合规边界。这套系统定位是资料整理助手不是在线医生所以强制限定“不做诊断、不给剂量”这一点在毕设答辩里反而是加分项说明你有安全意识。补充说明很多人会把“不要输出诊断”写进 prompt 就完事实际测试里模型还是会偶尔越界。可靠的做法是在输出端再加一道正则检查命中“建议服用”“剂量”等敏感词时直接切换到兜底回答代码量很少但能挡住绝大多数风险。4.2 评估集构造与 hit rate / recall 计算RAG 系统最怕的事情是“感觉还行但说不清哪里不行”。要量化效果就得有评估集。做法是手工构造 20 个问句每个问句标注一个或多个“标准答案所在的片段编号”然后跑检索看这 20 个问句的 top-K 结果里有没有包含标准片段。hit rate 就是“命中的问句数 / 总问句数”这个指标专门衡量检索质量和大模型无关定位问题非常有用。def evaluate(retriever, eval_set, top_k: int 5): hit 0 mrr 0.0 for query, golden_ids in eval_set: results retriever.search(query, top_ktop_k) retrieved_ids [r[idx] for r in results] if any(g in retrieved_ids for g in golden_ids): hit 1 for rank, r in enumerate(results, start1): if r[idx] in golden_ids: mrr 1.0 / rank break n len(eval_set) return {hit_rate: hit / n, mrr: mrr / n} eval_set [ (阿莫西林胶囊的用法用量是什么, [12, 13]), (高血压患者能否同时服用布洛芬, [204]), # ... 实际评估集建议准备 20 组以上 ] print(evaluate(retriever, eval_set))逻辑说明hit_rate 是召回质量的及格线mrr 是“标准片段排在第几位”的加权指标排名越靠前生成效果越好。跑评估时我会把 top_k 固定为 5这样 hit_rate 高说明检索链路没毛病如果 hit_rate 低于 0.8先别调生成层回头查切分和 Embedding。建议每个问句的标准片段编号要从切分后的 chunks 列表里挑而不是从原文档里标注。因为检索返回的是切分后的片段标注错了编号评估脚本就会误判。这里建议在评估集里同时保存“原文档段落”和“chunk 编号”避免标注错位。4.3 流式输出与超时控制后端接口这一层源码用的是 FastAPI。实测下来有两个细节值得照着做第一大模型生成必须用流式否则一个长回答可能要等十几秒前端体验很糟糕第二检索和生成要设置各自独立的超时检索超过 3 秒直接返回兜底不能让用户永远转圈。from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() def generate_stream(prompt: str): # 实际项目中这里调用大模型接口此处伪代码示意 for token in chat_model.stream(prompt): yield token app.post(/chat) async def chat(query: str, top_k: int 5): hits retriever.search(query, top_ktop_k) if not hits: return {answer: 未在现有资料中检索到相关内容建议线下就医。} prompt build_prompt(query, hits[:top_k]) return StreamingResponse(generate_stream(prompt), media_typetext/plain)逻辑说明StreamingResponse把生成器的输出逐个发给前端用户看到的是“打字机”效果。检索为空时直接返回固定话术不再调用大模型省一次耗时和成本。这套接口设计在你做压力测试时也省心每条请求的耗时都在生成阶段检索阶段相对稳定调优方向清晰。5. 避坑排查医疗问答系统最常见的五个翻车现场5.1 症状检索召回全是无关段落现象无论问什么返回的片段都是文档里的通用内容比如“请仔细阅读说明书并在医师指导下使用”这种句子。 原因这类句子几乎出现在每份文档里向量分布和所有问句的匹配度都高把真正相关的片段挤出了 top_k。 解决清洗阶段做一遍“高频模板句过滤”正则把“请仔细阅读”“本品为”这类通用套句直接剔除如果不想写死在代码里就把这些模板句放进一个黑名单文本文件清洗时逐行匹配删除。5.2 症状模型答非所问引用与答案对不上现象答案内容看着合理但标注的 [1][2] 对应片段根本不包含答案依据。 原因prompt 里没强制“引用必须来自片段原文”模型按自己的知识作答随口编了编号。 解决生成后加一道校验把引用编号对应的 chunk 原文和答案一起送去算相似度低于阈值就拒绝该引用。我这个校验脚本是后置的不改造生成层部署阶段也敢用。5.3 症状启动即 OOM 或者向量库加载慢现象服务一起显存或内存直接爆掉或者加载索引就要好几分钟。 原因向量索引全量加载进内存加之大模型权重也在显存里两边抢资源。 解决faiss 索引加载改用faiss.read_index后再index.reconstruct的按需方式或者换IndexIVF这类压缩索引代价是召回率略微下降大模型推理用量化版本。经验值是 10 万条 chunk 以内IndexFlatIP完全够用不用过度设计。5.4 症状文档更新后检索结果没变化现象替换了知识库文档但同一个问题返回的片段还是旧的。 原因索引没有重建或者服务进程缓存了旧的 Embedding 结果。 解决把“重建索引”做成单独的脚本文档变更后先跑清洗与切分再跑向量化入库。这个步骤不要和启动服务绑在一起否则每次启动都重建时间全浪费掉了。5.5 症状中文医疗术语被切碎现象检索“阿莫西林克拉维酸钾片”BM25 路召回的是“阿莫西林”和“克拉维酸”的碎片文档反而不如纯向量。 原因jieba 默认词典对专业药品名不识别的结果。 解决给 jieba 加载一个医疗词典词典行就是把常见药名、检查名、疾病名写进去用jieba.load_userdict加载。效果立竿见影BM25 的 token 不再碎片化精确匹配能力一下子上来了。6. 进阶一步把单轮 RAG 升级为带追踪的 Agentic RAG6.1 从单轮到多跳源码扩展点在哪这套源码跑通之后如果你还有余力最值得升级的方向是 Agentic RAG。单轮 RAG 是“问一次查一次”碰上“高血压患者感冒了能吃哪种感冒药”这种要跨两份文档推理的问题就会卡住。常见做法是给检索加一个 query 改写器首轮检索不满意就自动拆解成子问题逐个子问题检索再合并进 prompt。这个扩展点建议落在源码的检索层而不是生成层因为生成层改动会影响你之前调好的效果。def rewrite_query(query: str, hit_scores: List[float], threshold: float 0.4) - str: # 首轮检索分数普遍偏低时尝试改写为更聚焦的问法 avg_score sum(hit_scores) / len(hit_scores) if avg_score threshold: return query # 实际项目中这里调用大模型改写伪代码示意 return f针对{query}列出与诊断、用药、禁忌相关的要点逻辑说明threshold是触发改写的分数阈值低于它就说明首轮检索没找到强相关片段把问句转成“要点式提问”再查一次往往能命中指南里的核心章节。这套做法在 agentic rag 项目里被反复验证过原理是让检索目标从“用户原话”变成“文档更可能写的表达方式”。6.2 验证方法用 20 个病例把系统压一遍升级完别急着开心把评估集扩成 20 个真实病例类问句跑一遍第 4.2 节的评估脚本重点看两个数字hit_rate 是否维持在 0.8 以上引用覆盖率答案里标注了有效编号的比例是否超过 90%。有这两个数兜底系统拿去答辩或演示才有底气。我自己的教训是曾经跳过了这步直接演示结果现场一个问题召回翻车场面一度尴尬。从那以后每次交出去的问答系统我都强制走一遍 20 问回归先看检索指标再看引用完整率这两个数字不过关绝不进演示环节。这套源码已经帮你把文档处理、检索、评估和部署的骨架搭好了照着跑一遍再按你自己的场景调一遍参比从零开始省太多时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表