☰
Python构建RAG医疗问答系统:从文档切分到检索生成全流程
2026/10/11 7:13:40 网站建设 项目流程

简介:这是一套面向计算机相关专业学生与项目实战学习者的高分毕业设计资源,主题为基于RAG与大模型技术的Python医疗问答系统,评审分达99分,代码完整可运行,适合用作毕业设计、课程设计或期末大作业。压缩包共75个文件,约84.66MB,包含10个py源码、7个ipynb实验笔记、19个txt数据与说明、18个png及4个jpg界面截图、7个json配置、3个yaml与3个md文档等,覆盖模型微调、命名实体识别、知识图谱构建与Web交互等模块。已有120人学习下载。读者可获得从数据预处理、图谱搭建到问答推理的完整工程链路,配合文档说明与运行笔记,能快速理解RAG检索增强与大模型微调的实现思路,并在此基础上完成二次开发与答辩准备。

1. 医疗问答系统为什么不能直接调大模型 API:从一次答非所问说起

你问它「二甲双胍的禁忌证有哪些」,它洋洋洒洒列了五条,其中两条是编的。这不是模型不聪明,而是它压根没见过你们医院那本内部用药手册。医疗问答系统跟通用聊天机器人最大的区别在于:答案必须可溯源、可审计、错了要能定位到是哪段知识出了问题。直接调大模型 API 做医疗问答,等于让一个博览群书但从没读过你科室规范的人坐诊,翻车是迟早的事。

RAG(检索增强生成)解决的正是这个断层:先从你的私有知识库里检索出相关段落,再让大模型基于这些段落组织答案。配合 Python 生态里成熟的向量库和框架,一套能跑通「上传文档 → 切分入库 → 检索 → 生成带出处回答」的医疗问答系统,单机就能搭起来。这篇笔记面向要做毕设或落地原型的开发者,把选型、代码、参数和踩过的坑一次讲清楚,你照着能复现,也能判断这套方案到底值不值得投入。

2. RAG 医疗问答的检索链路:从文档切分到向量召回

2.1 为什么医疗场景必须用 RAG 而不是微调

先把这个选型问题说透,因为很多人一上来就想微调大模型。微调适合改变模型的表达风格或领域语气,但它不擅长注入频繁变动的 factual knowledge。医疗知识的特点恰恰是:药品说明书会更新、诊疗指南每年修订、科室有自己的补充规定。你微调一次成本不低,知识一变又得重来。

RAG 的优势在于知识外置。文档更新了,重新切分入库即可,模型本身不动。对于毕设这种周期短、知识源明确(通常就是几本教材加若干指南)的场景,RAG 的投入产出比远高于微调。常见做法是:RAG 负责「答得对」,微调负责「答得像」,两者不冲突,但毕设阶段先把 RAG 跑通性价比最高。

还有一个现实考量:可解释性。RAG 能给出答案引用了哪几段原文,答辩时老师问「你这个答案哪来的」,你能直接指出来。纯微调模型给不出这个。

2.2 文档切分:医疗文本的 chunk 策略

切分是 RAG 里最容易被低估的一步。通用教程喜欢用固定 500 字切分,但医疗文本有它的特殊性:一条药品的「用法用量」和「禁忌」如果被切到两个 chunk 里,检索时只召回一半,答案就会残缺。

我一般用「按语义结构切分 + 重叠窗口」的组合策略。对于结构化的药品说明书,优先按小标题切;对于叙述性的指南文本,用递归字符切分并保留重叠。

from langchain.text_splitter import RecursiveCharacterTextSplitter # 医疗文本切分:优先按段落和标题切,保留 15% 重叠防止语义断裂 splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 单块目标长度,医疗文本建议 400-600 chunk_overlap=80, # 重叠长度,约为 chunk_size 的 15% separators=["\n\n", "\n", "。", ";", ",", ""], # 中文标点优先 length_function=len, ) chunks = splitter.split_text(raw_medical_text) print(f"切分出 {len(chunks)} 个片段,平均长度 {sum(len(c) for c in chunks)//len(chunks)}")

逻辑说明:separators的顺序决定了切分优先级,先尝试在段落间切,切不动再退到句号、分号。中文医疗文本里「;」常用来分隔并列的禁忌项,放在分隔符里能避免把一组禁忌拆散。chunk_overlap设 80 是为了让跨块的语义有衔接,比如「除下列情况外禁用」和后面的列表。

参数怎么调:chunk_size 太小(如 200)会导致单块信息不完整,检索出来答非所问;太大(如 1000)会引入无关内容稀释相关性。医疗问答我实测 400-600 比较稳。overlap 别超过 chunk_size 的 20%,否则检索结果重复度高。

2.3 向量化与入库:embedding 模型的选择

