简介:本资源为基于RAG与大模型技术的医疗问答系统完整项目工程,面向计算机、人工智能相关专业的毕业设计、课程设计、大作业、工程实训及学科竞赛参赛者。项目利用DiseaseKG数据集与Neo4j构建知识图谱,结合BERT命名实体识别与34b大模型意图识别,通过精确知识检索与问答生成提升医疗咨询性能,着力解决大模型在医疗领域应用的可靠性问题。压缩包共75个文件,约84.65MB,涵盖Python源码、Jupyter Notebook实验记录、JSON与CSV数据文件、PNG/JPG界面截图、YAML配置及Markdown说明文档,覆盖知识图谱构建、模型微调、NER训练与Web交互等模块。已有157人学习关注。项目代码经过测试运行,功能完整,可实现复现复刻,答辩评审平均分达96分,设计报告亦可借鉴,适合在此基础上扩展开发新功能,遇到问题可联系作者获取解答与学习资料支持。
1. 医疗问答系统为什么不能只靠大模型硬答
医疗问答这个场景,很多人第一反应是「接个大模型 API 不就行了」。真做过就知道,纯靠大模型硬答在医疗领域几乎不可用:模型会一本正经地编出并不存在的药物剂量、把禁忌症说反、把不同科室的诊疗路径混在一起。这不是模型不够大,而是它没有「依据」——它是在用参数里的统计记忆回答一个需要精确引用的问题。
RAG(检索增强生成)解决的正是这件事:先从可信知识库里检索出相关片段,再让大模型基于这些片段组织答案。落到医疗问答系统这个毕设/课设/大作业题目上,它天然包含四块可拆的工程量——知识库构建、检索、生成、评测。而热词里反复出现的 Neo4j、BERT,恰好对应两个关键升级点:用 Neo4j 把扁平的文本块升级成带关系的知识图谱,用 BERT 类模型把「按关键词匹配」升级成「按语义匹配」。
这套系统适合谁?适合要交一个能演示、能答辩、能讲清技术选型的大作业的学生,也适合想入门 RAG 实战、agentic rag 的工程师。它不需要 GPU 集群,一台 16G 内存的机器加一个可调用的大模型接口就能跑通最小闭环。下面我按「先立住原理、再动手复现、最后讲坑」的顺序,把这条链路拆开讲清楚。
2. 知识库怎么建:从医疗文本到 Neo4j 图谱
医疗问答的质量上限,在检索这一步就定死了。检索不到正确片段,后面大模型再强也是巧妇难为无米之炊。所以这一章先把知识库做扎实,分文本切分和知识图谱两条路走。
2.1 医疗语料的切分策略与 chunk 参数
医疗文本有个特点:一段话里往往同时包含症状、检查、诊断、用药四类信息,粗暴按固定字数切会把一个完整的诊疗逻辑切断。我一般按「语义段落 + 重叠窗口」切,而不是纯按 token 数切。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 医疗片段建议 400-600,太短丢上下文,太长稀释语义 chunk_overlap=80, # 重叠 15% 左右,防止关键句被切断 separators=["\n\n", "\n", "。", ";", ",", ""], # 中文标点优先 length_function=len, ) def split_medical_doc(text: str): chunks = splitter.split_text(text) # 过滤掉纯标题、纯页码这类无信息片段 return [c for c in chunks if len(c.strip()) > 30]逻辑说明:RecursiveCharacterTextSplitter会按 separators 顺序尝试切分,优先在段落边界断开,实在不行才退到句号、逗号。chunk_size=500是针对中文医疗文本的经验值——一段完整的「某病的临床表现」通常在 300 到 600 字之间。chunk_overlap=80保证跨块的句子不会两边都缺一半。
参数怎么调:如果你的语料是药品说明书,句子短、结构规整,chunk_size 可以降到 300;如果是诊疗指南这种长段落,可以提到 800。判断标准很简单——随机抽 20 个 chunk,看有没有「上半句在讲症状、下半句被切走」的情况,有就加大 overlap。
2.2 用 Neo4j 把文本块升级成知识图谱
纯向量检索的短板是「关系盲」:它知道「二甲双胍」和「糖尿病」相关,但不知道是「治疗」还是「禁忌」。Neo4j 构建知识图谱就是补这块。医疗领域天然适合图谱——疾病、症状、药物、检查之间全是明确的关系。
先装 Neo4j 社区版,导入数据前确认内存配置,很多人踩的坑是默认堆内存太小,导入几万节点就卡死。
# neo4j.conf 关键配置(社区版默认值偏小) dbms.memory.heap.initial_size=2G dbms.memory.heap.max_size=4G dbms.memory.pagecache.size=2G建图谱的核心是定义节点和关系。下面用 Python 驱动写入一个「疾病-症状-药物」三元组:
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password")) def create_medical_graph(tx, disease, symptom, drug): # MERGE 而非 CREATE,避免重复节点 tx.run(""" MERGE (d:Disease {name: $disease}) MERGE (s:Symptom {name: $symptom}) MERGE (m:Drug {name: $drug}) MERGE (d)-[:HAS_SYMPTOM]->(s) MERGE (d)-[:TREATED_BY]->(m) """, disease=disease, symptom=symptom, drug=drug) with driver.session() as session: session.execute_write(create_medical_graph, "2型糖尿病", "多饮多尿", "二甲双胍")逻辑说明:MERGE是「存在则复用、不存在则创建」,这是图谱去重的关键,用CREATE会造出大量同名节点。关系类型HAS_SYMPTOM、TREATED_BY是自定义的,命名要统一,否则后面查询会乱。
从图谱里查「一个疾病关联的所有药物」,就是热词里问的「neo4j 查询从一个节点出发如何查询多条」:
MATCH (d:Disease {name: "2型糖尿病"})-[:TREATED_BY]->(m:Drug) RETURN m.name AS drug这条 Cypher 从 Disease 节点出发,沿 TREATED_BY 关系找到所有 Drug 节点。图谱的价值在于,检索时你可以先定位疾病节点,再顺着关系把症状、药物、检查一次性捞出来,作为上下文喂给大模型,比单纯向量召回的信息密度高得多。
2.3 向量库与图谱的混合检索
实际系统里我一般两条路都留:向量库负责「语义相似」,图谱负责「关系精确」。检索时先向量召回 Top-K,再用图谱补全相关实体,合并后去重。这样既不会漏掉语义相近但用词不同的片段,也不会丢掉结构化的关系信息。
3. 检索层怎么做:BERT 语义召回与 RAG 检索链路
知识库建好只是原料,检索层决定了大模型最终能看到什么。这一章讲清楚 BERT 在这里扮演什么角色,以及 RAG 检索链路怎么串。
3.1 BERT 在医疗检索里到底解决什么问题
关键词检索(BM25)在医疗场景有个致命问题:患者问「血糖高怎么办」,知识库里写的是「高血糖的干预措施」,字面不重合,BM25 直接漏掉。BERT 类模型把句子编码成向量,语义相近的向量距离近,就能召回。
用 sentence-transformers 加载一个中文 BERT 做编码:
from sentence_transformers import SentenceTransformer import numpy as np # 用中文预训练模型,医疗领域可换成语料微调过的版本 model = SentenceTransformer("shibing624/text2vec-base-chinese") def encode_chunks(chunks): # normalize_embeddings=True 让余弦相似度等价于点积,加速检索 return model.encode(chunks, normalize_embeddings=True, batch_size=32) def search(query, chunk_embeddings, chunks, top_k=5): q_emb = model.encode([query], normalize_embeddings=True)[0] scores = chunk_embeddings @ q_emb # 点积即余弦相似度 idx = np.argsort(scores)[::-1][:top_k] return [(chunks[i], float(scores[i])) for i in idx]逻辑说明:normalize_embeddings=True是关键,归一化后向量点积等于余弦相似度,省掉除法。batch_size=32是显存和速度的平衡点,CPU 上可以降到 8。
参数说明:top_k不是越大越好。医疗问答里 top_k 设 3 到 5 比较稳,太大反而把不相关片段塞进上下文,稀释关键信息,还会拉低 rag hit rate。判断检索质量看 hit rate——正确片段是否落在 Top-K 里,这个指标比准确率更能反映检索层好坏。
3.2 把检索结果拼成 prompt 喂给大模型
检索到片段后,要组织成结构化 prompt。医疗场景的 prompt 必须强约束「只依据给定资料回答,资料没有就说不知道」,否则模型又开始编。
def build_prompt(query, retrieved): context = "\n\n".join([f"[资料{i+1}] {c}" for i, (c, _) in enumerate(retrieved)]) return f"""你是医疗问答助手,只能依据下面资料回答,资料未提及的内容必须回答"资料中未提及"。 资料: {context} 问题:{query} 回答:"""逻辑说明:编号[资料1]是为了让模型能引用来源,答辩时能展示「答案有据可查」。强约束句放在最前面,是因为大模型对 prompt 开头的指令遵循度最高。
调用大模型时,temperature 建议设 0.1 到 0.3,医疗问答要的是稳定复现,不是创意。如果你用本地部署,注意上下文长度——检索 5 个 500 字片段就是 2500 字,加上问题和回答,要留够余量。
3.3 检索链路的完整串联
把前面几步串起来,一个最小可用的 RAG 检索链路就是:query 编码 → 向量召回 Top-K → 图谱补全实体 → 合并去重 → 拼 prompt → 大模型生成。这条链路每一步都可单独替换升级,比如把向量召回换成 BERT 微调版,把图谱补全换成 ontology rag 的本体推理。这也是为什么 RAG 项目适合做毕设——模块清晰,每块都能讲出选型理由。
4. 生成与评测:让答案可复现、可量化
系统能跑通不等于能答辩。答辩老师一定会问「你怎么证明它比直接问大模型好」。这一章讲生成端的约束和评测方法。
4.1 生成端的幻觉抑制手段
除了 prompt 约束,还有两个实操手段。一是「引用回填」:要求模型在答案里标注依据的资料编号,生成后校验编号是否真实存在,不存在就重生成。二是「答案一致性检查」:同一个问题换不同措辞问三次,如果答案差异过大,说明检索不稳定,需要回头调检索层。
def check_citation(answer, num_docs): import re cited = set(int(x) for x in re.findall(r"\[资料(\d+)\]", answer)) # 引用了不存在的资料编号,判定为幻觉 return all(1 <= c <= num_docs for c in cited)逻辑说明:这个函数扫描答案里的[资料N]标记,如果 N 超出实际资料数量,说明模型在编造来源,直接判为不可信答案。
4.2 用 hit rate 和人工抽检做评测
RAG 系统的评测分两层。检索层看 hit rate:准备 50 到 100 个「问题-正确片段」对,看正确片段有多少落在 Top-K 里,这个数字低于 0.8 就要优化检索。生成层看人工抽检:随机抽 30 个回答,按「有据可查、无编造、逻辑通顺」三项打分。
| 评测维度 | 指标 | 合格线 | 优化方向 |
|---|---|---|---|
| 检索层 | hit rate@5 | ≥ 0.8 | 换 BERT 模型、调 chunk 大小 |
| 生成层 | 引用准确率 | ≥ 0.9 | 加强 prompt 约束 |
| 生成层 | 人工可用率 | ≥ 0.7 | 补图谱关系、扩知识库 |
这张表可以直接放进答辩 PPT。注意 hit rate 和最终答案质量不是线性关系——hit rate 从 0.8 提到 0.9 可能只让可用率涨几个点,边际收益递减,别在这上面无限投入。
4.3 大模型选型:本地部署还是调 API
毕设场景我一般建议先用 API 跑通链路,把精力放在检索和知识库上,最后再考虑本地部署。本地部署要考虑显存、量化、推理速度,容易在环境上耗掉大半时间。如果必须本地,选 7B 级别的量化模型,用 GGUF 格式在 CPU 上也能跑,只是慢。选型时别纠结「哪个模型最强」,医疗问答更看检索质量,模型只要指令遵循能力过关就行。
5. 避坑与排查:医疗 RAG 最容易翻车的五个地方
这一章全是血泪经验,每条按「现象 → 原因 → 解决」写,照着排查能省很多时间。
现象一:检索明明召回了正确片段,大模型还是答错。原因通常是 prompt 里资料和问题混在一起,模型没分清哪是资料哪是问题。解决:用明确的分隔符和标签,把资料区、问题区、指令区物理隔开,指令放最前。
现象二:Neo4j 导入几万节点后查询越来越慢。原因是没建索引,每次查询全图扫描。解决:给高频查询属性建索引,CREATE INDEX FOR (d:Disease) ON (d.name),建完查询能从秒级降到毫秒级。
现象三:BERT 编码中文医疗术语效果差。原因是通用中文模型没见过大量医疗专有名词。解决:要么换医疗语料微调过的模型,要么在检索前做一层同义词归一化,把「心梗」和「心肌梗死」映射到同一实体。
现象四:chunk 切得太碎,答案缺上下文。现象是模型回答「资料中未提及」,但知识库里明明有。原因是关键信息被切到相邻 chunk 里了。解决:加大 overlap,或者检索时把命中 chunk 的前后各一个 chunk 一起带上。
现象五:换个大模型后效果反而变差。原因是不同模型对 prompt 格式敏感度不同,原来调好的 prompt 不适用了。解决:换模型必须重新跑一遍评测集,别凭感觉判断。
注意:医疗问答系统只能作为学习和演示用途,任何真实医疗决策都必须由执业医师做出。系统输出要明确标注「仅供参考,不构成诊疗建议」。
6. 进阶技巧:把 RAG 升级成 GraphRAG 的实操路径
基础版跑通后,想让系统上一个档次,最值得投入的方向是 GraphRAG——把向量检索和图谱推理结合起来。普通 RAG 只能召回「和问题相似的片段」,GraphRAG 能沿着实体关系做多跳推理,回答「A 病的常用药里,哪个对 B 症状有禁忌」这类需要跨关系的问题。
具体做法分三步。第一步,实体抽取:用大模型从每个 chunk 里抽出「疾病、症状、药物、检查」四类实体和它们的关系,输出结构化三元组。第二步,图谱融合:把抽出的三元组用 MERGE 写进 Neo4j,同名实体自动合并,形成全局图谱。第三步,检索时先向量召回定位入口实体,再从入口实体出发做 1 到 2 跳的图查询,把路径上的节点作为补充上下文。
def graph_expand(driver, entity_name, hops=2): # 从入口实体出发,最多扩展 hops 跳,返回关联实体 query = f""" MATCH (n {{name: $name}})-[*1..{hops}]-(m) RETURN DISTINCT m.name AS related, labels(m)[0] AS type LIMIT 20 """ with driver.session() as session: return session.run(query, name=entity_name).data()逻辑说明:[*1..{hops}]是变长路径匹配,hops 控制扩展深度。设 2 跳是经验值——1 跳信息太少,3 跳以上会引入大量噪声实体,反而干扰生成。LIMIT 20防止某个热门实体关联过多导致上下文爆炸。
参数上,hops 和 LIMIT 要一起调。如果发现补充上下文里出现大量无关实体,先降 hops 再降 LIMIT。图谱扩展出来的实体不是直接喂给大模型,而是作为「线索」去向量库里二次召回对应片段,这样既利用了关系,又保证了上下文是自然语言而非干巴巴的实体名。
验证 GraphRAG 有没有效果,还是回到评测集:对比开启图谱扩展前后,多跳问题的 hit rate 和人工可用率。如果多跳问题占比不高,GraphRAG 的收益有限,别为了技术而技术,基础 RAG 够用就先交付。
我自己做这类系统的习惯是:先把最小闭环跑通并录一个能演示的视频,再逐模块升级。因为答辩和验收看的是「能跑、能讲清、有数据」,不是模块堆得多。图谱、BERT、GraphRAG 都是加分项,但前提是基础链路稳。希望帮到你。
本文还有配套的精品资源,点击获取