简介:基于RAG与大模型技术的医疗问答系统项目资源,面向毕业设计、课程设计、工程实训及学科竞赛等场景,适合需要快速搭建完整AI应用的开发者。系统以DiseaseKG数据集和Neo4j知识图谱为底座,融合BERT命名实体识别与34b大模型意图识别,实现精准知识检索与问答生成,有效提升医疗咨询可靠性。资源共75个文件,压缩包84.65MB,包含Python源码、Jupyter Notebook、YAML配置、JSON数据及说明文档等,其中py与ipynb覆盖知识图谱构建、NER训练、微调推理等核心流程,配图帮助还原界面与系统效果。已有157人学习参考。项目经过严格测试运行,功能完好,可直接复现;提供完整工程文件与设计说明,可作为课程设计报告、答辩演示及后续功能扩展的基础,同时附有开发环境配置文件,便于快速上手。
1. 医疗问答不用 RAG:34B 裸回答的幻觉率会让项目直接失控
医疗问答里最反直觉的一点:参数越大,裸答越自信,犯错越隐蔽。问 34B 大模型“2 型糖尿病患者适合吃哪种水果”,回答结构完整,但药品学名和剂量错误要逐字核对才能发现。这个项目把“事实”从模型参数里拆了出去:DiseaseKG 清洗后导入 Neo4j 知识图谱,BERT 抽取疾病、症状、药物实体,34B 大模型只做意图识别和答案重写,结论必须附带 RAG 检索出的证据节点。整条链路在单机 GPU 上可复现,适合做 RAG 项目、大模型应用开发练手,也方便改造成毕业设计和课程设计。下面按数据管道、双模型分工、检索链路、系统集成拆开讲。
2. DiseaseKG 清洗与 Neo4j 图谱构建:从 JSON 到可查询的实体关系
2.1 为什么医疗问答要先建图谱,而不是纯靠向量库
纯向量库做医疗问答有两个硬伤。第一,语义近似不等于事实正确,“高血压”和“血压偏高”向量接近,但关联的用药方案完全不同;第二,多跳问题推不动,比如“同时患高血压和糖尿病的患者,首选药物有什么冲突”,向量检索只能召回语义片段,无法沿着“疾病—症状—药物—禁忌”的关系链走两跳三跳。知识图谱把答案从“找相似的文字”变成“找确定的关系路径”,这是医疗场景强约束的必然选择。
DiseaseKG 数据集的原始形态是 JSON,工程里对应medical_new_2.json。这份数据描述了一个疾病的完整画像:疾病名、所属科室、典型症状、常用药物、推荐检查。构建图谱前先要对齐实体口径,把“高血压病”“高血压症”这类同义写法归一到一个节点上,否则后面 RAG 检索会反复出现“查得到但连不上”的问题。
2.2 实体与关系的 Schema 设计:先把 ontology 定死
图谱不是把所有 JSON 字段无脑变成节点。我一般会先画一个最小的 ontology,节点类型控制在五类以内,关系类型控制在四类以内。这个项目里比较合理的设计如下:
| 节点类型 | 属性示例 | 关系类型 | 方向与语义 |
|---|---|---|---|
| Disease | name, department, alias | HAS_SYMPTOM | Disease → Symptom |
| Symptom | name | RECOMMEND_DRUG | Disease → Drug |
| Drug | name, usage | NEED_CHECK | Disease → Check |
| Check | name | LOCATED_IN | Disease → Department |
| Department | name | — | — |
关系类型宁少勿多。有的工程把“禁忌”“慎用”“不良反应”全部拆成独立关系,最后查询时路由复杂,效果反而差。先用四类核心关系把主链路跑通,后续要扩展再补。
2.3 build_up_graph.py 的批处理导入逻辑
数据量不大时不适合走LOAD CSV,因为原数据是嵌套 JSON,要先用 Python 做实体对齐,再写 Neo4j。常见做法是用py2neo的事务接口批量提交,伪代码结构如下:
from py2neo import Graph, Node, Relationship import json graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) with open("medical_new_2.json", encoding="utf-8") as f: records = json.load(f) BATCH = 500 # 单批写入的节点/关系数量 tx = graph.begin() for i, rec in enumerate(records): disease = Node("Disease", name=rec["疾病"]) tx.merge(disease, "Disease", "name") # merge 而不是 create for symptom in rec.get("症状", []): sym_node = Node("Symptom", name=symptom) tx.merge(sym_node, "Symptom", "name") tx.merge(Relationship(disease, "HAS_SYMPTOM", sym_node)) for drug in rec.get("药物", []): drug_node = Node("Drug", name=drug) tx.merge(drug_node, "Drug", "name") tx.merge(Relationship(disease, "RECOMMEND_DRUG", drug_node)) if i % BATCH == 0: tx.commit() # 分批提交,避免大事务 tx = graph.begin() tx.commit()这段代码有三个关键点。merge按name属性做唯一匹配,所以脚本重复执行不会产生重复节点,这是可复现工程的基本要求。BATCH=500是经验值,Neo4j 单事务写入过多节点时堆内存容易飙高,批量提交把内存峰值压住。关系也走merge,保证同一条“疾病—症状”关系不会被重复插入,后面跑 RAG 检索时不会出现重复证据。
2.4 rel_aug.txt 在补什么:关系增强与同义词归一
rel_aug.txt这个文件看起来像人工整理的关系增强语料,它的作用是补全数据集中缺失的关系。例如原始 JSON 只写了“糖尿病—症状—多饮”,没有反向关系,但用户提问往往是“多饮是什么病的症状”,这时就要通过关系增强生成反向三元组,或者补充同义实体映射(如“口渴”映射到“多饮”)。增强后的数据可以喂给后续 NER 数据生成,也可以直接用于构建更多图谱路径。
导入完成后验证图谱连通性,Cypher 如下:
MATCH (d:Disease)-[r:HAS_SYMPTOM]->(s:Symptom) RETURN d.name AS disease, collect(s.name) AS symptoms LIMIT 10collect把症状聚合到数组里,方便一眼看出某疾病的症状覆盖度。验证重点不是查出来多少条,而是查一下有没有孤立节点:MATCH (n) WHERE NOT (n)--() RETURN count(n),孤立节点意味着实体对不上,要回源头查 JSON 和关系增强文件的格式。
3. BERT 实体识别与 34B 意图识别:医疗场景的双模型分工
3.1 为什么实体抽取用小模型,意图识别反而用大模型
实体抽取是每一条用户请求都要走的环节,频率高,必须快。BERT 这类百兆级别模型在 CPU 上跑一次几十毫秒,精度在医疗命名实体上够用;而意图识别要处理的是“我想知道糖尿病平时吃饭注意啥”这类非结构化表达,涉及指代和省略,小模型容易判错,所以交给 34B 大模型做 few-shot 分类更稳。
双模型分工后还有一个好处:大模型不用每轮都处理全量文本,只拿到 BERT 输出的结构化实体和规范化后的问句,prompt 更短,推理延迟更低。34B 模型 4bit 量化后在 24GB 显存的卡上推理是可行的,但如果每轮都让它自己读原文抽实体,延迟会翻倍。
3.2 NER 数据增强与 BIO 标签体系:ner_data_aug.txt 的用法
ner_data_aug.txt是 NER 训练语料的增强版本。原始标注数据量有限,医疗实体描述多变,常见增强手段是实体替换和句式改写,例如“糖尿病患者出现多饮多尿”可以改写成“患了糖尿病的人老觉得口渴、上厕所频繁”,保持实体标签不变。这样训练出来的 BERT 对口语化表达更鲁棒。
标签体系采用标准 BIO 格式,tag2idx.npy保存的是标签到索引的映射,推理时必须保证它和模型输出维度一致。标注样本大致长这样:
糖 B-Disease 尿 I-Disease 病 I-Disease 患 O 者 O 多 B-Symptom 饮 I-Symptom3.3 NER 推理代码:从 tag2idx 到实体合并
import numpy as np import torch from transformers import AutoTokenizer, BertForTokenClassification tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = BertForTokenClassification.from_pretrained( "./model", num_labels=len(tag2idx) ) tag2idx = np.load("tag2idx.npy", allow_pickle=True).item() idx2tag = {v: k for k, v in tag2idx.items()} def predict_entities(text: str): tokens = tokenizer(text, return_tensors="pt", truncation=True, max_length=128) with torch.no_grad(): logits = model(**tokens).logits[0] # [seq_len, num_labels] preds = logits.argmax(-1).numpy() entities = [] cur_tokens, cur_type = [], None for tok, pid in zip(tokens["input_ids"][0][1:-1], preds[1:-1]): label = idx2tag[int(pid)] word = tokenizer.convert_ids_to_tokens(int(tok)) if label.startswith("B-"): if cur_tokens: entities.append((cur_type, "".join(cur_tokens))) cur_tokens, cur_type = [word], label[2:] elif label.startswith("I-") and cur_type == label[2:]: cur_tokens.append(word) else: if cur_tokens: entities.append((cur_type, "".join(cur_tokens))) cur_tokens, cur_type = [], None if cur_tokens: entities.append((cur_type, "".join(cur_tokens))) return entities代码里[1:-1]是把[CLS]和[SEP]位去掉,这两个 token 的预测结果不参与实体合并。max_length=128覆盖绝大多数医疗问句,但如果问题很长,截断会丢掉尾部实体,后续可以在预处理阶段加滑窗切分。合并逻辑是“B- 开头,同类型 I- 续接”,遇到 O 或不同类型就断开,最终输出[(类型, 实体词), ...]。
3.4 意图识别 prompt 与 LoRA 微调配置
意图识别用 34B 大模型跑 few-shot 是性价比最高的方式,prompt 只需要固定输出 JSON,避免二次解析。模板大致如下:
你是医疗问答系统的意图路由器,只输出 JSON。 问题:{question} 实体:{entities} 规则: - intent=kg_query 表示查询知识图谱 - intent=chat 表示闲聊 - intent=reject 表示超出医疗范围 输出示例:{"intent": "kg_query", "disease": "糖尿病", "target": "饮食"}结构化输出直接作为下一阶段 RAG 检索的参数,不需要再写一套解析规则。工程里的finetune_hf.py和lora_finetune.ipynb做的则是另一件事:如果 few-shot 效果不稳定,可以对lora_data里的意图语料做 LoRA 微调,让模型输出格式收敛。
LoRA 微调有几个参数直接影响效果和显存占用,我调这个项目时用的配置如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| lora_r | 16 | 秩越高表达能力越强,显存也越高 |
| lora_alpha | 32 | 通常取2 * lora_r |
| target_modules | q_proj, v_proj | ChatGLM/Qwen 系列的注意力投影层 |
| lora_dropout | 0.05 | 防止微调过拟合 |
| per_device_train_batch_size | 1 | 34B 模型只能小 batch |
| gradient_accumulation_steps | 8 | 等效 batch size 8 |
| learning_rate | 2e-4 | LoRA 微调常用区间 |
| 量化 | 4bit NF4 | QLoRA 标配,显存降到 1/4 |
34B 模型的 LoRA 微调在单卡 A100 40G 上可行,24G 显卡建议直接推理不微调,或者改用 6B/7B 底座。finetune_hf.py里本质就是把底座模型用bitsandbytes做 4bit 加载,再挂peft.LoraModel,训练完只保存 adapter 权重,推理时合并。注意tag2idx.npy和模型输出头维度要匹配,换底座模型时最容易在这里报错。
4. RAG 检索链路与 nl2cypher:把自然语言编译成 Cypher 查询
4.1 混合检索:知识图谱查询和向量召回并行
RAG 检索层的核心问题是:用户表达和知识库表达之间往往存在语义鸿沟。“血压有点高”和图谱里的“高血压”不是同一个字符串,所以需要混合检索。一条查询同时走两条路:结构化路径从 Neo4j 里按实体名精确匹配,非结构化路径把医学文本语料分块后做向量召回,最后把两路结果合并送进生成层。
向量召回的分块策略直接影响命中率。医疗文本里实体短语经常跨越块边界,chunk_overlap太小会把“2 型糖尿病”切碎。我一般用 256 的块大小配合 32 的重叠长度:
| 组件 | 参数 | 推荐值 |
|---|---|---|
| 文本分块 | chunk_size | 256 |
| 文本分块 | chunk_overlap | 32 |
| Embedding 模型 | 向量维度 | 768(bge-base-zh-v1.5) |
| 向量召回 | 初筛 top_k | 20 |
| 重排序 | 交叉编码器 | 保留 5 条 |
| 图谱查询 | 查询上限 | LIMIT 20 |
向量召回用 faiss 内积检索就够,数据量超过百万级再考虑迁移 Milvus。重排序这一步不要省,初筛 20 条里真正相关的可能只有 3 条,交叉编码器逐条打分会显著提升答案质量。
4.2 nl2cypher:从意图 JSON 到可执行查询
工程里的nl2cypher.py、nl2cypher_data.txt、nl2cypher_data_test.txt组成了一套完整的“自然语言转 Cypher”模块。它做的事情是把意图识别输出的 JSON 转成 Neo4j 查询语句。微调数据集里每一行是一对“自然语言问题 + 对应 Cypher”,比如:
糖尿病早期症状有哪些? MATCH (d:Disease {name:"糖尿病"})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name LIMIT 20生成方式可以走 34B 大模型 few-shot,也可以对nl2cypher_data.txt做专项微调。我这里展示基于模板的兜底方案,适合冷启动:
def build_cypher(intent: dict) -> str: disease = intent.get("disease", "") target = intent.get("target", "") REL_MAP = { "症状": "HAS_SYMPTOM", "药物": "RECOMMEND_DRUG", "检查": "NEED_CHECK", "科室": "LOCATED_IN", } rel = REL_MAP.get(target) if not rel: raise ValueError(f"无法映射目标类型: {target}") return ( f"MATCH (d:Disease {{name:$disease}})" f"-[:{rel}]->(n) RETURN n.name " f"LIMIT 20" )这里有个安全细节容易被忽略。Cypher 拼接时用户输入的疾病名称必须走参数绑定($disease),不能直接拼字符串,否则一个包含引号的恶意输入就能改查询结构。执行前还应该用白名单过滤:只允许MATCH、WHERE、RETURN、LIMIT这些只读关键字开头,拒绝DELETE、MERGE、CREATE。医疗系统里数据写操作一旦被注入,后果不是性能问题而是数据完整性问题。
4.3 证据组装与答案生成:强迫模型引用来源
RAG 生成阶段,把图谱查询结果和向量召回片段统一编号拼进 prompt,模型只允许基于证据编号作答:
def build_knowledge_prompt(question, entities, kg_records, doc_chunks): evidence = [] for i, (source, content) in enumerate(kg_records + doc_chunks, 1): evidence.append(f"[{i}] {source}: {content}") return f"""请仅依据证据回答,不要使用模型记忆。 证据不完整时直接回答“依据现有知识无法回答”。 问题:{question} 识别实体:{entities} 证据: {chr(10).join(evidence)} 回答:"""这个 prompt 的作用是强制模型做“摘要生成”而不是“自由发挥”。每条证据带编号,模型回答时可以引用,后端也能把答案对应的证据链展示到界面上。没有证据命中时kg_records和doc_chunks都为空,此时模型会走到拒答分支,这个兜底逻辑是医疗问答系统可靠性的最后一道防线。
5. WebUI 集成与系统排错:从登录态到一次问答的完整链路
5.1 工程文件结构与模块职责
webui.py是 Web 服务入口,login.py负责登录注册,user_data_storage.py管理用户提问记录与会话历史,user_credentials.json保存账号数据。分工上,登录认证和业务问答分离是很合理的结构,user_data_storage.py单独抽出来意味着用户数据的读写逻辑可以被测试脚本直接调用,不用启动 Web 服务。
一个要注意的点:user_credentials.json存账号密码时,绝不能明文存储。常见做法是使用werkzeug.security的generate_password_hash,每次登录用check_password_hash校验。这个项目的用户数据文件暴露在根目录,如果带着这个结构做答辩,评审大概率会问密码安全问题,提前处理掉值得。
5.2 一次完整问答的调用时序
用户从前端提交问题后,请求依次经过:登录态校验 → NER 实体抽取 → 34B 大模型意图识别 → 图谱查询与向量检索 → 证据拼装 → 大模型生成回答 → 答案与证据链返回前端。用 Flask 写路由时,核心逻辑如下:
@app.route("/ask", methods=["POST"]) def ask(): question = request.json["question"] user = session.get("user") if not user: return jsonify({"error": "未登录"}), 401 entities = predict_entities(question) # BERT 实体抽取 intent = llm_intent(question, entities) # 34B 意图识别 kg_rows = [] if intent["intent"] == "kg_query": cypher = build_cypher(intent) # 生成 Cypher kg_rows = query_neo4j(cypher) # Neo4j 查询 doc_chunks = vector_search(question, top_k=20) # 向量召回 prompt = build_knowledge_prompt(question, entities, kg_rows, doc_chunks) # 证据拼装 answer = llm_generate(prompt) # 答案生成 save_history(user, question, answer, kg_rows[:3]) # 写用户数据 return jsonify({"answer": answer, "evidence": kg_rows[:3]})session依赖 Flask 的SECRET_KEY,不配置的话每次重启服务登录态都会失效,这是本地调试常见的“为什么我登录完又跳回登录页”的原因。save_history写用户数据时只存前 3 条证据,避免 JSON 文件无限膨胀。
5.3 从 log.txt 看常见故障与排查手段
log.txt是之前跑通的运行日志,里面能看到的几类典型问题:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| Cypher 语法错误 | 实体名含引号等特殊字符 | 所有值走参数绑定,禁止字符串拼接 |
| NER 返回空实体 | 医学名词被分词器拆碎 | 加载自定义词典,把常见病名加入tokenizer.add_tokens |
| 生成回答为空 | 证据不足触发拒答分支 | 检查图谱覆盖率和分块重叠度 |
| 显存溢出 OOM | 34B 全精度推理 | 4bit 量化,batch size 设为 1 |
| 答案质量差 | 向量召回命中率低 | 加交叉编码器重排,或换更大的 embedding 模型 |
定位问题的通用手段是把日志结构化。给每个环节记录独立耗时和结果,能快速判断瓶颈在哪一层:
import json, logging, time def log_query(request_id, stage, elapsed_ms, extra=None): logging.info(json.dumps({ "request_id": request_id, "stage": stage, "ms": round(elapsed_ms, 1), **(extra or {}) }, ensure_ascii=False))用questions.csv里的测试问题做回归时,我给每条请求生成一个request_id,记录 NER 耗时、意图识别耗时、Neo4j 查询耗时、生成耗时四个指标。如果单条回答超过三秒,查看日志定位是查询慢还是模型生成慢。Neo4j 查询慢就检查是否命中索引,模型生成慢就考虑换 6B 模型做生成、用 34B 只做意图识别。把日志格式规范好之后,再去调整 RAG 分块参数、top_k 或者本地方案下的向量检索配置,每次改动都能直接对比日志指标的差异,而不是凭感觉反复试。
本文还有配套的精品资源,点击获取