切分完要转成向量存进向量库。embedding 模型的选择直接决定检索质量。中文医疗场景我推荐用 BGE 系列的中文模型,它在中文语义匹配上比通用多语言模型好一截。如果算力有限,用轻量版也能接受,但召回率会降。

from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import Chroma # 加载中文 embedding 模型,normalize 让余弦相似度计算更稳定 embedding = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, # 有 GPU 改 "cuda" encode_kwargs={"normalize_embeddings": True}, ) # 持久化到本地磁盘,避免每次重启重新计算 vectorstore = Chroma.from_texts( texts=chunks, embedding=embedding, persist_directory="./medical_db", collection_name="medical_qa", ) vectorstore.persist()

逻辑说明:normalize_embeddings=True把向量归一化到单位长度,这样内积就等于余弦相似度,检索打分更直观。persist_directory指定持久化路径,Chroma 会把向量和原文一起存下来,下次直接加载不用重算。

参数说明:device选 cpu 还是 cuda 看你的机器,small 版模型 CPU 也能跑,只是入库慢一点。collection_name 建议按知识库来源命名,比如「内科指南」和「药品说明书」分开建 collection,检索时可以指定范围,避免跨领域干扰。

2.4 检索召回:top-k 与相似度阈值怎么定

检索环节有两个关键参数:召回几条(top-k)和相似度阈值。k 太小可能漏掉关键信息,太大则引入噪声让大模型分心。医疗问答我一般 k=4 到 6,配合一个相似度下限过滤掉明显不相关的。

# 带相似度分数的检索,方便做阈值过滤 results = vectorstore.similarity_search_with_score( query="二甲双胍的禁忌证", k=6, ) # 过滤掉相似度低于阈值的片段(Chroma 返回的是距离,越小越相似) filtered = [(doc, score) for doc, score in results if score < 0.8] context = "\n\n".join([doc.page_content for doc, _ in filtered])

逻辑说明:similarity_search_with_score返回文档和距离分数。注意 Chroma 默认返回的是距离(越小越相似),不是相似度,阈值方向别搞反。过滤后把剩余片段拼成 context 喂给大模型。

参数说明:阈值 0.8 是针对归一化向量的经验值,不同 embedding 模型和距离度量下要重新标定。判断方法:拿几个已知相关的 query 跑一遍,看相关片段的分数分布,取一个能滤掉噪声又保留相关的分界点。

3. 用 Python 把生成环节接上:Prompt 设计与大模型调用

3.1 医疗问答的 Prompt 模板怎么写才不胡说

检索到 context 之后,Prompt 决定了模型怎么用这些 context。医疗场景的 Prompt 必须强约束:只允许基于给定资料回答,资料里没有就明说不知道,禁止自行发挥。这是防止幻觉的第一道闸门。

MEDICAL_PROMPT = """你是一个严谨的医疗问答助手。请严格根据下面提供的资料回答问题。 要求: 1. 只使用资料中明确提到的信息,不要补充资料外的知识。 2. 如果资料不足以回答,直接回复"根据现有资料无法回答该问题"。 3. 回答时标注信息来自哪段资料,格式为[资料N]。 4. 涉及用药剂量、禁忌等关键信息时,原文引用,不要改写。 资料: {context} 问题:{question} 回答:"""

逻辑说明:四条要求分别对应四个常见问题——幻觉、强行回答、不可溯源、关键信息被改写。第 4 条尤其重要,剂量和禁忌改写一个字都可能出大事,要求原文引用能最大程度保真。

参数说明:{context}和{question}是占位符,用的时候替换。temperature 建议设 0.1 到 0.3,医疗问答不需要创造性,越低越稳定。

3.2 调用大模型的两种落地方式

落地时你有两条路:调云端 API 或本地部署开源模型。毕设场景如果预算有限,本地部署更可控,也不依赖网络。

# 方式一:调用兼容 OpenAI 接口的本地服务(如 Ollama) from openai import OpenAI client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") def ask_medical(question, context): prompt = MEDICAL_PROMPT.format(context=context, question=question) resp = client.chat.completions.create( model="qwen2.5:7b", # 本地模型名,按实际部署改 messages=[{"role": "user", "content": prompt}], temperature=0.2, max_tokens=800, ) return resp.choices[0].message.content

逻辑说明:本地部署的模型服务通常兼容 OpenAI 接口格式,改base_url就能对接。这样代码不用为不同后端重写。

参数说明:temperature=0.2保证输出稳定;max_tokens=800限制回答长度,医疗问答不需要长篇大论,太长反而容易夹带私货。模型选 7B 级别在消费级显卡上能跑,效果对毕设够用。

3.3 把检索和生成串成完整链路

单测通过后,把前面的模块串起来就是一个最小可用的问答函数。

