基于RAG与大模型的医疗问答系统实战:从架构到部署全解析
简介这是一份基于RAG与大模型的医疗问答系统毕业设计项目聚焦医疗文本知识检索、实体识别、知识图谱构建与问答生成等典型任务适合计算机相关专业学生用于大作业、毕业设计或项目实战。项目源码均经过调试可运行难度适中有助于快速理解大模型应用与检索增强生成落地流程。压缩包内共75个文件包含Python脚本py、Jupyter Notebookipynb、JSON/YAML配置与数据文件、Markdown说明文档以及用于展示界面的PNG/JPG图片整体约84.66MBpy文件覆盖数据预处理、NER模型、知识图谱构建、RAG调用与Web界面ipynb则直观呈现微调和评估过程。内容上涵盖医疗NER数据增强、ChatGLM微调、nl2cypher查询生成等模块并附有README文档和运行配置说明可帮助读者复现整套问答系统。目前已有134人学习下载适合希望基于RAG大模型快速搭建医疗问答原型并对源码进行二次开发的开发者。1. 基于 RAG 与大模型的医疗问答系统是个什么毕设选题很多人第一眼看到“基于 RAG 与大模型的医疗问答系统”这个标题会默认它是一套高不可攀的工程——既要搞检索、又要调大模型、还得处理医学知识。但实际做过一遍你会发现它是计算机类毕业设计里少有的、一个人能在一到两周内跑通全链路的选题。这套系统的本质是给大模型外挂一个“医疗知识库”把诊疗指南、药品说明书、健康科普这些资料切成片段、转成向量存起来用户提问时先从库里捞出最相关的几段再让大模型基于这些片段作答。和直接“问大模型”相比它的优势在于答案可溯源、资料可控更新而且不需要微调模型就能引入垂直领域知识。这篇笔记面向的是准备拿它做毕设、或者想快速上手 RAG 项目的开发者。我会按自己做过的方案把技术选型、最小实现、医疗场景的专属处理、常见坑和验收方法一次讲完。不需要你有 NLP 基础但最好能看懂 Python 和基本的命令行操作。2. 分层拆解 RAG 医疗问答的架构从资料入库到大模型生成RAG 不是某个库的名字而是一套流程。理解它最好的方式是把系统拆成“离线索引”和“在线问答”两段。离线阶段你要把散落的医疗文档清洗、切分、向量化存进向量数据库在线阶段用户问题进来后先向量化去库里做相似度检索再把检索到的原文片段和问题拼成提示词交给大模型生成答案。下面把每一层的职责和选型逻辑讲清楚。2.1 医疗问答为什么不建议直接“问大模型”大模型在医疗场景里有一个绕不开的问题幻觉。它不知道的知识会一本正经地编而且编出来的话听起来很专业。医疗场景对错误答案的容忍度极低所以不能直接拿通用模型当医生用。RAG 解决这个问题的方式很简单——强行把模型的回答范围锁死在检索回来的文档片段里并在提示词里写明“只能依据给定资料回答资料不足时直接说不知道”。另一个原因是时效性。医学知识更新很快指南和药品说明书都会修订。微调模型一次成本高、周期长而 RAG 只需要替换语料库文件再重建索引几分钟就能完成一轮知识更新。这也是答辩时最值得讲的一个卖点系统具备低成本的知识更新能力。2.2 四段式主链路加载、切分、向量化、检索生成整套系统的核心链路可以用四步概括。第一步是文档加载。医疗资料最常见的格式是 PDF、Word 和 Markdown。加载层要做的是把文件里的文字抽出来生成一个带来源标记的文档对象列表。第二步是文本切分。这一步决定了检索质量的上限。医疗长文档里一段描述症状、一段写用药禁忌如果切分太粗检索命中后上下文冗余切分太细语义被切散。一般做法是按段落和句号做递归切分配合 10% 到 20% 的重叠窗口。第三步是向量化。把切好的文本片段通过嵌入模型转成向量。选型上中文场景优先考虑中文本地化支持好的嵌入模型比如 BAAI/bge 系列。向量维度常见的是 512 到 1024维度越高精度越好但检索速度会下降。第四步是检索与生成。用户的 query 也转成向量在向量库里做余弦相似度检索取前 k 个片段作为上下文。大模型拿到的提示词包括“系统角色声明 检索片段 用户问题 输出约束”最终生成答案。2.3 组件选型对比向量库与生成模型的搭配建议环节常见方案适用场景备注嵌入模型BAAI/bge-small-zh-v1.5本地 CPU 可跑适合毕设向量维度 512速度与精度平衡嵌入模型text-embedding-3-small有 API 额度时使用质量高但依赖联网向量库FAISS单机、小数据量万级安装简单内存模式够用向量库Chroma / Milvus进阶需要持久化、并发访问毕设阶段 FAISS 足够生成模型Ollama Qwen 系列本地部署无需联网7B 参数级别在消费级显卡上可跑生成模型在线大模型 API追求回答质量需要接口密钥注意隐私限制毕设阶段我不建议上重型组件。FAISS 加一个本地 Ollama 模型已经能支撑 1 万份文档以内的问答演示演示过程不依赖外网现场翻车的概率更小。如果你要用在线 API注意医疗问题的隐私边界——不要把真实患者病历传入第三方接口演示数据可以自己构造。3. 用 Python 在本地跑通医疗 RAG 最小系统依赖安装、索引构建与三条验证命令这一章直接给你一套能跑的最小实现。我用的是 LangChain 社区版作为胶水层FAISS 负责向量存储嵌入模型用本地的小尺寸中英双语模型生成侧用 Ollama 拉起一个本地大模型。整个过程不需要 GPU 也能完成索引构建生成阶段如果机器不够也可以临时换成 API。3.1 安装依赖与目录规划先建一个干净的工作目录把文档和数据分开放mkdir medical_rag cd medical_rag mkdir data docs app modelsdata 放原始 PDF 和文档docs 放切分后的中间结果app 放代码。依赖安装用 pip建议在虚拟环境里执行pip install langchain langchain-community langchain-huggingface faiss-cpu sentence-transformers pdfplumber说明一下langchain 只提供流程编排真正干活的是 langchain_community 里的加载器和 FAISS 向量库。pdfplumber 负责解析文字版 PDF如果你的资料是扫描图片这一步会失败需要先做 OCR后面避坑章节会细说。3.2 索引构建脚本把医疗文档变成可检索向量库新建 ingest.py内容分四步加载文档、切分、向量化、存库。from langchain_community.document_loaders import DirectoryLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_huggingface import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS # 1. 加载 data 目录下的所有 PDF loader DirectoryLoader(./data, glob*.pdf) documents loader.load() print(f加载到 {len(documents)} 份文档) # 2. 切分按块长 600、重叠 120 做递归切分 text_splitter RecursiveCharacterTextSplitter( chunk_size600, chunk_overlap120, separators[\n\n, \n, 。, , , ., !, ?, ] ) chunks text_splitter.split_documents(documents) print(f切分成 {len(chunks)} 个片段) # 3. 加载本地嵌入模型 embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, encode_kwargs{normalize_embeddings: True} ) # 4. 构建向量库并保存 vector_store FAISS.from_documents(chunks, embeddings) vector_store.save_local(./faiss_index) print(索引已保存到 ./faiss_index)参数上chunk_size600 对中文医疗文本是一个折中值短了语义不完整长了检索精度下降。overlap120 是为了避免句子被拦腰截断。嵌入模型选了 bge-small 系列它在中文检索上的表现优于同体量的通用模型而且不依赖外部 API。3.3 问答脚本检索加生成的最小闭环新建 query.py加载索引、接收问题、检索片段、调用本地大模型生成答案。from langchain_community.vectorstores import FAISS from langchain_huggingface import HuggingFaceEmbeddings from langchain_community.llms import Ollama from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, encode_kwargs{normalize_embeddings: True} ) vector_store FAISS.load_local(./faiss_index, embeddings, allow_dangerous_deserializationTrue) llm Ollama(modelqwen2.5, temperature0.2) template 你是一名医疗健康问答助手。请只依据下面提供的资料回答问题。 如果资料中没有足够信息请直接回复“资料中未找到相关内容”。 资料 {context} 问题{question} 回答 prompt PromptTemplate(templatetemplate, input_variables[context, question]) qa_chain RetrievalQA.from_chain_type( llmllm, retrievervector_store.as_retriever(search_kwargs{k: 4}), return_source_documentsTrue, chain_type_kwargs{prompt: prompt} ) answer qa_chain.invoke(高血压患者在饮食上有哪些注意事项) print(回答, answer[result]) print(\n引用片段) for doc in answer[source_documents]: print(- * 40) print(doc.page_content[:200])search_kwargs 里的 k4 表示取最相关的 4 个片段。k 值太小容易漏信息太大会把提示词撑爆。实践下来医疗问答 4 到 6 是一个比较稳的区间。温度设为 0.2让生成结果尽量稳定、少发散这里不能学写诗的场景把温度调高。3.4 三条验证命令python ingest.py python query.py 高血压患者在饮食上有哪些注意事项 python query.py 布洛芬的服用禁忌有哪些第一条命令用来确认文档能被加载、切分、入库后两条验证检索和生成链路。如果第二条命令能输出带引用片段的结果说明最小系统已经跑通。接下来要做的事情是把你手里的医疗资料换进 data 目录重新构建索引。资料质量直接决定系统表现宁可少而精不要多而杂。4. 医疗场景的专属配置术语召回、段落切分与安全护栏通用 RAG 在医疗场景直接套用大概率会出现两个问题专业术语匹配不上、答案看起来专业但缺乏安全边界。这一章做的事就是把这些“通用之外”的细节补上也是毕设答辩时最能体现功底的加分项。4.1 医疗术语同义映射让“血压高”能召回“高血压”病历和患者提问里充满口语表达而知识库文档里是规范术语。向量检索对字面相似敏感对语义相似则依赖嵌入模型的能力。小尺寸嵌入模型在“血压高 高血压”这种同义改写上经常失手。常见做法是在检索前加一层查询改写MEDICAL_SYNONYMS { 血压高: 高血压, 血糖高: 高血糖, 心跳快: 心动过速, 拉肚子: 腹泻, 止咳: 镇咳, } def expand_query(query: str) - list[str]: 对用户 query 做术语扩展返回多个候选检索词 candidates [query] for colloquial, standard in MEDICAL_SYNONYMS.items(): if colloquial in query and standard not in query: candidates.append(query.replace(colloquial, standard)) return candidates检索时先用原始 query 查一次再用扩展后的 query 各查一次把结果合并去重。代价是多跑几次向量检索但命中率提升明显。这个思路在面对“乙肝”和“乙型肝炎”、“冠心病”和“冠状动脉粥样硬化性心脏病”这类专业缩写时同样有效。做法上不要试图维护一份包罗万象的词典你只需要针对自己知识库里出现的术语做映射即可。在构建索引时可以顺手统计资料里的高频医学术语覆盖前 100 个就能解决绝大多数口语化提问。4.2 段落切分要“按意思”而不是“按字数”通用切分器按分隔符和固定长度硬切对医疗文档有两个坑。第一个坑是表格内容被切得七零八落第二个坑是“适应症”和“禁忌”可能分别落在两个片段里导致检索到禁忌时缺少上下文。我一般会写一个简单的自定义切分器先按表格结构或段落标题做粗切再对粗切结果做细切def split_medical_document(text: str, max_len: int 500): 按段落和换行先分块再对超长块做句级切分 # 先用连续换行分段落保留标题上下文 raw_blocks [b.strip() for b in text.split(\n\n) if b.strip()] chunks [] for block in raw_blocks: # 段落不长直接作为一个块 if len(block) max_len: chunks.append(block) continue # 长段落按句号进一步切分保持上下文不丢失 sentences block.replace(。, 。\n).split(\n) current for sent in sentences: if len(current) len(sent) max_len: chunks.append(current) current sent else: current sent if current: chunks.append(current) return chunks这样切出来的片段要么是完整段落要么是语义成组的句子组。在医疗资料里“标题 正文”的组合检索效果远好于纯按字数切。如果你手里的资料是 Markdown 或 HTML还可以把标题拼进片段内容让向量召回时能感知章节上下文。4.3 安全护栏提示词约束与输出后置过滤两层保护医疗问答系统不能真的“看病”。规范做法是在提示词层做明确限制然后在输出层做二次检查。提示词层在模板里加入限定条件SAFETY_PROMPT 你是医疗健康知识助手只能提供健康教育信息和资料摘录。 严禁给出诊断结论、严禁推荐具体药物剂量、严禁替代医生意见。 当用户询问“该吃什么药”“要不要手术”等问题时必须提示“请及时就医并遵医嘱”。 输出层加一道简单的关键词和规则检查def safety_check(answer: str) - str: 检查生成结果是否包含违规建议 danger_patterns [一次口服, 每日剂量, 建议手术, 确诊为] for pattern in danger_patterns: if pattern in answer: return 该问题涉及具体诊疗建议请咨询专业医生。 return answer这道过滤不是万能的但在毕设答辩场景里它能证明你意识到了医疗 AI 的安全边界。答辩时如果被问“系统会不会给出危险建议”这一段就是你最好的回应。5. 医疗 RAG 项目避坑实录召回失效、答案幻觉与检索慢的五个常见原因这类项目翻车的地方高度集中。以下五条是我在复现和调试类似系统时真正遇到过的问题每一条都按“现象、原因、解决”的顺序写可以直接对照排查。5.1 加载 PDF 后索引里全是空文档现象是 ingest.py 打印出“加载到 20 份文档”但切分后片段数为 0检索结果永远是“资料中未找到相关内容”。原因有两条。其一PDF 是扫描件pdfplumber 提不出文字page_content 是空字符串。其二PDF 里文字是图片格式必须走 OCR。解决方法是先用 pdfplumber 抽样打印前 100 个字符确认文本是否存在python -c import pdfplumber; pdfpdfplumber.open(./data/test.pdf); print(pdf.pages[0].extract_text()[:200])如果输出是 None说明这是扫描件要走 OCR 流程。常见做法是用 PaddleOCR 或 Tesseract 把 PDF 转成带文字的文本文件再把文本文件喂给加载器。这一条在医疗资料里出现频率极高——很多指南和说明书是扫描后存成 PDF 的。5.2 检索召回率很高但生成答案总是“资料不足”现象是单独测试 FAISS 检索k4 返回的片段明显包含答案内容但大模型回答“资料中未找到相关内容”。原因一般不在检索而在提示词。LangChain 的 RetrievalQA 默认会把检索片段塞进 context 变量但如果你的 prompt 模板字段名与 chain 的参数名不一致或者 chain_type_kwargs 覆盖了模板导致 context 为空模型拿到的就是空资料。解决方法是打印实际发送给模型的提示词。可以在 chain 里加回调或临时用 PromptTemplate 直接拼一个 prompt 再让 llm.invoke 调用确认 context 真实携带了片段内容。我在调试时发现看日志永远比看代码直观建议把完整 prompt 打出来跑一次。5.3 答案引用的片段编号与回答内容对不上现象是用了 return_source_documentsTrue打印出的引用片段很长但答案里提到的关键信息并不在第一个片段里。原因是 top_k 切片按相似度排序而大模型在生成时可能把多个片段的信息混在一起。要提升可信度做法是为每个片段编号并在提示词里要求模型在句中标注来源formatted_context \n\n.join( f[{i1}] {doc.page_content} for i, doc in docs ) prompt template.replace({context}, formatted_context) # 提示词里写明请用[1][2]标注信息出处这样生成的答案形如“高血压患者应低盐饮食[1][2]”既便于答辩演示也能揪出模型“综合多个片段”造成的错误归因。5.4 切分时一句话被“腰斩”检索到片段但不完整现象是某个片段以“患者应避免”结尾下一个片段以“高盐食物”开头检索命中后上下文不连贯。原因是 chunk 边界所在位置正好把一句话拆开overlap 不够覆盖。解决方法是把 chunk_overlap 从 120 提到 200并把句子分隔符“。”放在 separators 数组的最前面让切分器优先在句号处切。中文文本对 fixed-size 切分敏感宁可增加一点存储冗余也要保住语义完整。5.5 本地大模型响应 10 秒起步演示现场很尴尬现象是提问后要等很久现场演示时用户体验很差。原因是 Ollama 在 CPU 上跑 7B 模型本身就慢加上检索也要几百毫秒。常见的应急方案有三个第一切换成更小的量化模型比如 qwen2.5:3b回答速度能快一倍演示阶段质量差距不明显第二增加一个缓存层对相同问题直接返回历史结果第三把提示词里的检索片段数量从 4 降到 3减少上下文长度也能压缩生成时间。这类性能问题不解决答辩演示时很容易被“卡顿”带走节奏。提前把所有演示问题跑一遍缓存好是最简单也最可靠的办法。6. 用 hit rate 和忠实度指标把毕设结论写成能给人看的数据做完系统只是第一步答辩时老师一定会问“效果怎么衡量”。很多人只会截图几个问答对展示这个说服力不够。更稳的做法是准备一组带标准答案的评测问题集跑两个指标hit rate检索命中率和生成忠实度。hit rate 的定义是对每个问题人工标注出知识库中哪个片段包含答案系统检索返回的 top k 里包含该片段就算命中。计算脚本很简单import numpy as np # 模拟 50 个评测问题 q_ids list(range(50)) hit_count 0 for qid in q_ids: # 假设返回 top 4人工标注的标准片段 id 写在 standardized_answer_ids 里 returned_ids retriever.invoke(questions[qid]) # 伪代码 if standard_answer_ids[qid] in [d.id for d in returned_ids]: hit_count 1 print(fHit Rate{len(returned_ids)}: {hit_count / len(q_ids):.2%})这个指标反映的是“检索层有没有把答案找出来”和生成无关。生成忠实度则可以人工打分答案里的关键信息是否都在引用片段里出现出现记 1 分部分出现记 0.5 分凭空出现记 0 分。把两个数字放进论文的实验章比十张截图都有分量。顺着这个方向进阶做法是给每个问答对加上“标准答案片段 id 标注”做一个 30 到 50 条的小规模测试集。你会发现调优过程里 hit rate 比生成效果更敏感改动切分参数、换嵌入模型、加同义词扩展hit rate 都会变化这是最有说服力的调参依据。如果时间充裕把 agentic rag 的思想加进去——让模型先判断当前检索片段是否充足、不足时自主决定再检索一次——可以作为“进阶改进”写进后续工作也是答辩时很好的展望素材。最后提醒一句我自己的习惯所有评测数据、脚本和标注文件都放进项目仓库的 evaluation 目录别只留在本地。毕业设计资料最怕临时补实验数据评测集从第一天就开始攒。希望这份笔记能帮你少走几段弯路。本文还有配套的精品资源点击获取