
简介这份资源是面向计算机相关专业学生与项目实战学习者的医疗问答系统完整实现以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 转成带层级标记的文本再以标题为边界切大块块内如果超过阈值再按句子切。切分块大小控制在 300500 字比较稳太小会丢上下文太大检索精度下降。块之间保留 5080 字重叠防止关键信息正好卡在边界上被切断。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_size400, chunk_overlap60, separators[\n\n, \n, 。, , , ], length_functionlen, ) return splitter.split_documents(docs)这段代码的逻辑是先按文件类型选解析器PDF 用 PyPDFLoaderWord 用 Docx2txtLoader解析后做一轮正则清洗把页码行和多余空格去掉。切分用 RecursiveCharacterTextSplitter它的 separators 顺序很关键——优先按空行切再按换行、句号、分号、逗号逐级降级这样能尽量保证一个块内语义完整。chunk_size 设 400、chunk_overlap 设 60 是医疗语料比较通用的值如果你的文档里单条药品说明特别长可以把 chunk_size 提到 600但别超过 800否则检索时噪声会明显变多。提示解析完一定要人工抽查 510 个块看看有没有把「禁忌」和「用法」切散。这一步偷懒后面检索再优化也救不回来。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_nameBAAI/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 dbnormalize_embeddings 设为 True 是为了让向量归一化配合余弦相似度检索更稳定这是很多人会漏掉的一个参数。device 按机器实际情况选毕设机器没显卡就用 cpu几万块向量建库也就几分钟。存到本地后下次启动直接 load_local 加载不用重复向量化省时间也省 API 调用。3. 检索与生成把召回片段变成可信答案3.1 检索环节的三个必调参数检索不是「相似度最高的那条直接拿来用」这么简单。top_k、相似度阈值、是否做重排这三个参数决定了召回的片段质量和数量。top_k 太小可能漏掉关键信息太大又会把无关内容塞进 Prompt 干扰模型。医疗问答我一般设 top_k46配合一个相似度阈值过滤掉明显不相关的块。如果向量库支持再加一层重排rerank模型把召回的块按与问题的真实相关度重新排序取前 3 条进 Prompt效果比单纯调 top_k 好很多。def retrieve_context(db, query, top_k5, score_threshold0.35): 带阈值过滤的检索返回 (文本, 来源) 列表 results db.similarity_search_with_score(query, ktop_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(contextctx_text, questionquestion)Prompt 里把「只使用资料」「无法回答就直说」「标注来源」写成硬性要求是医疗问答可控性的关键。context 拼接时带上来源文件名方便后面做答案溯源展示。如果你的大模型上下文长度有限注意控制拼接后的总长度一般留出 500800 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}], temperature0.1, # 医疗场景压低随机性 max_tokens600, ) 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_k5): 向量检索 BM25 关键词检索RRF 融合 # 向量路 vec_results db.similarity_search(query, ktop_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, keyrrf.get, reverseTrue)[:top_k] return [c for c in chunks if c.metadata.get(id) in top_ids]RRF 里的 60 是经验常数作用是削弱排名靠前项的绝对优势让两路结果更均衡。这段代码要求每个 chunk 在入库时写入唯一 id 到 metadata建库时补上即可。融合后再走前面的 Prompt 组装和生成流程整体准确率会比单路向量检索稳不少。另一个值得做的进阶是答案校验生成答案后把答案和召回资料再喂给模型做一次一致性检查问它「这个答案是否完全被资料支持」。这一步会增加一次调用开销但对医疗这种高风险场景多一道校验就少一分误导风险。我自己的习惯是任何涉及剂量、禁忌、诊断结论的回答都必须过校验这一关宁可慢一点也不能错。这套 RAG 加校验的链路才是医疗问答系统真正能拿得出手、也经得起答辩追问的完整方案。希望帮到你。本文还有配套的精品资源点击获取