基于RAG与大模型的医疗问答系统:Python源码解析与避坑指南

发布时间:2026/10/25 18:29:55
基于RAG与大模型的医疗问答系统:Python源码解析与避坑指南

简介:这份资源是面向计算机相关专业学生与项目实战学习者的医疗问答系统完整实现,以RAG检索增强生成结合大模型为核心技术路线,适合用作毕业设计、课程大作业或进阶练手项目。压缩包共75个文件,约84.66MB,包含10个Python源码文件、7个Jupyter Notebook实验记录、19个txt数据与说明、18张png界面截图及json、yaml、md等配置文档,覆盖数据处理、模型微调、知识图谱构建与Web交互等模块。项目经导师指导并通过评审,源码本地编译可运行,调试完整。已有138人学习关注。读者可获得从医疗语料清洗、实体识别、图谱搭建到LangChain与ChatGLM集成的完整方案,并借助Notebook与截图快速理解各环节实现逻辑与排错思路,目录结构清晰,便于按模块拆解学习与二次开发。

1. 医疗问答系统为什么不能只靠大模型硬答

把一份「基于 RAG 与大模型的医疗问答系统 Python 源码和文档说明」放到面前,很多人第一反应是:不就是调个大模型 API,把用户问题丢进去,再把回答吐出来吗?真做过医疗场景的人会告诉你,纯靠大模型硬答,翻车是迟早的事。医疗问答对事实准确性、可追溯性、边界控制的要求远高于闲聊,模型一旦编造出看似专业的用药剂量或诊疗建议,后果不是「答得不好」而是「可能误导」。RAG(检索增强生成)要解决的正是这个痛点:先从可信知识库里检索出相关片段,再让大模型基于这些片段组织答案,把「凭记忆瞎说」变成「看着资料回答」。

这套系统适合谁?计算机相关专业的毕设同学,需要一个有技术深度、能讲清原理又能跑起来的题目;也适合刚接触大模型应用开发、想搞明白 RAG 完整链路的工程师。它覆盖的核心环节包括文档解析、文本切分、向量化、向量检索、Prompt 组装、大模型调用和答案溯源。下面按「先立住原理、再动手复现、最后讲坑」的顺序拆开讲,每一步都给到能直接抄的代码和参数说明。

2. 把医疗知识库喂进 RAG:文档解析与切分

2.1 医疗语料的特殊性决定了切分策略

医疗知识库的常见来源是药品说明书、诊疗指南、医学教材 PDF、FAQ 问答对。这些语料和通用网页文本差别很大:药品说明书里「适应症」「用法用量」「禁忌」「不良反应」是强结构化的,一段话往往就是一个完整语义单元;诊疗指南里则经常出现「一线用药」「二线用药」这种带层级关系的表述。如果按固定字数硬切,很容易把「用法用量」和「禁忌」切到两个块里,检索时召回一个却丢了另一个,答案就不完整。

我一般会按「标题层级 + 段落」做切分,而不是纯按字符数。具体做法是:先用解析库把 PDF 或 Word 转成带层级标记的文本,再以标题为边界切大块,块内如果超过阈值再按句子切。切分块大小控制在 300~500 字比较稳,太小会丢上下文,太大检索精度下降。块之间保留 50~80 字重叠,防止关键信息正好卡在边界上被切断。

2.2 用 Python 把 PDF 和 Word 解析成结构化文本

import re from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader from langchain.text_splitter import RecursiveCharacterTextSplitter def load_medical_docs(file_path): """根据文件后缀选择解析器,返回原始文档列表""" if file_path.endswith(".pdf"): loader = PyPDFLoader(file_path) elif file_path.endswith(".docx"): loader = Docx2txtLoader(file_path) else: raise ValueError("仅支持 pdf / docx") docs = loader.load() # 清洗页眉页脚等噪声:连续空白、页码行 for d in docs: d.page_content = re.sub(r"\n\s*\d+\s*\n", "\n", d.page_content) d.page_content = re.sub(r"[ \t]+", " ", d.page_content) return docs def split_docs(docs): """按语义边界切分,块大小 400 字,重叠 60 字""" splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=60, separators=["\n\n", "\n", "。", ";", ",", " "], length_function=len, ) return splitter.split_documents(docs)

这段代码的逻辑是:先按文件类型选解析器,PDF 用 PyPDFLoader,Word 用 Docx2txtLoader,解析后做一轮正则清洗,把页码行和多余空格去掉。切分用 RecursiveCharacterTextSplitter,它的 separators 顺序很关键——优先按空行切,再按换行、句号、分号、逗号逐级降级,这样能尽量保证一个块内语义完整。chunk_size 设 400、chunk_overlap 设 60 是医疗语料比较通用的值,如果你的文档里单条药品说明特别长,可以把 chunk_size 提到 600,但别超过 800,否则检索时噪声会明显变多。

