ARTICLE DETAIL

资讯详情

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

医疗问答系统实战:基于RAG与大模型的检索增强生成架构与调优

医疗问答系统实战:基于RAG与大模型的检索增强生成架构与调优 简介这是一套面向计算机、人工智能及相关专业学生与开发者的毕业设计级完整项目基于RAG检索增强生成与大模型技术构建Python医疗问答系统可用于毕设、课程设计、项目演示或进阶学习。压缩包共1667个文件约140.19MB以ts、tsx前端源码与py后端脚本为主辅以json配置、md/mdx文档、pickle模型数据、png/jpg图片资源以及Dockerfile、yml、sh等部署脚本前后端与部署链路齐全。项目包含完整源码、详细设计报告及配套资料源码经过测试功能完备、运行稳定便于复现具备基础的用户可在其上进行二次开发扩展更多医疗问答场景。已有56人学习关注。对于配置和运行有疑问的初学者作者提供远程指导与技术支持帮助读者快速理解RAG检索流程、大模型调用与前后端交互逻辑是一份可直接用于毕设答辩与项目实战的参考方案。1. 医疗问答系统为什么死磕 RAG 与大模型从一次答非所问说起去年帮一个做互联网医院的朋友看他们的智能导诊用户问“吃了头孢能喝藿香正气水吗”系统回了一段头孢的适应症完全没提酒精和藿香正气水含乙醇这回事。这不是模型不聪明是它压根不知道这条冷门禁忌——通用大模型的参数里没存这种长尾医学知识。医疗问答系统基于 RAG 与大模型技术本质就是给大模型外挂一个可检索的医学知识库让它先查资料再回答。这个方向适合做毕设的计算机学生、想入门 RAG 的 Python 开发者以及需要私有化医疗问答的团队。RAG 检索增强能解决大模型幻觉和知识过期两个硬伤而医疗场景对准确性要求极高正好是 RAG 最该发力的地方。下面从架构选型一路讲到检索调参和避坑都是能直接复现的路子。2. 医疗 RAG 系统的四层架构与选型逻辑2.1 为什么医疗场景不能只靠大模型微调很多人第一反应是拿医学语料去微调大模型觉得把知识灌进参数里就完事了。这条路在医疗问答上问题很大。微调成本高一次全量微调动辄几张 A100 跑几天毕设环境根本扛不住。更麻烦的是知识更新医学指南每年都在改微调一次就得重来而 RAG 只需要替换知识库里的文档。还有可解释性微调后的模型答错了你根本不知道它从哪学的RAG 能直接给出检索到的原文出处医生和患者都更信服。大模型微调和 RAG 不是对立的。常见做法是基座模型用开源的中文医疗微调版本比如基于 Qwen 或 Baichuan 在医学语料上做过指令微调的模型负责语言组织和推理RAG 负责提供事实性知识。这样分工微调管“怎么说”RAG 管“说什么”。毕设里如果时间紧微调这步可以跳过直接用通用中文大模型加 RAG效果也能打。选型上嵌入模型决定检索质量是 RAG 的命门。医疗文本专业术语多通用嵌入模型比如 text-embedding-ada-002 对“心肌梗死”和“心梗”的向量距离可能拉得不够近。我一般会选在中文医学语料上训练过的嵌入模型或者至少是中文优化过的比如 BGE 系列的中文版。生成模型侧7B 到 13B 参数量的中文模型在消费级显卡上能跑量化版毕设够用。2.2 四层架构拆解与数据流一个能跑起来的医疗 RAG 问答系统拆开就是四层文档处理层、向量检索层、生成层、应用层。文档处理层负责把 PDF、Word、网页这些原始医学资料切成适合检索的片段。医疗文档有个特点表格和列表特别多药品说明书里剂量、禁忌、相互作用经常是表格形式。直接按固定字数切会把一个药品的完整信息切散检索时只召回半截。我一般会按语义段落切遇到表格单独处理成结构化文本再嵌入。向量检索层把切好的文本块用嵌入模型转成向量存进向量数据库。检索时把用户问题也转成向量算相似度取 Top-K。这里有个 RAG 瓶颈纯向量检索对精确匹配不敏感。用户问“阿司匹林肠溶片”知识库里写的是“阿司匹林”向量能匹配上但如果问一个具体的药品商品名向量可能召回一堆不相关的。所以生产级 RAG 通常是向量检索加关键词检索的混合方案。生成层拿到检索结果后把问题和检索到的上下文拼成 prompt 喂给大模型。prompt 模板很关键要明确告诉模型“只根据以下资料回答资料里没有就说不知道”。医疗场景宁可拒答也不能瞎编。应用层就是前端交互和会话管理多轮对话时要把历史对话也纳入检索上下文否则用户追问“那这个药能吃吗”系统就懵了。2.3 最小可跑通的代码骨架下面这段代码用 Python 搭一个最小 RAG 流程嵌入模型和生成模型都走本地部署不依赖外部 API。向量库用 FAISS轻量适合毕设。import numpy as np from sentence_transformers import SentenceTransformer import faiss from transformers import AutoTokenizer, AutoModelForCausalLM # 1. 加载嵌入模型这里用中文优化的 BGE 小模型 embed_model SentenceTransformer(BAAI/bge-small-zh-v1.5) # 2. 模拟医学知识库文档实际项目从 PDF/数据库加载 docs [ 头孢类抗生素与酒精同服可能引发双硫仑样反应表现为面部潮红、头痛、恶心、呼吸困难。, 藿香正气水含有40%-50%的乙醇属于含酒精制剂。, 服用头孢类药物期间及停药后7天内应避免饮酒或使用含酒精的药物。, 双硫仑样反应严重时可导致心肌梗死、急性心衰甚至死亡。, ] # 3. 文档向量化并建索引 doc_vectors embed_model.encode(docs, normalize_embeddingsTrue) dimension doc_vectors.shape[1] index faiss.IndexFlatIP(dimension) # 内积相似度配合归一化等于余弦相似度 index.add(doc_vectors.astype(float32)) # 4. 加载生成模型7B 量化版在 8G 显存可跑 model_name Qwen/Qwen2-7B-Instruct tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForCausalLM.from_pretrained( model_name, device_mapauto, load_in_4bitTrue ) def rag_answer(question, top_k3): # 检索 q_vec embed_model.encode([question], normalize_embeddingsTrue) scores, indices index.search(q_vec.astype(float32), top_k) retrieved [docs[i] for i in indices[0]] # 拼 prompt强制模型基于资料回答 context \n.join(retrieved) prompt f你是一个医疗问答助手。请严格根据以下资料回答问题资料中没有的信息不要编造直接说不知道。 资料 {context} 问题{question} 回答 inputs tokenizer(prompt, return_tensorspt).to(model.device) outputs model.generate(**inputs, max_new_tokens256, temperature0.1) answer tokenizer.decode(outputs[0], skip_special_tokensTrue) return answer, retrieved # 测试 ans, refs rag_answer(吃了头孢能喝藿香正气水吗) print(ans) print(检索到的依据, refs)这段代码的逻辑是先把知识库文档向量化存进 FAISS用户提问时检索最相似的 Top-K 文档把文档和问题拼成 prompt 让大模型生成回答。参数上normalize_embeddingsTrue配合IndexFlatIP等价于余弦相似度这是最常用的向量检索方式。top_k3是检索返回的文档数太小可能漏掉关键信息太大则 prompt 变长、生成变慢且可能引入噪声医疗场景建议 3 到 5。temperature0.1让输出更确定医疗问答不需要创造性。load_in_4bitTrue是量化加载显存不够时的后悔药。3. 文档切分与向量化医疗文本的预处理实战3.1 医疗文档切分的三个参数切分看着简单实际是 RAG 效果的分水岭。chunk_size 和 chunk_overlap 这两个参数直接决定检索质量。chunk_size 是每个文本块的字数医疗文本我一般设 300 到 500 字。太小了信息不完整比如一个药品的禁忌症被切到两个块里检索只召回一半太大了向量表征会稀释一个 1000 字的块里可能只有一句话和问题相关但整块向量被其他内容拉偏。chunk_overlap 是相邻块的重叠字数设 chunk_size 的 10% 到 20%比如 50 到 100 字。重叠是为了防止关键信息正好落在切分边界上被切断。医疗文本里“禁忌”“慎用”“不良反应”这些关键词经常出现在段落中间重叠能提高召回率。还有一个容易被忽略的参数是分隔符优先级。医疗文档里段落 句子 逗号切分时优先按段落切段落超长再按句子切。LangChain 的 RecursiveCharacterTextSplitter 支持自定义分隔符列表中文场景要把\n\n、\n、。、都加进去。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size400, chunk_overlap80, separators[\n\n, \n, 。, , , ], length_functionlen, ) chunks splitter.split_text(medical_document)separators列表的顺序就是优先级先按双换行切段落段落还超长就按单换行再超长按句号切。length_functionlen对中文按字符数算英文场景可以换成 token 计数。3.2 表格和结构化数据的处理药品说明书、检验报告这类文档里表格占比很高。纯文本切分器会把表格拍扁成一行行的文字丢失行列对应关系。比如一个表格里“药品名 | 禁忌 | 剂量”三列拍扁后变成“阿司匹林 胃溃疡禁用 100mg”检索“胃溃疡禁用哪些药”时可能召回但检索“阿司匹林剂量”时这段文本的向量会被“胃溃疡”带偏。我的做法是表格单独抽出来转成“药品名阿司匹林禁忌胃溃疡剂量100mg”这种键值对文本再嵌入。这样每个字段的语义更集中。Python 里用 pdfplumber 抽 PDF 表格用 python-docx 抽 Word 表格转成 JSON 再拼成自然语言。import pdfplumber def extract_tables(pdf_path): structured [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: tables page.extract_tables() for table in tables: headers table[0] for row in table[1:]: record dict(zip(headers, row)) # 转成键值对文本保留字段语义 text .join([f{k}{v} for k, v in record.items() if v]) structured.append(text) return structured这段代码把 PDF 里每个表格转成一条条“字段值”的文本。extract_tables()返回的是二维列表第一行当表头后面每行和表头 zip 成字典。实际用的时候要处理空单元格和跨页表格跨页表格需要判断表头是否重复出现。3.3 向量化时的归一化与批量处理嵌入模型输出的是高维向量算相似度前要做归一化。不归一化的话向量模长会影响相似度计算长文本的向量模长通常更大会不公平地获得更高相似度。normalize_embeddingsTrue把向量缩放到单位长度这时内积就等于余弦相似度。批量处理是性能关键。几千个文档块逐个 encode 很慢SentenceTransformer.encode支持传列表批量编码内部会自动分 batch。batch_size 默认 32显存够可以调到 64 或 128。医疗知识库动辄上万块批量编码能把时间从几小时压到几分钟。# 批量编码batch_size 根据显存调整 doc_vectors embed_model.encode( chunks, batch_size64, normalize_embeddingsTrue, show_progress_barTrue )show_progress_barTrue在毕设演示时很有用能直观看到处理进度。编码完的向量存 FAISS 或 ChromaFAISS 适合纯向量检索Chroma 自带元数据过滤医疗场景如果要按科室、药品类型过滤Chroma 更方便。4. 检索策略调优从 Top-K 到混合检索4.1 Top-K 和相似度阈值的配合Top-K 决定返回几个文档块相似度阈值决定低于多少分的结果直接丢弃。只设 Top-K 不设阈值用户问一个知识库里完全没有的问题系统也会硬凑 K 个最相似的返回大模型拿到这些不相关上下文就可能编答案。医疗场景这是大忌。我一般设相似度阈值 0.5 到 0.6低于这个分的检索结果不要。同时 Top-K 设 5阈值过滤后可能只剩 2 到 3 条够用。阈值设太高会漏召回设太低等于没设。这个值要在自己的知识库上试不同嵌入模型的分数分布不一样。def retrieve_with_threshold(question, top_k5, threshold0.55): q_vec embed_model.encode([question], normalize_embeddingsTrue) scores, indices index.search(q_vec.astype(float32), top_k) results [] for score, idx in zip(scores[0], indices[0]): if score threshold: results.append({text: docs[idx], score: float(score)}) return resultsthreshold0.55是经验值BGE 模型在中文语义相似度上 0.55 大致对应“相关”的下限。实际项目里可以拿一批标注好的问答对跑一遍画 precision-recall 曲线找最佳阈值。4.2 混合检索向量加 BM25纯向量检索的短板在精确匹配。用户问“阿司匹林肠溶片100mg”知识库里有“阿司匹林肠溶片”和“阿司匹林片100mg”两条向量检索可能把两条都召回但排序不理想。BM25 是关键词检索对精确词项匹配敏感两者结合能互补。常见做法是向量检索取 Top-20BM25 取 Top-20然后用 Reciprocal Rank Fusion 合并排序。RRF 不需要调权重按排名倒数求和简单有效。from rank_bm25 import BM25Okapi # 中文需要先分词用 jieba import jieba tokenized_docs [list(jieba.cut(doc)) for doc in docs] bm25 BM25Okapi(tokenized_docs) def hybrid_retrieve(question, top_k5): # 向量检索 q_vec embed_model.encode([question], normalize_embeddingsTrue) vec_scores, vec_indices index.search(q_vec.astype(float32), 20) # BM25 检索 tokenized_q list(jieba.cut(question)) bm25_scores bm25.get_scores(tokenized_q) bm25_indices np.argsort(bm25_scores)[::-1][:20] # RRF 融合 rrf {} for rank, idx in enumerate(vec_indices[0]): rrf[idx] rrf.get(idx, 0) 1 / (60 rank) for rank, idx in enumerate(bm25_indices): rrf[idx] rrf.get(idx, 0) 1 / (60 rank) sorted_idx sorted(rrf, keyrrf.get, reverseTrue)[:top_k] return [docs[i] for i in sorted_idx]RRF 公式里 60 是平滑常数防止排名靠前时倒数过大。这个方案不用调向量和 BM25 的权重工程上省事。jieba 分词对医疗术语要加自定义词典否则“双硫仑样反应”会被切成“双硫仑”“样”“反应”BM25 匹配就废了。4.3 重排序检索后的最后一道关检索召回 20 条真正相关的可能只有 3 条直接喂给大模型会引入噪声。重排序模型Reranker对召回的文档逐条和问题算相关性重新排序取 Top-3。Reranker 比嵌入模型慢但准因为它能做问题-文档的交叉注意力不是各自编码后算距离。BGE 系列有配套的 reranker用法和嵌入模型类似。流程是向量检索召回 20 条 → reranker 精排 → 取 Top-3 给大模型。这一步在医疗场景很值能把不相关的药品说明书过滤掉。from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True) def rerank(question, candidates, top_k3): pairs [[question, doc] for doc in candidates] scores reranker.compute_score(pairs) ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return [doc for doc, _ in ranked[:top_k]]use_fp16True用半精度推理速度快一倍精度损失很小。reranker 的分数和嵌入模型的余弦相似度不是一个量纲不要混用阈值。5. 医疗 RAG 避坑与常见问题排查5.1 检索到了但大模型不用现象检索结果里有正确答案但大模型生成时忽略它按自己的参数知识回答。原因通常是 prompt 模板没约束好模型没意识到必须用上下文。解决prompt 里明确写“只根据以下资料回答资料中没有的信息说不知道”并且把资料放在问题前面模型对前置上下文更敏感。还可以在资料前后加分隔符比如【资料开始】...【资料结束】让模型清楚边界。5.2 多轮对话时检索跑偏现象第一轮问“头孢禁忌”第二轮问“那藿香正气水呢”系统检索“藿香正气水”但丢了“头孢”这个关键上下文召回一堆不相关的。原因检索时只用了当前问题没带历史。解决把最近一轮的问答拼进检索 query比如“头孢禁忌 那藿香正气水呢”或者用大模型先把追问改写成独立问题再检索。后者更准但多一次模型调用。5.3 中文分词导致 BM25 失效现象混合检索里 BM25 召回的全是无关文档。原因jieba 默认词典对医疗术语分词错误“心肌梗死”被切成“心肌”“梗死”BM25 匹配时和用户 query 的分词对不上。解决加载医学词典jieba 支持load_userdict把药品名、疾病名、检查项加进去。或者直接用字符级 BM25中文单字匹配虽然粗但不会因分词错误丢召回。5.4 向量库更新后检索结果没变现象往知识库里加了新文档但检索还是老结果。原因新文档没重新编码入库或者 FAISS 索引没重建。FAISS 的 IndexFlatIP 支持 add 增量添加但如果用了 IVF 等需要训练的索引新增数据要重新训练。解决维护一个文档 ID 到向量的映射新增时只编码新文档并 add同时记录版本号。Chroma 这类数据库自带增删改比裸 FAISS 省心。5.5 显存不够导致生成中断现象检索正常生成到一半报 CUDA out of memory。原因7B 模型全精度加载要 14G 显存加上嵌入模型和 reranker 同时驻留8G 卡扛不住。解决生成模型用 4bit 量化加载嵌入和 reranker 用完及时释放或者把嵌入模型换成 small 版本。毕设演示时可以把 reranker 关掉用向量检索加阈值过滤也能跑。6. 把 RAG 问答系统做扎实的两个进阶技巧第一个技巧是查询改写。用户问“吃了头孢能喝藿香正气水吗”直接拿这句话去检索向量可能匹配到“头孢”和“藿香正气水”两个主题的文档但真正相关的是“双硫仑样反应”那条。查询改写用大模型把口语化问题转成检索友好的关键词组合比如“头孢 酒精 相互作用 双硫仑样反应”召回准确率能提一截。实现上就是在检索前加一步模型调用prompt 写“把以下医疗问题改写成适合检索的关键词用空格分隔”。def rewrite_query(question): prompt f把以下医疗问题改写成适合检索的关键词用空格分隔只输出关键词{question} inputs tokenizer(prompt, return_tensorspt).to(model.device) outputs model.generate(**inputs, max_new_tokens64, temperature0.1) return tokenizer.decode(outputs[0], skip_special_tokensTrue).strip()这个改写步骤会增加一次生成延迟但医疗问答对准确率的要求高于速度值得。改写后的关键词同时喂给向量检索和 BM25混合检索的效果更稳。第二个技巧是答案溯源。医疗问答系统给出的每个结论都要能指回原文这既是可信度也是合规要求。实现上在 prompt 里要求模型在回答中标注引用编号比如“头孢与酒精同服可能引发双硫仑样反应[1]”然后把检索到的文档按编号附在回答后面。前端展示时把 [1] 做成可点击的点开显示原文。这个功能在毕设答辩时是加分项评委一看就知道你想到了落地。技巧增加延迟效果提升适用场景查询改写约 0.5-1s召回准确率提升 10%-20%用户提问口语化严重答案溯源几乎无可信度和合规性所有医疗问答混合检索约 0.1s精确匹配场景提升明显药品名、检查项查询重排序约 0.3-0.5sTop-3 准确率提升知识库文档量大这几个技巧不用全上毕设时间有限的话优先做答案溯源和混合检索性价比最高。查询改写和重排序看剩余时间。我自己踩过最深的坑是早期版本没设相似度阈值用户问“新冠疫苗第三针加强针多久打”知识库里只有第一针第二针的信息系统硬凑了五条不相关的文档大模型编了一个“六个月后打第三针”的答案。后来加了阈值过滤和拒答逻辑检索不到相关文档时直接回“抱歉知识库中没有相关信息建议咨询医生”。医疗问答系统宁可拒答也不能给错答案这条底线比任何技术优化都重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表