ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于RAG与大模型的医疗问答系统:从检索到生成全链路实战

2026/10/8 11:10:06 拓冰建站 浏览量
基于RAG与大模型的医疗问答系统:从检索到生成全链路实战 简介此压缩包为基于 RAG 与大模型技术的医疗问答系统毕业设计资源面向计算机、人工智能、软件工程等相关专业学生及 NLP 方向开发者尤其适合医学问答、知识图谱及智能问诊类课程设计与毕业设计。项目以 Python 实现覆盖实体识别、关系抽取、Neo4j 知识图谱构建、ChatGLM 微调、nl2cypher 查询生成与 WebUI 交互等完整链路能够帮助读者理解 RAG 在垂直医疗场景中的落地方法并体会一个高分毕设的系统组织方式。包体内共 75 个文件约 84.65MB以 Python 源码、Jupyter 分析脚本、数据文本、界面截图与模型配置为主另有 README 等文档可快速定位数据预处理、微调训练、前端演示等模块。目前已有 378 人学习使用作者额外提供调试完成的可运行代码及全部资料并附有微调脚本、实体关系数据等便于二次开发或替换领域数据适合从入门到进阶逐步搭建医疗问答原型系统。1. 基于RAG与大模型技术的医疗问答系统为什么值得做医疗问答系统的难点从来不在“生成”而在“敢不敢答”——一个连药师都说不全的副作用模型却满口跑火车这比答不上来更可怕。RAG检索增强生成解决的就是这个问题把可信的医疗资料切块、索引回答时先检索、后生成让大模型只基于检索到的原文给结论。这也是为什么这个方向适合做成毕业设计它不需要巨额算力不需要专职标注团队却能完整覆盖数据处理、索引构建、检索调优、生成输出与接口封装整条工程链路答辩时有数据可讲、有界面可看、有脚本可跑工作量一眼就能看出来。对准备拿它交差的高年级学生这套系统涉及四个硬骨头文档预处理、向量检索、提示词工程、后端接口。对已经在工作的开发者它又是一个可以换数据源、迁移到法律或教育资料问答的基座。下面从设计选型、落地实现到排错路径把整件事拆开讲清楚尽量做到你拿到一台普通电脑就能复现。2. 先立住设计RAG医疗问答系统的架构、选型与数据流2.1 为什么选择RAG而不是微调先回答一个绕不开的问题医疗问答为什么不直接微调大模型微调的思路是让模型把医学知识“背下来”但医疗知识更新太快药品说明书每年修订、诊疗指南不断改版微调一次就要重新准备数据、重新训练成本高且版本难维护。更现实的问题是微调后的模型仍然会一本正经地编造剂量因为生成机制决定的“幻觉”并不会因为训练数据变多而消失。RAG把知识从模型参数里抽出来放到外部知识库回答时先检索原文再生成模型参数不动更新资料只需要重建索引这更符合医疗场景对“答案有出处”的诉求。这个选择也对应了最近经常被讨论的“RAG瓶颈”RAG的上限取决于检索质量而不是生成能力。检索回来的片段如果本身不完整、排序不对再强的模型也编不出正确答案。所以在下面的设计里我不会把RAG框架当黑匣子用而是把检索链路拆开每一步都能单独调、单独验这是提升答辩质量的关键。对比维度RAG方案微调方案知识更新换文档重建索引分钟级完成重训模型小时到天级幻觉控制回答可绑定原文幻觉有限依赖训练质量仍可能编造算力要求一台带CPU/入门显卡的电脑可跑通常需要多卡训练资料缺失处理检索为空时可明确说“不知道”模型倾向强行作答答辩展示价值数据流、检索、评估全链路可演示偏重训练过程结果不好解释2.2 系统模块与核心组件一个完整的RAG医疗问答系统按数据流方向可以拆成六个部件。文档加载器读取PDF、Markdown、TXT等格式的原始资料并记录来源路径。清洗与切分把长文档切成适合检索的片段切分粒度直接影响召回效果。嵌入模型把文本片段变成向量中文场景一般用bge或text2vec系列。向量库存储向量并做近似最近邻检索毕设规模用Faiss或Chroma足够。混合检索与重排序向量召回搭配BM25关键词召回再用cross-encoder精排。大模型生成基于精排后的片段构造提示词调用本地模型或在线API输出答案。这六个部件缺一不可。很多人为了省事只做“向量化相似度检索”两步结果用户问“这个药一天吃几次”库里明明写了“每6小时一次”却因为字面不匹配而召回失败。医疗问答的特殊性在于用户口语表达高度多样同一个概念可能有多个叫法所以知识库设计不能只靠一种检索方式这也是后面要把混合检索单独拿出来讲的原因。2.3 用 FastAPI 串起主链路的最小骨架先把后端主链路搭起来。我一般会用FastAPI写一个简单的问答接口内部调用封装好的RAG流水线。这样做的好处是答辩时可以直接用浏览器页面演示也可以留出后续接入微信小程序或Web前端的空间。# app.py from fastapi import FastAPI from pydantic import BaseModel # 封装的RAG主流程先检索再生成 from rag_core.pipeline import answer app FastAPI(title医疗问答系统) class Question(BaseModel): question: str # 用户输入的问题 top_k: int 5 # 最终送入提示词的片段数 history: list | None [] # 多轮会话上下文预留扩展位 app.post(/api/ask) def ask(payload: Question): # 医疗场景要求稳定temperature 调低避免自由发挥 result answer( payload.question, top_kpayload.top_k, historypayload.history ) return { answer: result[answer], citations: result[citations], # 回答对应的文献卡片 time_ms: result[time_ms] }这段代码的逻辑很直观接收问题、调用流水线、返回答案和引用来源。两个参数值得说明。top_k5表示最终只把5个片段交给模型太少容易漏信息太多容易让模型被噪声带偏history暂时留空但接口先设计好后面支持多轮追问就不需要改数据结构。temperature的取值在生成阶段控制医疗问答我一般固定到0.1超过0.3回答就开始飘。先把这层骨架搭好接下来把重点放到最容易被忽视的数据侧知识库是怎么建起来的。3. 把知识库灌进去文档加载、切分、向量化全流程3.1 文档加载先解决资料从哪来知识库的原料通常是PDF药品说明书、临床指南、医学教材章节也可能是自己整理的Markdown问答对。PDF是最难处理的格式因为版面复杂有些还是扫描件。我用得最多的是pdfplumber它对文字版PDF的抽取效果稳定遇到表格也能保留基本结构。# loader.py import pdfplumber def load_pdf(path): 抽取PDF文本并按页记录来源 docs [] with pdfplumber.open(path) as pdf: for page_no, page in enumerate(pdf.pages, start1): text page.extract_text() or if len(text.strip()) 20: # 跳过只有抬头或页码的空白页 continue docs.append({ text: text, source: f{path}#page{page_no} }) return docs这里把source字段一并存下来很重要后续回答溯源全靠它。抽取完的文本先不要急着切分先扫一遍有没有乱码、重复页、页眉页脚混入这些问题在中文扫描版PDF里非常常见。毕设阶段建议先用干净的文字版资料跑通流程再逐步增加复杂格式。3.2 中文医疗文本的切分策略chunk_size 不是玄学切分是RAG系统里最容易被低估的环节。很多教程默认用RecursiveCharacterTextSplitter按字符数硬切这在英文里勉强能用放到中文医疗文本上就出问题一句“成人一次0.2g每6小时一次24小时内不超过4次”可能在“每”字后面被切断检索到的片段语义不完整。中文天然以句读为边界所以我一般把分隔符优先级调整为句号、分号、逗号让切分尽量落在语义完整的句子上。# splitter.py from langchain.text_splitter import RecursiveCharacterTextSplitter separators [\n\n, \n, 。, , ] # 片段目标长度512重叠64给句子边界留容错 splitter RecursiveCharacterTextSplitter( separatorsseparators, chunk_size512, chunk_overlap64, length_functionlen, )chunk_size512不是随便定的。中文一个字约占一个字符512字大约能容纳一个“疾病定义典型症状用药注意”的完整条目检索时既不会因为太短而缺失关键信息也不会因为太长而混入多个主题。chunk_overlap64让相邻片段在句尾重复一小段避免关键句恰好落在两个片段交界处被漏掉。不同参数的效果差异可以用下面这张表做参考答辩时把它画成对比图会很有说服力。chunk_size片段粒度典型表现适用场景256偏短召回精准但上下文不完整结构化词条、短问答对512适中语句完整信息密度平衡说明书、诊疗指南1024偏长命中率高但噪声增多教科书章节、综述文献提示chunk_size不是普适常量脱离语料分布谈参数没有意义。拿到资料先抽取5个典型片段人工看一遍再决定用哪个档位。3.3 嵌入模型与向量库先选型再写代码中文领域我常用的嵌入模型有两类bge-small-zh-v1.5和text2vec-base-chinese。bge系列检索效果更稳支持768维向量在普通CPU上编码一万个片段也不慢text2vec对医学专有名词的适配也不错但维度更高检索速度略慢。选模型还有一个约束查询和文档必须用同一个嵌入模型编码否则向量空间不一致相似度分数失去意义。我一般在项目开始时就把模型名写进配置文件避免后面切换导致全部索引作废。向量库的选择更简单。毕设规模下没必要上分布式向量数据库Faiss足够轻量适合离线建索引、进程内检索Chroma则自带持久化和元数据过滤适合需要边写边查的迭代调试。我个人的习惯是数据量在几十万条以内、希望逻辑清晰就用Faiss加JSON存片段想要快速加删改就选Chroma。下面是Faiss建索引的完整脚本。3.4 建索引全流程脚本# build_index.py import json import faiss import numpy as np from sentence_transformers import SentenceTransformer from loader import load_pdf from splitter import splitter # 1. 加载并切分文档这里路径按自己资料情况替换 docs load_pdf(./data/药品说明书.pdf) chunks [] for doc in docs: parts splitter.split_text(doc[text]) for p in parts: chunks.append({text: p, source: doc[source]}) # 2. 批量编码 embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) texts [c[text] for c in chunks] vectors embedder.encode( texts, normalize_embeddingsTrue, # 归一化后内积近似余弦相似度 batch_size64, show_progress_barTrue, ).astype(float32) # 3. 写入Faiss索引并保存片段原文 index faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors) faiss.write_index(index, medical.index) with open(chunks.json, w, encodingutf-8) as f: json.dump(chunks, f, ensure_asciiFalse, indent2) print(findexed {len(chunks)} chunks, dim{vectors.shape[1]})这段代码做了三件事切分片段、编码向量、落地索引。normalize_embeddingsTrue是bge模型的标准用法向量归一化之后用内积就能等价计算余弦相似度batch_size64是CPU友好的批大小显存小的机器可以降到32。chunks.json和medical.index两个文件就是后续检索的全部依赖答辩时可以单独演示“换一批资料重建索引只花几十秒”这比微调模型的更新演示直观得多。这里有一个经常被忽略的问题嵌入模型输出的维度必须和索引维度一致。如果建索引时用了bge-small-zh-v1.5768维检索时却加载了text2vec-base-chinese768维但向量空间不同虽然不会报错但检索结果会变得完全不可用。所以我会在索引文件旁边存一个meta.json记录模型名和维度检索启动时先做校验不一致就报错提示重建索引。4. 检索与生成让答案有依据RAG主流程实现4.1 混合检索向量召回加 BM25 补中文短板纯向量检索的问题在中文医疗场景中特别突出用户说“这药饭前吃还是饭后吃”文档里写的是“空腹或餐后服用均可”语义相近但字面完全不同向量模型有时能兜住有时兜不住反过来用户问“布洛芬”文档里叫“异丁苯丙酸”向量检索也容易失配。所以我一般会做成混合检索向量召回一批候选BM25关键词召回一批候选两边合并再去重让语义匹配和字面匹配互补。# retriever.py import jieba import numpy as np import faiss from rank_bm25 import BM25Okapi # 加载全局资源避免每次请求都重复读索引 index faiss.read_index(medical.index) chunks json.load(open(chunks.json, encodingutf-8)) embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) corpus_tokens [list(jieba.cut(c[text])) for c in chunks] def hybrid_search(query: str, k: int 20): # 向量召回 q_vec embedder.encode([query], normalize_embeddingsTrue) vec_scores, vec_ids index.search(q_vec, k) # BM25召回 bm25 BM25Okapi(corpus_tokens) bm25_scores bm25.get_scores(list(jieba.cut(query))) bm25_ids np.argsort(bm25_scores)[::-1][:k] # 合并候选集这里先用并集避免某一侧漏掉 merged list(dict.fromkeys(list(vec_ids[0]) list(bm25_ids))) return merged, vec_scores[0]参数k20的意思是先各取20个候选合并后面还会重排。不要一开始就把k设成5因为向量和BM25的命中条件不同过早截断会把正确片段挡在门外。BM25部分注意用jieba分词中文不分词的话BM25基本退化成单字匹配效果很差。合并时用字典去重保留顺序保证高频片段优先进入下一阶段。4.2 重排序用 cross-encoder 把 20 条压回 5 条混合检索结束之后候选集里通常有十几到二十几条片段直接全塞给大模型不仅浪费token还会让回答变得冗长。我一般加一个重排序层用cross-encoder对问题和片段逐对打分把最相关的5条留下来。cross-encoder和前面嵌入模型最大的区别是它把问题和片段拼接在一起过模型能捕捉两者之间的细粒度交互效果比纯向量相似度好不少。# reranker.py from sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-base) def rerank(query: str, candidate_ids: list, top_k: int 5): pairs [(query, chunks[i][text]) for i in candidate_ids] scores reranker.predict(pairs) order np.argsort(scores)[::-1][:top_k] return [candidate_ids[i] for i in order]重排序是实时计算20条候选通常也就是几百毫秒到一两秒对答辩演示完全可以接受。如果后续数据量变大可以先把重排结果按来源文档聚合同一篇文档只保留分数最高的一条避免答案通篇引同一页说明书。4.3 提示词模板让模型“有据可依、无据可说”检索做完了最后一步是生成。医疗场景的提示词要把“禁止编造”写到最前面同时把检索片段按编号列出要求模型回答时带上编号引用。这样前端就能显示“参考来源”答辩时一眼看出每个结论都有出处。# generate.py from openai import OpenAI client OpenAI(base_urlhttp://localhost:11434/v1, api_keyollama) def answer(question: str, top_k: int 5, history: list None): candidate_ids, _ hybrid_search(question) top_ids rerank(question, candidate_ids, top_k) references \n.join( f[{i1}]《{chunks[idx][source]}》{chunks[idx][text]} for i, idx in enumerate(top_ids) ) prompt f你是一名严谨的医疗信息助手。请严格依据下面的参考资料回答用户问题。 参考资料 {references} 用户问题{question} 要求 1. 只允许使用参考资料中的信息禁止编造剂量、禁忌或疗效。 2. 回答中标注引用编号例如“根据资料[1]”。 3. 如果资料中没有对应信息请直接回复“资料中未找到相关信息建议尽快就医咨询”。 4. 回答控制在200字以内分点输出。 回答 resp client.chat.completions.create( modelqwen2.5:7b, messages[{role: user, content: prompt}], temperature0.1, max_tokens512, ) return { answer: resp.choices[0].message.content, citations: [chunks[i][source] for i in top_ids], }这里的base_url指向本地Ollama服务模型用qwen2.5:7b这种中文能力不错的可本地部署参数规模。temperature0.1是医疗问答的安全线改到0.5以上模型就会开始发挥出现“可能有效”“建议尝试”这类没有依据的话。max_tokens512限制回答长度避免长回答把引用编号淹没。提示词里那句“资料中未找到相关信息”非常重要它是整个系统的安全兜底也是评委最爱追问的点。5. 高分毕设最容易踩的坑检索质量、部署与文档三大块5.1 检索质量三个高频翻车点现象一问“糖尿病并发症有哪些”回答只提到了视网膜病变漏了肾病和神经病变。原因是切分时chunk_size设到1024并在换行处硬切把一篇完整指南切成了几段每段各讲一个并发症检索排序却只把第一段排进来。解决办法是把chunk_size降到512并且在“。”边界切分同时在重排序阶段按来源文档聚合保证同一文档的多段相关内容都能进入候选。手动检查五个高频问题的召回结果比看整体指标更早发现问题。现象二用户问“布洛芬”知识库里的答案在“异丁苯丙酸”条目下无论怎么做向量检索都返回不到。这就是同义词问题。解决思路是维护一个简单的别名映射表在检索前把查询词扩展成“布洛芬 异丁苯丙酸 ibuprofen”再交给检索器。这个表不需要很大把药品通用名、商品名、曾用名各列几行就能覆盖绝大多数测试问题。现象三检索分数很高但回答明显不对。排查后发现查询用的嵌入模型和建索引时的模型不一致向量空间整体漂移相似度分数虚高。解决方式就是前面提到的meta.json校验机制启动时检查模型名和维度不一致就直接退出并提示重建索引。5.2 部署稳定别让接口和缓存拖垮演示现象四答辩现场网络波动在线大模型API超时页面转圈半天最后报错。这是把在线API当主链路最容易翻车的地方。我一般会默认走本地模型把在线API作为备用同时准备高频问答缓存命中缓存就直接返回不经过模型推理。缓存的实现不复杂把question哈希后查Redis或内存字典没有缓存再走完整RAG流程拿到结果后写回缓存。演示时同一个问题被问两次第二次秒回本身就是性能亮点。现象五检索完全查不到但模型还是强行作答。原因是提示词里没有加“拒绝基线”。解决起来分两步第一步在提示词里明确“资料没有就直说不知道”第二步在后端对重排后的最高分设一个阈值比如相似度低于0.35就返回固定提示语“资料库中暂未找到相关内容请咨询专业医生”不让模型有机会看到低质量片段。阈值的确定方法后面会讲到。5.3 交付文档源码之外“全部资料”要能自解释现象六答辩老师拿到源码装了半小时依赖还是没跑起来。问题通常不在代码而在文档。很多同学只交一个源码包README写三行字数据库文件、模型名称、Python版本全部靠猜。所谓“全部资料”不是塞一堆文件而是让别人按顺序能复现。我一般会在项目里固定放这几样东西用一张表就能说清楚文件/目录作用备注README.md运行环境、安装步骤、启动命令从零到能跑的关键requirements.txtPython依赖清单固定版本号避免冲突data/原始资料与清洗后文本标注授权来源config.yaml模型名、路径、检索参数答辩时可现场改参数重跑eval/测试集与评估结果体现工作量的一手资料docs/答辩演示脚本.md演示问题清单与预期回答防止现场紧张忘词README.md里最该写的不是项目简介而是“能跑的最小步骤”。从创建虚拟环境、安装依赖、执行建索引脚本、启动后端服务到打开页面问第一个问题每一步对应一条指令。做到这一步即使答辩老师临时换一台电脑也能复现分数自然低不了。6. 让检索再聪明一点结构化知识切片与可追溯回答6.1 给答案装上出处前面已经把citations字段传了出来前端可以把引用编号渲染成可点击的链接点击后展开对应的原文片段。这在答辩中是一个很有记忆点的功能每次回答都有参考文献评委问“这句话的依据是什么”点一下编号就能看到。实现上不需要改生成逻辑只需要在提示词里要求“回答中标注[编号]”再把chunks里的source字段映射成引用列表返回即可。这是我建议所有做RAG项目的同学保留的最小增强。6.2 用结构化知识库提升精准命中当资料里有大量“药品商品名”“成人剂量”“禁忌人群”这类字段时纯长文本切分的检索效率并不高。我一般会把这类高频信息拆成键值卡片再和长文本一起放进知识库长文本解决“是什么、为什么”结构化卡片解决“剂量多少、什么时候吃”。这样构造的kg知识库和普通文本知识库各自负责一块应用边界很清楚。实际编码时只需要把卡片转成一小段规范文本和文档片段一起建索引检索时模型自然能同时看到两种形态的资料。我第一版系统把chunk_size调到2048想着减少检索次数结果大量禁忌描述被硬切成了两半回答质量反而变差。后来老老实实按临床语义粒度切分并在每次改参数前先跑一遍固定的20条验收问题把回答逐条对比才让检索分稳定下来。这也是我后来做任何RAG项目都保留的习惯调参之前先定义好“什么叫做好”。希望帮到你。本文还有配套的精品资源点击获取