提示:解析完一定要人工抽查 5~10 个块,看看有没有把「禁忌」和「用法」切散。这一步偷懒,后面检索再优化也救不回来。

2.3 向量化与入库:选模型和选库的两个决策

切分完就要把文本转成向量存进向量库。Embedding 模型的选择直接决定检索质量。医疗领域如果追求中文效果,常见做法是用 BGE 系列或 M3E 这类中文优化模型,维度一般是 768 或 1024。不要用英文为主的通用模型硬套中文医疗语料,召回率会明显掉。向量库方面,毕设规模(几千到几万块)用 FAISS 或 Chroma 就够了,本地跑、零依赖、不花钱;如果要做企业大模型私有化部署那种规模,再考虑 Milvus 或 Qdrant。

from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS def build_vector_store(chunks, persist_dir="./med_faiss"): """把切分块向量化并持久化到本地""" embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-base-zh-v1.5", model_kwargs={"device": "cpu"}, # 有 GPU 改 cuda encode_kwargs={"normalize_embeddings": True}, # 归一化提升余弦相似度稳定性 ) db = FAISS.from_documents(chunks, embeddings) db.save_local(persist_dir) return db

normalize_embeddings 设为 True 是为了让向量归一化,配合余弦相似度检索更稳定,这是很多人会漏掉的一个参数。device 按机器实际情况选,毕设机器没显卡就用 cpu,几万块向量建库也就几分钟。存到本地后,下次启动直接 load_local 加载,不用重复向量化,省时间也省 API 调用。

3. 检索与生成:把召回片段变成可信答案

3.1 检索环节的三个必调参数

检索不是「相似度最高的那条直接拿来用」这么简单。top_k、相似度阈值、是否做重排,这三个参数决定了召回的片段质量和数量。top_k 太小可能漏掉关键信息,太大又会把无关内容塞进 Prompt 干扰模型。医疗问答我一般设 top_k=4~6,配合一个相似度阈值过滤掉明显不相关的块。如果向量库支持,再加一层重排(rerank)模型,把召回的块按与问题的真实相关度重新排序,取前 3 条进 Prompt,效果比单纯调 top_k 好很多。

def retrieve_context(db, query, top_k=5, score_threshold=0.35): """带阈值过滤的检索,返回 (文本, 来源) 列表""" results = db.similarity_search_with_score(query, k=top_k) contexts = [] for doc, score in results: # FAISS 返回的是 L2 距离,越小越相关,这里做简单归一化判断 if score < score_threshold: contexts.append({ "text": doc.page_content, "source": doc.metadata.get("source", "未知"), }) return contexts

这里要注意,不同向量库返回的 score 含义不一样:FAISS 默认返回 L2 距离,越小越相关;Chroma 返回的可能是余弦距离。阈值不能照搬,得先打印几条真实检索结果看看分布再定。我见过有人直接把阈值设成 0.8,结果一条都召不回来,排查半天才发现是距离和相似度搞反了。

3.2 Prompt 组装:让大模型「看着资料说话」

检索回来的片段怎么塞给大模型,直接决定答案质量。核心原则有三条:明确告诉模型只能基于给定资料回答、要求标注信息来源、资料里没有的内容要明确说「不知道」。医疗场景尤其不能允许模型自由发挥,宁可答「当前资料未涵盖」也不能编。

MED_PROMPT = """你是一个严谨的医疗知识问答助手。请严格根据下面提供的资料回答问题。 要求: 1. 只使用资料中的信息,不要引入资料之外的知识。 2. 如果资料不足以回答,直接回复「根据现有资料无法回答该问题」。 3. 回答末尾用 [来源: 文件名] 标注依据。 资料: {context} 问题:{question} 回答:""" def build_prompt(contexts, question): ctx_text = "\n\n".join( f"[{c['source']}]\n{c['text']}" for c in contexts ) return MED_PROMPT.format(context=ctx_text, question=question)

Prompt 里把「只使用资料」「无法回答就直说」「标注来源」写成硬性要求,是医疗问答可控性的关键。context 拼接时带上来源文件名,方便后面做答案溯源展示。如果你的大模型上下文长度有限,注意控制拼接后的总长度,一般留出 500~800 token 给回答,其余给资料。

3.3 调用大模型并做答案溯源

