
简介这是一份面向计算机、人工智能、自动化等专业学生与从业者的医疗问答系统毕业设计项目基于RAG与大模型技术实现覆盖知识图谱构建、NER实体识别、LoRA微调、nl2cypher查询生成及WebUI交互等完整流程。资源共75个文件压缩包约84.65MB包含10个Python源码脚本、7个Jupyter Notebook、7个JSON配置、3个YAML环境配置、多个Markdown文档及大量txt数据与png界面截图分别对应数据增强、模型训练、图谱构建和前端展示模块目录清晰便于按阶段学习。项目答辩评审达98分代码经调试可运行并附有文档说明适合用于课程设计、毕业大作业或作为RAG应用落地的参考范例。目前已有1114人学习下载对于希望快速理解从数据处理到模型部署全链路的学习者也具有较高借鉴价值。1. 医疗问答系统拿到后先别急着跑这套 RAG 源码的定位与适用人群每年都有人从各种渠道下载到标着「Python基于 RAG 与大模型技术的医疗问答系统源码文档说明高分毕设」的压缩包第一反应通常是解压、装依赖、把 demo 跑出来看界面。真动手的人会发现这套系统最难的环节不在大模型调用而在知识库那一头医疗文本怎么切、向量库里怎么存、检索回来的段落为什么不对。这个标题看起来就是把大模型和 RAG 组合成一个医疗问答服务本质上解决的是一件事让用户基于自有医学资料用自然语言提问并让答案有出处、可验证——这正是医疗问答与通用聊天最大的分界。它适合两类人一类是毕业设计选型需要一套结构完整、答辩时讲得清的系统另一类是想在医疗领域快速搭一个问答 MVP 的开发者。接下来按「架构 → 知识库 → 检索生成 → 避坑 → 验证」这条线把它讲透。2. 医疗场景为什么必须用 RAG架构拆解与最小启动工程2.1 纯微调大模型为什么在医疗场景走不通三个硬约束想做一个医疗问答系统第一个摆在面前的问题往往不是代码而是路线选择。很多人一上来就打算微调一个开源大模型让它「学会」医学知识。这条路在通用领域可行但在医疗场景有三个硬约束让它很难落地。第一是部署成本与数据边界。医院、体检机构这类客户的常态是内网环境这两年企业大模型私有化部署的需求越来越明确模型要跑在自己的机房。想微调像样的生成模型7B 以上才有点效果一张专业显卡只够训练退到量化推理又明显掉效果真拿几千条医疗 QA 做 LoRA 微调数据标注成本和时间都很可观。RAG 的好处是不动模型参数只用一套检索加拼接逻辑把知识「喂」给模型一张普通显卡甚至纯 CPU 都能转起来。第二是知识更新。医学知识的更新速度远超通用领域常用药品说明书、临床指南、科室宣教材料都在不断改版。微调路线每次更新都要重新攒数据、重新训练RAG 只需要替换或增加知识库文件把新文档重新切分入库就能再上线。第三是可解释性。医疗问答的答案需要「证据」。RAG 天然会把命中的原文段落带回来追问时能翻到是哪份文档里的内容微调模型的回答则是黑匣子这在给评审讲解或给用户交代时非常被动。所以这套源码选 RAG不是因为它新潮而是它在成本、更新、可解释这三件事上同时达标。这两年大家反复聊 rag 瓶颈绝大多数瓶颈出在工程细节而不是路线本身后面章节会逐个展开。2.2 源码包里的核心模块从项目目录看懂一条问答链路标题里既然带了「源码文档说明」解压之后第一步不是跑代码而是先看目录结构。以这类源码的典型组织方式为例项目通常长这样medical_qa_system/ ├── config/ │ └── config.yaml # 全局配置模型、向量库、检索参数 ├── data/ │ ├── raw_docs/ # 原始医疗资料txt / md / pdf │ └── vector_store/ # 本地向量库存放目录 ├── scripts/ │ ├── build_kb.py # 知识库构建切分 向量化 入库 │ └── init_db.py # 初始化向量数据库 ├── server/ │ ├── api.py # 问答服务接口 │ ├── retriever.py # 检索模块 │ ├── reranker.py # 重排可选 │ └── llm_client.py # 大模型调用封装 ├── docs/ │ └── 设计文档.md # 需求分析、架构、接口说明 └── requirements.txt这是一条能自圆其说的工程链路用户提交问题后检索模块从向量库里召回相关片段再把这些片段交给大模型模型只照着片段组织答案返回。如果你打算把它写进毕业论文架构图按「用户层 → 问答服务层 → 检索增强层 → 知识库层 → 模型层」画就没问题文档里的章节通常也能直接改着用。模块职责对应关系表如下理解它就知道改哪一部分会牵连什么模块职责改动影响面data/raw_docs知识来源答案覆盖范围scripts/build_kb.py切分与向量化检索精度server/retriever.py召回策略答案相关性server/llm_client.py模型选用成本与生成质量config/config.yaml全局参数以上全部看目录时最常踩的坑是把 data/raw_docs 当摆设跳过建库直接跑问答结果系统只会答内置示例里的问题。知识库不重建提示词改一百遍也没用。2.3 把系统跑起来配置文件与最小启动命令跑起来之前先看配置。这类源码一般会有一份 YAML 配置文件关键参数大致长这样embedding: model: bge-small-zh-v1.5 # 中文向量模型 device: cpu # cpu / cuda dimension: 512 # 向量维度由模型决定 vector_store: type: chroma # chroma / milvus / elasticsearch persist_dir: ./data/vector_store retrieval: top_k: 6 # 召回段落数 score_threshold: 0.45 # 相似度阈值低于此值不启用 use_bm25: true # 是否启用关键词召回混合 llm: provider: openai_compatible # 兼容 openai 协议的本地或在线模型 base_url: # 本地模型服务地址 api_key: sk-demo # 本地部署可随便填 model_name: chatglm3-6b temperature: 0.2 # 医疗问答建议低随机性 max_tokens: 1024 context_window: 8192 # 上下文上限启动顺序常规三步走# 1. 创建虚拟环境并安装依赖 python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt # 2. 初始化知识库没有这一步问答就是一个空壳 python scripts/build_kb.py # 3. 启动问答服务 python server/api.py这里几个关键点必须说清。embedding 的 dimension 必须和模型匹配bge-small-zh-v1.5 是 512 维换成 bge-m3 后维度变成 1024不改配置硬启动会直接报错或查出来是乱码数据。score_threshold 很多人不知道有什么用它决定「模型宁可说不知道也不要拿低分段落硬答」这对医疗场景比通用场景重要得多。llm 配置里 provider 写的「兼容 OpenAI 协议」是这类源码最常见的实现方式。本地能用 Ollama 拉起一个模型监听端口也可以直接填一家提供免费大模型 API 的在线服务跑通流程。我一般建议先用免费 API 把链路验证通再考虑换私有化模型这样排查问题的时候少一层环境干扰。3. 知识库决定问答质量医疗文本切分与向量化入库3.1 RAG 的瓶颈在知识库为什么切分参数直接决定答案质量聊 rag 瓶颈的人很多聊到最后会发现大多数问题的根不在大模型而在知识库侧。知识库侧的第一道关是切分策略。医疗文本和一般新闻文本差别很大一句话里经常同时塞着疾病名、用药剂量、禁忌人群三个信息段与段之间按病程顺序严格排列。切分块太小比如按 100 字切一个完整的诊断描述会被拦腰截断向量很难表达「这个块到底在讲什么」切分块太大比如整段塞进 1000 字块内夹杂五六个实体检索时噪声多召回的段落往往只在开头或结尾命中关键词。常见做法是层叠策略先按章节和空行粗分再对超长段落用滑动窗口重切。窗口大小一般取 200 到 400 字overlap 取 20 到 50 字。overlap 的作用是给相邻块保留重复信息避免关键句恰好落在边界上被切开。这里还要分清常被混为一谈的问题kg 知识库和 rag 知识库是两种东西。RAG 向量库适合回答「某病有什么症状」「某药的用法用量」这类文本检索问题速度快、落地简单知识图谱类知识库适合回答「药 A 和药 B 是否存在相互作用」这种关系推理问题但要抽实体、建边、维护 schema成本高出一个量级。源码如果不带图谱部分遇到关系型问题就得靠重排在召回结果里兜底或者把问题答案在文档里写清楚让向量检索能直接命中。顺带回答一个高频疑问rag 知识库能存储图片吗严格说可以但要换多模态 embedding 模型把图片转成向量再入库工程复杂度直接上一个台阶。毕设里如果只是想展示 CT 影像或化验单截图正常做法是让接口把图片路径作为文本字段随答案返回而不是把图片本身送进向量库。3.2 中文医疗文本切分与向量化入库脚本下面这份脚本是知识库构建的最小实现对应源码里的 build_kb.py可以直接当模板改from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma import yaml, glob with open(config/config.yaml, r, encodingutf-8) as f: cfg yaml.safe_load(f) # 1. 读取原始文档目录下的所有 md/txt 文件 docs [] for path in glob.glob(data/raw_docs/*.md) glob.glob(data/raw_docs/*.txt): with open(path, r, encodingutf-8) as f: content f.read() docs.append({page_content: content, metadata: {source: path}}) # 2. 层叠切分章节 - 块 - 重叠窗口 text_splitter RecursiveCharacterTextSplitter( chunk_size300, # 每块目标字符数 chunk_overlap40, # 相邻块重叠字符数 separators[\n\n, \n, 。, , ], # 中文语序的切分优先级 ) split_docs text_splitter.split_documents(docs) print(f切分完成{len(split_docs)} 个块) # 3. 初始化中文向量模型 embeddings HuggingFaceEmbeddings( model_namecfg[embedding][model], model_kwargs{device: cfg[embedding][device]}, ) # 4. 建库并持久化 vector_store Chroma.from_documents( documentssplit_docs, embeddingembeddings, persist_directorycfg[vector_store][persist_dir], ) print(f入库完成{vector_store._collection.count()} 条向量)这段脚本的逻辑是先按行读原始文件再交给递归切分器。切分器最重要的参数是 separators中文医疗文本千万别只用默认的英文分隔符那会把很多中文句子硬生生切碎。把空行、换行、句号、分号、逗号按优先级排进去后目录和正文的边界能保留句子内部尽量不动。chunk_size 和 chunk_overlap 在这里是按字符算的不是 token选型时要注意。300 字对中文医疗文本是一个折中能容纳一段完整描述又不会把多个实体混在一起。如果你的知识文档是小标题极多的手册类可以降成 200如果来源是整章连续的教材可以升到 400。这个参数值得花半小时做对比实验它是后续所有检索效果的地基。最后一步入库Chroma 在这类源码中出现频率最高原因就一个零配置、文件即库不用额外装数据库服务对毕设和 MVP 足够友好。3.3 Embedding 选型、向量维度与存储什么时候该换 Milvus切分之后知识库的第二个关键变量是 embedding 模型。同一段文本不同向量模型算出的语义空间差别很大。这类源码默认的通常是国产开源中文向量模型优点是体积小、CPU 能跑几百条文档几分钟就能转完。常见选择大致分三档模型向量维度显存/硬件需求适合场景bge-small-zh-v1.5512无 / CPU 可跑毕设、千级文档、追求速度bge-large-zh-v1.510244G 以上显存万级文档、追求精度bge-m310248G 以上显存多语言、长文本混合模型换掉之后config 里的 dimension 必须同步改这是最容易被忽略的一步。另外千万别把「检索用的小模型」和「生成用的大模型」搞混它们是两套东西检索模型负责算相似度生成模型负责组织语言前者要小要快后者要大要稳。存储选型看规模。如果知识库只有几百到几千条文本块Chroma 单机文件库足够启动快、调试简单这是源码默认配套它的原因。当文档量级到几万条以上或者预见到生产环境要并发查询再考虑 Milvus 或 Elasticsearch。Milvus 主打向量检索ES 的优势是向量和关键词混合检索都支持后者对医疗场景很有用因为药名、检查缩写天生适合精确匹配。没到那个量级就先用 Chroma别为架构的「未来」提前买单。4. 把检索结果变成可信答案召回、重排与生成链路4.1 混合检索BM25 与向量召回为什么在医疗场景缺一不可知识库建好之后问答系统的第二个重头戏是召回。纯向量召回有一个通病它把文本映射到语义空间靠「意思相近」找段落但医疗场景大量术语是硬匹配。比如用户问「厄贝沙坦能不能掰开吃」向量召回可能把「高血压药物服用注意事项」整段拉出来却没定位到关于厄贝沙坦的那一句。这不是 embedding 模型不够好而是语义检索天然擅长宽泛问题、不擅长精确实体定位。解决办法是混合检索一路用向量做语义召回一路用 BM25 做关键词精确召回两路合并、去重、再进提示词。BM25 的依据是词频和文档长度药名这类精确词在文档里出现的频次很能说明问题。一个可复用的召回函数长这样import jieba import numpy as np from rank_bm25 import BM25Okapi def hybrid_search(query, vector_store, bm25_index, docs, top_k6): # 1. 向量召回语义匹配 vec_hits vector_store.similarity_search_with_score(query, ktop_k) # 2. BM25 召回词法匹配中文先分词 tokens list(jieba.cut(query)) bm25_scores bm25_index.get_scores(tokens) bm25_top_idx np.argsort(bm25_scores)[::-1][:top_k] # 3. 两路结果按内容去重合并 merged [] seen_doc_ids set() for doc, score in vec_hits: merged.append({text: doc.page_content, score: float(score), source: doc.metadata.get(source, )}) seen_doc_ids.add(doc.page_content) for idx in bm25_top_idx: if docs[idx].page_content not in seen_doc_ids and len(merged) top_k * 2: merged.append({text: docs[idx].page_content, score: float(bm25_scores[idx]), source: docs[idx].metadata.get(source, )}) return merged这段代码的思路是双路取、再合并。向量路返回语义相似的块BM25 路返回关键词命中的块两路交集去重后一起交给下游。合并时要注意两路分数的量纲不同向量相似度一般落在 0 到 1 之间BM25 分数和词频成正比没有可比性。所以函数里没有对两路分数做统一加权而是在两路各自内部排序后按来源去重更精细的做法是把两边分数各自 min-max 归一化再按 0.6 和 0.4 加权文档量大时收益明显。重排也应该在这步考虑。源码里如果有 reranker 模块通常用的是交叉编码器模型把召回的 10 到 20 个候选段落逐个和问题拼起来算相关性再取前 3 到 5 个。交叉编码器精度高但速度慢所以只对候选集做不能对全库做。毕设阶段这算加分项能跑通就加。4.2 Prompt 模板与上下文窗口控制让模型只答知识库内的事召回完成后的关键一步是把候选段落正确地塞进提示词。医疗问答里最经典的翻车现场是模型根本没看知识库直接凭训练记忆作答或者把知识库段落和它自己的知识混在一起编答案。要压住这个问题提示词的约束必须写在明面上。下面是一个实践过的医疗问答提示词模板prompt_template 你是一名严谨的医学知识助手。 请只依据下面的资料片段回答问题禁止使用资料片段以外的事实。 如果资料中没有相关信息请直接回答“知识库中未找到相关信息”不要自行编造。 【资料片段】 {context} 【用户问题】 {question} 回答要求 1. 答案中每条关键结论后标注来源编号如 [1][2] 2. 不确定的内容明确说明 3. 不使用“根据资料”“资料显示”这类套话 def build_prompt(question, top_chunks): context_text for i, chunk in enumerate(top_chunks): context_text f[{i1}] {chunk[text]}\n return prompt_template.format(contextcontext_text, questionquestion)这个模板里真正起作用的是三句话「只依据资料片段回答」「资料中没有就直接说没有」「每条结论标来源编号」。前两句压制模型凭记忆编答案的倾向第三句让答案在演示时显得可信。很多源码自带的提示词只有一句角色设定模型自然会放飞自我。模型生成时还要注意大模型上下文长度的预算。比如模型支持 8192 token这 8192 是输入加输出的总和不是只给你塞知识用的。知识块结构良好时每个块 300 字大约占 200 到 300 tokentop_k 取 6 也就是 1200 到 1800 token加上系统提示词和问题输入占用不超过 2500 token把 max_tokens 设到 1024 就合理。如果你的 top_k 调到 10块又切成 500 字输入可能逼近 5000 token再赶上并发请求超时是必然的。上下文长度不是越大越好是要在召回质量和生成空间之间找平衡。temperature 也是医疗场景必调参数。通用对话喜欢 0.7 甚至 1.0 的随机性医疗问答直接压到 0.2病患和医生都不需要模型发挥创造力宁可每次答案句式保守也要保证结论稳定。4.3 生成接口流式输出与查询改写生成接口这一步源码里一般是用 Web 服务包一层。实现示意如下from fastapi import FastAPI from fastapi.responses import StreamingResponse import json, requests app FastAPI() def stream_qa(question, context_chunks): prompt build_prompt(question, context_chunks) payload { model: chatglm3-6b, messages: [{role: user, content: prompt}], temperature: 0.2, max_tokens: 1024, stream: True, } resp requests.post(http://localhost:8000/v1/chat/completions, jsonpayload, streamTrue) for line in resp.iter_lines(decode_unicodeTrue): if line.startswith(data: ): data line[6:].strip() if data and data ! [DONE]: delta json.loads(data)[choices][0][delta].get(content, ) yield delta app.post(/qa) def qa_endpoint(question: str): chunks hybrid_search(question, vector_store, bm25_index, docs) return StreamingResponse(stream_qa(question, chunks), media_typetext/plain)流式输出在医疗问答里的价值被低估了大模型生成一段答案需要几秒如果等全部生成完再返回用户会以为系统卡死。流式让答案一句一句往外蹦体感响应时间从 5 秒降到 1 秒以内。这个体验差异在答辩演示现场尤其明显。查询改写是多轮对话的优化。用户追问「这个药呢」「那还能不能吃」时单轮问答系统无法把指代和前文关联起来。常见做法是把最近一轮对话拼接后调用一次小模型把「这个药」补全成「厄贝沙坦」再去做检索。加上这层整个链路才能支撑连续问答。这里还建议加一个返回字段答案生成完成后从召回的 chunks 里把来源文件路径一并返回前端就能展示参考了哪份资料。这一步投入很低却是医疗问答系统专业感的直接来源。5. 医疗问答系统跑通后最容易翻车的 5 个坑现象、原因与处理5.1 向量检索总召回无关段落还带着低分硬答现象用户随便问一个疾病系统返回的内容和问题沾边但细节全错。原因切分块设置不合理加上 score_threshold 没调。块太大时上下文混杂块太小又切断语义相似度分数在 0.4 以下的结果本来就不该启用。解决切分参数退回 200 到 300 字加 40 字重叠重新入库同时在检索函数里把低于阈值的段落过滤掉让系统优先回答「知识库中未找到相关信息」。5.2 用户问知识库之外的问题模型照样一本正经回答现象问「感冒了吃什么药」知识库里根本没这个内容模型还是长篇大论编出答案。原因提示词里没有禁止模型使用内部知识底模能力太强自己有知识就控制不住输出。解决把「只能依据资料片段回答、资料中没有就直接说没有」明确写进系统提示词并把 temperature 压到 0.2 以下。这一步比换更大的模型更直接是挤压幻觉最有效的单一改动。5.3 知识块塞多了请求直接超时现象增加知识库文档之后接口开始报超时或提示超出上下文窗口。原因top_k 设成 10、块大小 500 字输入 token 已经逼近甚至超过模型上限。很多人误以为知识块越多越好把整份知识库都塞进提示词翻车是必然。解决设置输入预算。top_k 调回 6块大小控制在 300 字左右必要时对检索结果做个摘要再进提示词。5.4 离了 GPU 跑不动启动和查询都慢得离谱现象在纯 CPU 服务器上跑查询一次要几十秒怀疑代码有 bug。原因embedding 用了中大型模型或者干脆在 CPU 上加载了生成模型也可能机器本身是 ARM 架构依赖里有些包没编译好。解决检索侧换轻量 embedding 模型生成侧优先接本地轻量模型服务或在线 API。先用工具确认瓶颈在推理设备再决定要不要怀疑代码。5.5 中文文本被按空格切得稀碎检索结果很差现象知识库建完后怎么检索都查不到想要的段落打开存储一看块内容全是半句话。原因切分器用了默认英文分隔符没有把逗号、句号写进分隔列表中文句子无法按语义完整性切分。解决在递归切分器里把分隔符改成「空行、换行、句号、分号、逗号」这个优先级顺序词法匹配时用中文分词器先分词。这是中文语料做 RAG 最典型的坑英文开源项目的代码直接拿来用必然中招。6. 让评测老师追着问的验证方法评估集、引用溯源与落地上限毕设答辩时评委最常问的一句话是「你怎么证明你的系统有效」。很多人的准备是「我测试了几个问题效果不错」——这几乎等于没有验证。正规做法是建一个小的评估集把「效果不错」变成一组可复现的数据。评估集不用多30 到 50 条问答对就够但要覆盖三类问题。一是抽取型比如「替米沙坦的禁用人群有哪些」答案是知识库里能直接找到的二是推理型比如「患者同时服用阿司匹林和布洛芬需要注意什么」需要同时命中多个段落再综合三是拒答型比如「推荐一款适合婴儿的奶粉」知识库没有就要明确说没有。集合建好后跑一次系统记录三种指标答案正确率、来源段落命中率、幻觉率。最简单的评估脚本是这样eval_pairs [ {q: 替米沙坦的禁用人群有哪些, expect: [孕妇, 哺乳期], should_refuse: False}, {q: 推荐一款适合婴儿的奶粉, expect: [], should_refuse: True}, ] review [] for item in eval_pairs: result qa_endpoint(item[q]) hit any(keyword in result for keyword in item[expect]) review.append({ 问题: item[q], 期望拒答: item[should_refuse], 实际拒绝: 未找到 in result, 命中知识库: hit, })然后用同一组问题做对照实验直接问裸大模型再问这套系统各记录一次。不用多严谨现场演示几轮就能让评委直观看到差距裸模型凭训练记忆答这套系统返回带来源编号的答案问到知识库外的问题会说不知道。这两条正是 RAG 医疗问答的核心价值。引用溯源在那个时刻就是护城河把返回体加一个 docs 字段界面上列出参考文档名称评审对这个细节的反响通常比算法本身更好。我自己的习惯是再往前走一步把知识库从演示文档换成真实的药品说明书和科室宣教材料让随机找来的朋友自己出题来问。这个测试没有标准答案翻车是常态但每一次翻车都能定位到是知识库缺文档还是切分不够细还是阈值卡太紧。把 demo 当产品用是这行最常见的翻车一份几十条的小评估集就是改需求时最大的后悔药。希望这条链路能帮你把医疗问答系统从「能跑」做到「能讲」少走几个坑少熬几个夜。本文还有配套的精品资源点击获取