def rag_qa(question): # 1. 检索 results = vectorstore.similarity_search_with_score(question, k=6) filtered = [(doc, s) for doc, s in results if s < 0.8] if not filtered: return "根据现有资料无法回答该问题。" # 2. 拼 context,带上资料编号方便溯源 context = "\n\n".join( [f"[资料{i+1}] {doc.page_content}" for i, (doc, _) in enumerate(filtered)] ) # 3. 生成 return ask_medical(question, context) print(rag_qa("二甲双胍的禁忌证有哪些"))

逻辑说明:检索为空时直接返回兜底话术,不浪费一次大模型调用。context 里加[资料N]编号,配合 Prompt 里的溯源要求,模型回答时能引用编号,你也能反查。

参数说明:这里的 k 和阈值要和 2.4 节保持一致,改一处要同步改另一处,否则行为不一致。

4. 医疗 RAG 的避坑清单:五个真实翻车现场

4.1 现象:答案里出现了资料中根本没有的药名

原因:Prompt 约束不够强,或者 temperature 设太高,模型在「补全」它训练时见过的知识。医疗领域这种幻觉最危险。

解决:Prompt 里明确「只使用资料信息」,temperature 压到 0.2 以下,并在后处理里做一次校验——把回答里的药名和 context 做匹配,出现 context 里没有的药名就标记待人工复核。

4.2 现象:同一个问题问两次,答案不一样

原因:检索环节有随机性(某些向量库默认带随机采样),或者大模型 temperature 不为 0。

解决:检索时固定 top-k 和排序方式,关闭随机采样;生成时 temperature 设 0 到 0.2。医疗问答要的是稳定复现,不是多样性。

4.3 现象:检索总是召回不相关的片段

原因:embedding 模型和你的文本领域不匹配,或者 chunk 切得太碎导致语义不完整。

解决:换成中文医疗语料训练过的 embedding 模型;检查切分结果,如果单块只有一两句话,说明 chunk_size 太小,调大并增加 overlap。

4.4 现象:知识库更新后,旧答案还在

原因:向量库没重新入库,或者用了缓存没清。

解决:文档更新后必须重新切分、重新 embedding、重建 collection。别在原 collection 上增量加,容易残留旧向量。我一般直接删库重建,毕设数据量不大,重建成本可以接受。

4.5 现象:回答很长但没重点,关键信息被淹没

原因:max_tokens 设太大,或者 Prompt 没要求简洁。

解决:Prompt 里加「回答控制在 200 字以内,先给结论再给依据」;max_tokens 收到 500 左右。医疗问答用户要的是快速拿到关键信息,不是读论文。

5. 让检索更准的两个进阶技巧:重排序与混合检索

5.1 用重排序模型给召回结果二次打分

向量检索是「粗筛」,它快但不够准。进阶做法是召回一批(比如 20 条)后用重排序模型精排,取前几条喂给大模型。重排序模型(reranker)会同时看 query 和文档做交叉编码,精度比纯向量高不少。

from sentence_transformers import CrossEncoder # 重排序模型,对 query-doc 对做精细打分 reranker = CrossEncoder("BAAI/bge-reranker-base") def retrieve_with_rerank(query, top_k=4): # 先粗召回 20 条 candidates = vectorstore.similarity_search(query, k=20) # 构造 query-doc 对打分 pairs = [(query, doc.page_content) for doc in candidates] scores = reranker.predict(pairs) # 按分数排序取前 top_k ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [doc for doc, _ in ranked[:top_k]]

逻辑说明:先用向量检索快速缩小范围,再用重排序模型精排。重排序模型计算量大,所以只对少量候选做,兼顾速度和精度。

参数说明:粗召回 k=20 是经验值,太小重排序没得选,太大拖慢速度。重排序模型选 base 版就够,large 版精度略高但慢一倍。

5.2 混合检索:向量 + 关键词双路召回

纯向量检索对专有名词(比如某个罕见药名)不敏感,因为它看的是语义相似而非字面匹配。混合检索把向量召回和 BM25 关键词召回结合,能补上这个短板。

检索方式优势短板适用场景
向量检索语义匹配强专有名词弱口语化提问
BM25 关键词字面精确不懂同义药名、术语查询
混合检索两者互补需调权重医疗问答推荐

落地时把两路结果按加权分数合并,权重一般向量 0.6、关键词 0.4,具体按你的数据调。医疗问答里药名查询很常见,混合检索的收益明显。

5.3 一个验证检索质量的小习惯

改完检索参数别凭感觉,建一个小的评测集:准备 20 个问题和它们对应的正确文档片段,每次改完跑一遍,看正确片段有没有被召回进 top-k。这个习惯帮我省了很多「以为改好了其实更差」的后悔药。召回率上不去,后面生成再花哨都是白搭。

我踩过最深的坑是:花两天调 Prompt,结果发现根本是检索没召回对的片段,模型再强也变不出来。先保证检索,再优化生成,这个顺序别反。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询