from openai import OpenAI client = OpenAI(base_url="你的模型服务地址", api_key="你的key") def answer_question(db, question): contexts = retrieve_context(db, question) if not contexts: return {"answer": "根据现有资料无法回答该问题", "sources": []} prompt = build_prompt(contexts, question) resp = client.chat.completions.create( model="你的模型名", messages=[{"role": "user", "content": prompt}], temperature=0.1, # 医疗场景压低随机性 max_tokens=600, ) return { "answer": resp.choices[0].message.content, "sources": list({c["source"] for c in contexts}), }

temperature 设 0.1 是为了压低随机性,医疗问答不需要「创造性」,要的是稳定复现。max_tokens 控制在 600 左右,避免模型啰嗦。返回结构里带上 sources,前端就能展示「本答案参考了哪些文档」,这是 RAG 相比纯大模型最大的可信度优势。如果用的是免费大模型 API 或本地部署模型,接口协议兼容 OpenAI 格式的话,这段代码基本不用改,只换 base_url 和 model 名即可。

4. 避坑排查:医疗 RAG 最容易翻车的 5 个地方

4.1 检索召回一堆但答案还是错

现象:明明知识库里有正确内容,检索也召回了相关块,但模型给出的答案仍然偏离。原因通常是召回块里混入了语义相近但结论相反的片段,比如同时召回了「某药适用于 A 病」和「某药禁用于 A 病并发症」,模型没分清主次。解决办法是在 Prompt 里要求模型对冲突信息做说明,或者引入重排模型把最相关的块排到最前,减少干扰块的影响。

4.2 切分把关键信息切断

现象:用户问某药的禁忌,系统答不出,但知识库里明明写了。原因多半是切分时「禁忌」标题和内容被切到了两个块,检索只召回了标题块或只召回了内容块。解决办法是切分时把标题和其下内容绑定,或者用带标题继承的切分策略,让每个块都带上所属章节标题。

4.3 相似度阈值设错导致空召回

现象:系统频繁回复「无法回答」,但知识库覆盖了这些问题。原因就是前面提到的,把距离当成了相似度,阈值方向设反。解决办法是先打印 20 条真实检索的 score 分布,确认数值范围后再定阈值,别拍脑袋。

4.4 Embedding 模型和语料语言不匹配

现象:中文医疗问题检索英文语料或通用语料时召回率极低。原因是用了英文为主的 embedding 模型。解决办法是换成中文优化的 BGE 或 M3E 系列,重新建库。换模型必须重建向量库,不能只换模型不重建,这是血泪经验。

4.5 大模型无视资料自由发挥

现象:资料里没有的内容,模型照样答得头头是道。原因是 Prompt 约束不够硬,或者 temperature 太高。解决办法是把「只使用资料」「无法回答就直说」写成明确规则,temperature 压到 0.1 以下,并在后处理里检查答案是否包含来源标注,没有标注的答案打回或提示存疑。

5. 进阶技巧:用混合检索和答案校验把准确率再抬一档

纯向量检索有个天然短板:对专有名词、药品编码、精确术语的匹配不如关键词检索。医疗场景里「阿司匹林肠溶片 100mg」这种精确表述,向量检索可能召回一堆语义相近但剂量不同的块。我一般会加一路 BM25 关键词检索,和向量检索结果做融合,这就是常说的混合检索。融合策略用简单的加权或 RRF(倒数排名融合)都行,实测对医疗问答的召回提升很明显。

from rank_bm25 import BM25Okapi import jieba def hybrid_retrieve(db, chunks, query, top_k=5): """向量检索 + BM25 关键词检索,RRF 融合""" # 向量路 vec_results = db.similarity_search(query, k=top_k) vec_ids = [d.metadata.get("id") for d in vec_results] # 关键词路 tokenized = [list(jieba.cut(c.page_content)) for c in chunks] bm25 = BM25Okapi(tokenized) scores = bm25.get_scores(list(jieba.cut(query))) bm25_ids = [chunks[i].metadata.get("id") for i in scores.argsort()[::-1][:top_k]] # RRF 融合 rrf = {} for rank, cid in enumerate(vec_ids): rrf[cid] = rrf.get(cid, 0) + 1 / (60 + rank + 1) for rank, cid in enumerate(bm25_ids): rrf[cid] = rrf.get(cid, 0) + 1 / (60 + rank + 1) top_ids = sorted(rrf, key=rrf.get, reverse=True)[:top_k] return [c for c in chunks if c.metadata.get("id") in top_ids]

RRF 里的 60 是经验常数,作用是削弱排名靠前项的绝对优势,让两路结果更均衡。这段代码要求每个 chunk 在入库时写入唯一 id 到 metadata,建库时补上即可。融合后再走前面的 Prompt 组装和生成流程,整体准确率会比单路向量检索稳不少。

另一个值得做的进阶是答案校验:生成答案后,把答案和召回资料再喂给模型做一次一致性检查,问它「这个答案是否完全被资料支持」。这一步会增加一次调用开销,但对医疗这种高风险场景,多一道校验就少一分误导风险。我自己的习惯是,任何涉及剂量、禁忌、诊断结论的回答,都必须过校验这一关,宁可慢一点也不能错。这套 RAG 加校验的链路,才是医疗问答系统真正能拿得出手、也经得起答辩追问的完整方案。希望帮到你。

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