☰
医疗问答RAG系统构建:从知识库到混合检索的实战指南
2026/10/7 21:57:49 网站建设 项目流程

简介:这套基于 RAG 与大模型技术的医疗问答系统源码及文档,是一份达到高分答辩水准的毕业设计项目,主要面向计算机、人工智能、自动化等专业学生与开发者,可作为毕设、课程设计或大作业的完整参考。压缩包共75个文件、约84.65MB,以Python脚本、Jupyter Notebook、文本与JSON数据及Markdown说明文档为主,覆盖问答服务启动、知识图谱构建、数据解析与模型微调等模块。系统将 ChatGLM 微调、Neo4j 图数据库与检索增强生成相结合,源码经调试可运行,配套的用户登录和管理界面便于演示,可帮助读者逐步理解 RAG 医疗问答从数据预处理、模型训练到推理落地的完整链路。已有1114人学习下载,附带的 requirements 与说明文档能帮助快速复现环境,适合在此基础上继续扩展功能的进阶学习者。

1. 医疗问答为什么要绕不开 RAG

你拿一个通用大模型问「阿莫西林和头孢的区别」,它能给你一条条对比得头头是道,但你要是追问一句「信息来源是哪版指南」,它就露馅了——要么编一个不存在的文献,要么把过期共识当最新结论。医疗场景最大的特点就是:知识更新快、表述必须严谨、答错了代价极高。RAG(检索增强生成)就是来解决这个问题的,它让大模型从「背诵记忆」变成「开卷答题」:先到知识库里检索相关资料,再基于资料生成答案,回答的每个结论都能溯源。这套思路不只能做毕设,企业里的药品问答、医护辅助查询、健康科普助手本质上都是同一套骨架。适合谁看:你手头有这个项目要跑通,或者你正在犹豫医疗问答系统的技术选型,想搞明白 RAG 和直接 finetune 大模型到底差在哪。

2. 医疗知识库的准备:为什么 PDF 直出会让 RAG 全军覆没

很多人做 RAG 的第一步是下载一批 PDF 说明书和指南,然后直接切块塞进向量库,结果检索阶段就翻车——查「高血压用药」返回一堆无关段落。问题不在模型,在于知识库压根没被处理成「可检索的语义单元」。医疗 PDF 的排版复杂,表格多、专业术语密集、层级标题交错,直接按字符切块等于把药典撕成纸条再随机捆成一叠,检索当然不准。这一章先解决数据侧的问题。

2.1 数据源选型与合规边界:哪些医疗资料能用作 RAG 知识库

常见的做法是优先选三类公开数据源:药品说明书(NMPA 批准的原版说明书)、临床诊疗指南(卫健委或各学会发布的公开版本)、医学教材的公开章节。这三类内容结构清晰、更新可追溯,最关键的是版权风险最低,适合毕设和演示场景。

这个环节不要贪多,也别追求全。我见过不少同学一上来就想把《内科学》整本灌进去,最后向量库里一堆重复内容,检索时前 20 条结果全是同一段话的变体。更合理的做法是先建一个 200 到 500 份文件的小型知识库,覆盖 3 到 5 个常见科室方向,比如呼吸科、心内科、消化科、内分泌科,每个方向配几份核心指南和常用药品说明书。规模小一点,后面做评估时你能人工核实每条答案的准确性。

还有一条合规红线需要提前确认:知识库只做「医学信息检索与展示」,不要碰「诊断建议」和「用药决策」。这不是文字游戏,而是系统边界。RAG 的职责是把知识库里的内容检索出来并组织成答案,而不是替医生下结论。你可以在系统说明里明确「本系统输出仅供参考,不构成诊疗建议」,这不是免责甩锅,是医疗问答系统的基本边界。

2.2 从 PDF 到结构化 Markdown:预处理脚本与表格陷阱

医疗 PDF 最大的敌人不是扫描件,而是「文字版 PDF 的阅读顺序是乱的」。很多药品说明书是双栏排版,pdfminer 或 PyMuPDF 直接提取文本时,会把左栏读完后跳到右栏,造成段落被拆散、表格数据变得完全不可读。所以预处理的第一原则是:分栏处理和表格识别。

我一般用 PyMuPDF 做第一轮转换,先检测页面的文本块坐标,再根据坐标排序还原阅读顺序。下面是核心思路,你可以直接抄:

import fitz # PyMuPDF def pdf_to_markdown(pdf_path: str) -> str: doc = fitz.open(pdf_path) md_lines = [] for page in doc: # 提取文本块和坐标 blocks = page.get_text("dict")["blocks"] line_items = [] for b in blocks: if "lines" in b: for line in b["lines"]: text = "".join(span["text"] for span in line["spans"]).strip() if text: # 记录每个文本块的 y 坐标和 x 坐标,用于还原阅读顺序 line_items.append((line["bbox"][1], line["bbox"][0], text)) # 按 y 坐标(行)排序,再按 x 坐标(列)排序 line_items.sort(key=lambda item: (item[0], item[1])) # 检测同一行内是否存在跨越半页的间距,有则插入分栏标记 for idx, (y, x, text) in enumerate(line_items): if idx > 0 and abs(y - line_items[idx - 1][0]) > 15: md_lines.append("") md_lines.append(text) doc.close() return "\n".join(md_lines)

这段代码的作用是把 PDF 里的文本块按坐标还原成正确阅读顺序。get_text("dict")返回页面内每个文本块的精确位置,按 y 坐标排序后,双栏排版的内容会被还原成从上到下、从左到右的正确顺序。abs(y - line_items[idx - 1][0]) > 15这个阈值用于判断是否出现了分栏或标题跳变,遇到这种跳变就插入一个空行,方便后续 Markdown 解析。

但这里有个关键坑:表格。药品说明书的「用法用量」「不良反应」经常是表格形态,PDF 文本提取会把表格拆成一堆散落的数字和文字。遇到这种情况,我的建议是不要试图在 PDF 层还原表格,而是先初步转成 Markdown,再用人工或规则补充的方式,把关键表格手动整理成 Markdown 表格。毕设场景里,药品说明书的表格总量并不大,花半天时间手工整理 30 份核心文件的表格,比写一个完美的表格识别器划算得多。这是典型的「算法成本 vs 人工成本」取舍,技术方案要服务于交付节奏。

2.3 面向语义的切片策略:按标题层级切,而不是按字数硬切

切片是 RAG 里最容易被低估的环节。很多教程让你用 LangChain 的RecursiveCharacterTextSplitter按 500 字切块、重叠 50 字,这套参数在通用文档上能用,但放到医疗指南上会切出大量「半个结论」——比如把「禁忌症」的完整列表从中间切断,前半段说「禁用」,后半段全是「慎用人群」,检索时模型只看到一半信息,生成的答案自然断章取义。

正确思路是先识别文档的标题层级,再以语义章节为基本单位切片。药品说明书天然有「适应症」「用法用量」「不良反应」「禁忌」这些固定小节,指南也有「推荐意见」「证据等级」的结构化分层。切片的逻辑应该是:优先保留完整小节,其次处理过长的小节。

import re def split_by_heading(markdown_text: str, max_chunk_size: int = 800, overlap: int = 80): # 识别 markdown 标题(# 或 ## 开头) headings = list(re.finditer(r"^#{1,3}\s+.+$", markdown_text, flags=re.MULTILINE)) chunks = [] for i, match in enumerate(headings): start = match.start() end = headings[i + 1].start() if i + 1 < len(headings) else len(markdown_text) section_text = markdown_text[start:end].strip() # 小节内容较短,直接整段作为 chunk if len(section_text) <= max_chunk_size: chunks.append({"source": match.group(), "text": section_text}) else: # 小节过长,滑动窗口切分,保留重叠 step = max_chunk_size - overlap offset = 0 while offset < len(section_text): chunk = section_text[offset:offset + max_chunk_size] chunk = chunk.strip() if chunk: chunks.append({"source": match.group(), "text": chunk}) offset += step return chunks

这段切片逻辑的关键在于source字段保留了章节标题,后面生成阶段做引用时可以直接把这个标题当作信息来源展示给用户。max_chunk_size=800经验值来自中文医学文本的特征:一个完整的用药说明条目大约 300 到 600 字,800 能覆盖大多数情况;超过这个阈值再滑动切。overlap=80是滑动窗口的重叠区,防止句子在切缝处断裂导致语义缺失。

切完后记得做一步质检:把「禁忌」和「适应症」这两个字段的切片单独拉出来检查,如果发现某条切片里只有「禁」或只有「用」,说明切得太碎,要把这个章节整体作为一个大 chunk 保留,哪怕长度超了也比切碎强。RAG 的问题里,「信息不完整」对答案准确性的伤害远大于「信息冗余」。

3. 向量化与检索链路:Embedding 选型到混合检索

知识库准备好了,下一步是把文本变成向量。很多人以为这一步就是调一个embedding接口的事,实际上 Embedding 模型选型、向量库选型、检索策略三个环节,每一个都决定最终问答质量的上限。这一章把检索链路的四个关键点拆开讲清楚。

3.1 Embedding 模型选型:通用中文模型在医学词上的表现差异

中文医学文本的 Embedding 有一个特有问题:专业术语的语义相似度并不等于文本表面的字面相似度。「阿司匹林」和「乙酰水杨酸」在字面上毫无重叠,但在医学语义上完全等价。通用中文 Embedding 模型(如常见的 text2vec、m3e)在训练时接触的医学语料有限,对这类同义词关系的表征能力偏弱。

我一般会在两个方向上做测试:开源的 BGE 系列(bge-large-zh)和阿里云的 text-embedding-v2 这类商业化接口。BGE 的优势是本地部署、可微调,且在多语言和中文检索基准上有公开数据支撑;商业化接口的优势是开箱即用,对医疗文本的泛化通常比通用开源模型好一些。选型没有标准答案,但有一个验证方法必须做:拿 20 组医疗同义词对(药品通用名 vs 商品名、疾病名称 vs 俗称、症状描述 vs 医学术语)分别计算 cosine 相似度,看模型能不能给出合理的高分。

from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer("BAAI/bge-large-zh-v1.5") pairs = [ ("阿司匹林", "乙酰水杨酸"), ("高血压", "血压升高"), ("心梗", "急性心肌梗死"), ("糖尿病", "2型糖尿病"), ] for text1, text2 in pairs: emb1 = model.encode(text1, normalize_embeddings=True) emb2 = model.encode(text2, normalize_embeddings=True) sim = np.dot(emb1, emb2) print(f"{text1} <-> {text2}: {sim:.4f}")

这段代码用余弦相似度检验 Embedding 模型对医学同义词的敏感度。normalize_embeddings=True把向量归一化到单位长度,这样点积结果就是余弦相似度,取值范围在 -1 到 1 之间。如果你的测试结果里「阿司匹林」和「乙酰水杨酸」的相似度在 0.6 以下,说明这个 Embedding 模型对医学概念的表征不够,你需要换模型或者在检索层做额外补偿,比如后面会提到的同义词改写。

3.2 向量库选型:毕设用 FAISS,还是上 Milvus

向量数据库这个环节,很多人纠结选型,实际上完全看你的场景规模。FAISS 是 Meta 开源的向量检索库,以库的形式嵌入你的 Python 进程,不需要单独部署服务,适合单机、小规模(十万级向量以内)场景。Milvus 是完整的向量数据库系统,支持分布式、数据持久化、多租户,适合生产环境和数据量持续增长的场景。

对毕设或者企业内部的 Demo 系统,我的建议是直接用 FAISS,本地建索引、内存检索,部署成本几乎为零。Milvus 需要单独启动服务、管理集合和索引,虽然功能强大,但对你验证 RAG 的核心逻辑没有直接帮助。

from sentence_transformers import SentenceTransformer import faiss import numpy as np embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5") # 假设 chunks 是上一步切片后的列表,每个元素有 "text" 字段 chunk_texts = [chunk["text"] for chunk in chunks] chunk_vectors = embedder.encode(chunk_texts, normalize_embeddings=True, show_progress_bar=True) # 建立 FAISS 索引 dim = chunk_vectors.shape[1] index = faiss.IndexFlatIP(dim) # 内积索引,配合归一化向量等价于余弦相似度 index.add(chunk_vectors.astype(np.float32)) # 检索 query = "高血压患者能不能服用阿司匹林" query_vec = embedder.encode([query], normalize_embeddings=True) scores, indices = index.search(query_vec.astype(np.float32), k=10) for rank, (score, idx) in enumerate(zip(scores[0], indices[0])): print(f"Top {rank + 1} (score={score:.4f}): {chunk_texts[idx][:80]}...")

IndexFlatIP是内积索引,配合归一化向量就是余弦相似度检索,在十万级数据量下毫秒级返回。这里没有用IndexIVFFlat或IndexHNSW是因为数据量不够大时,暴力检索的准确率最高,而且速度完全够用。k=10先多召回几条,后续用重排序模型精排。

3.3 混合检索:BM25 精确匹配和向量召回怎么互补

向量检索擅长语义匹配,但医学场景里很多查询是「术语精确匹配」。比如用户问「替诺福韦的肾毒性」,向量检索可能因为「替诺福韦」在句子里权重不够,召回一堆「抗病毒药物的不良反应」的宽泛内容,反而把精确提到替诺福韦的那条说明书埋没了。这时候 BM25(经典的关键词匹配算法)能直接按词频和逆文档频率把精确匹配的文档顶上来。

混合检索的常见做法是:向量检索和 BM25 各自取 top N,合并后按加权分数排序。BM25 在 Python 里直接用rank_bm25库,或者 Elasticsearch 的bm25评分。我一般用rank_bm25,轻量、无需额外服务:

from rank_bm25 import BM25Okapi import jieba # 对知识库切片做分词(需要加载医学词典) tokenized_docs = [list(jieba.cut(doc["text"])) for doc in chunks] bm25 = BM25Okapi(tokenized_docs) query_tokens = list(jieba.cut(query)) bm25_scores = bm25.get_scores(query_tokens) # 合并分数:向量分数和 bm25 分数先各自归一化到 0~1 vec_scores = scores[0] / max(scores[0]) # 归一化 bm25_norm = bm25_scores / max(bm25_scores) combined_scores = 0.6 * vec_scores + 0.4 * bm25_norm

这段混合检索代码的关键是权重配比。0.6给向量语义召回,0.4给 BM25 精确匹配,这个比例是我在医疗问答场景里的经验值,不是一个固定最优解。你可以做一个简单的调参实验:拿 30 个查询,分别测纯向量、纯 BM25、不同混合比例的召回准确率,挑最优的一组。实践里常见的效果差异是:纯向量召回时「阿司匹林 vs 乙酰水杨酸」这类同义词问题靠语义能兜住,但精确药名查询容易被埋没;混入 BM25 后,精确匹配的能力立刻体现。

分词这步有个前提工作必须做:把药品名、疾病名、症状术语加入 jieba 的自定义词典。比如「替诺福韦」如果不加词典,会被切成「替诺」「福韦」,BM25 匹配必然失败。这个坑极其常见。

3.4 重排序:把 top_20 收窄到 top_5 的关键一步

混合检索召回 top 20 之后,直接全部塞给大模型生成,效果通常很差——不相关的资料会干扰大模型的注意力,导致答案偏离正确方向。正确做法是加一步重排序(Rerank):用一个专门的交叉编码器模型,把 query 和每条召回文本成对打分,取 top 3 到 5。

交叉编码器(Cross-Encoder)和双编码器(Bi-Encoder)的区别在于:双编码器把 query 和文档分别编码成向量再算相似度,快但精度低;交叉编码器把 query 和文档拼接后一起过模型,慢但精度高。重排序阶段数据量已经从 20 降下来了,用慢一点的交叉编码器完全可接受。

from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-base") pairs = [(query, chunk_texts[idx]) for idx in indices[0]] rerank_scores = reranker.predict(pairs) # 按重排序分数重新排 reranked = sorted( zip(rerank_scores, indices[0]), key=lambda x: x[0], reverse=True ) # 取 top 5 作为生成阶段的参考材料 top_results = [(idx, score) for score, idx in reranked[:5]] for idx, score in top_results: print(f"Rerank score={score:.4f}: {chunk_texts[idx][:80]}...")

bge-reranker-base是 BGE 系列里的重排序模型,输入是「问题 + 候选文本」的拼接。这里reranked[:5]是最终送入生成阶段的上下文。重排序这步是 RAG 质量的隐形杠杆,很多系统向量检索跑得好好的,答案质量上不去,就是省略了这步。医疗场景尤其需要——同样的一个「注意」条目,重排序能把更贴近用户具体病情的片段排到前面。

4. 生成环节:用 Prompt 约束大模型只做「开卷答题」

检索链路通了,最后一个环节是让大模型基于检索到的资料生成答案。但如果你直接把资料拼接进 Prompt 然后丢给模型,很快会发现两个问题:模型还是会自行发挥编造内容,或者它引用的内容不在你给它的资料里。这一章讲怎么用 Prompt 塑形和结构化输出来约束生成行为。

4.1 模型选型:开源本地部署还是 API 调用

这一步取决于你的运行环境和预算。API 路线用 Qwen 或 GLM 的中大杯模型,上下文长度 32K 以上,回答质量和遵循指令的能力都更强;本地路线用 Ollama 部署 Qwen2.5-7B-Instruct 这类 7B 级开源模型,显存需求在 8 到 16 GB 之间,部署简单但回答质量会弱一些,尤其在遵循「只使用参考材料」这类约束时容易出现偏差。

医疗 RAG 项目里,我的建议是:7B 的开源模型足够用来验证流程,但如果要拿给导师或客户演示,API 路线会稳得多。模型能力在这个系统里不是主角,RAG 才是,但模型的指令遵循能力直接决定 RAG 效果能不能兑现。

# 本地部署 Qwen2.5-7B-Instruct(需要 16GB 显存或以上) ollama pull qwen2.5:7b # 启动服务 ollama serve

上面是本地部署的最小命令。ollama pull拉取模型权重,ollama serve启动本地 OpenAI 兼容接口。如果你没有 GPU 环境,直接走 API 接口,代码里唯一变化就是把base_url和api_key换成对应厂商的配置。

4.2 Prompt 塑形:系统角色、引用编号与强制拒答

生成阶段的 Prompt 设计直接决定模型是「忠实转述」还是「自由发挥」。核心有三条约束:明确系统角色是医学信息助手而非医生;要求模型只使用参考材料中编号对应的内容;遇到材料中没有的信息必须明确说「资料库中未找到相关信息」,而不是自己补。下面是我打磨过的 Prompt 模板:

prompt_template = """你是一个医学信息助手。你的任务是基于【参考材料】回答用户问题。 要求: 1. 只能使用参考材料中的信息,不得使用自身知识补充。 2. 回答中每个关键结论必须标注引用,格式为[来源编号],来源编号对应参考材料中每条资料的编号。 3. 如果参考材料中没有相关信息,直接回复:资料库中未找到相关信息。 4. 不提供诊断结论和用药建议,只陈述资料中的既有信息。 【参考材料】 {context} 【用户问题】 {question} 请回答:""" def build_prompt(query: str, top_results: list) -> str: context_parts = [] for idx, (chunk_idx, _) in enumerate(top_results): source = chunks[chunk_idx]["source"] text = chunks[chunk_idx]["text"] context_parts.append(f"[来源{idx + 1}]《{source}》\n{text}") context = "\n\n".join(context_parts) return prompt_template.format(context=context, question=query)

这个模板的巧妙之处在于把引用编号和资料内容绑定:模型只要按照格式输出[来源1],你就能在后端准确回溯到知识库里的原始文档位置。context按[来源1]、[来源2]的格式组装,一方面给模型清晰的材料边界,另一方面让引用回溯成为可能。强制拒答是医疗系统的安全底线,这不是为了面试表演,是因为在真实场景里「资料库中没有相关信息」这个回答比模型硬编一个结论要安全得多。

注意模板里写的是「陈述资料中的既有信息」,不是「根据资料回答」。这两者区别很大:前者要求模型做信息搬运工,后者给了模型发挥的空间,而发挥就意味着可能添加资料之外的内容。

4.3 结构化输出:把答案、来源、免责声明一次解析出来

生成阶段如果用纯文本输出,后续做前端展示、来源回溯、评估对齐都会很麻烦。更好的做法是要求模型输出 JSON 结构,一次性拿到答案正文、引用来源列表和是否命中资料的标志。

{ "answer": "根据资料,阿司匹林可用于高血压患者的二级预防,但需注意出血风险。", "sources": [1, 2], "has_answer": true }

这套 JSON 结构的关键是has_answer字段。当模型在检索阶段没有找到相关资料时,它应该输出has_answer: false,前端就可以直接展示「未找到相关信息」的默认提示,而不是渲染一段「资料库中未找到相关信息」的散文。这样处理,后端判断逻辑简单,前端展示也干净。

5. 医疗 RAG 的五个高频坑:现象、原因、解决

RAG 系统的坑往往不在模型而在工程细节。这一章把我做医疗问答时碰到最多的五个坑按「现象 → 原因 → 解决」列出来,每条都是血泪经验。

5.1 PDF 提取出的文字顺序错乱,切片全切错了

现象:知识库建好后,检索「高血压」召回的是「注意事项」里的杂散文字,而不是「适应症」里的内容。

原因:PDF 双栏排版的文本块坐标还原做漏了。有些 PDF 是表格混排,表格单元格的坐标和文本流乱序,标记排序后仍然有错位。

解决:预处理阶段增加表格区域检测,把表格用page.find_tables()单独提取并转换成 Markdown 表格,然后从文本流中剔除表格区域再排序。这样文本和表格分别处理,避免互相干扰。这个事没有捷径,你需要把知识库里的 PDF 按版式分类,先处理掉大头,剩下的边缘案例手工修正。

5.2 Embedding 无法识别「阿司匹林 = 乙酰水杨酸」

现象:用户问「乙酰水杨酸对胃的刺激」,检索结果里没有「阿司匹林」相关的说明书内容。

原因:通用 Embedding 模型对医学同义词的表征能力弱,字面差异大的两个术语在向量空间里距离很远。

解决:第一道兜底是混合检索,BM25 的精确匹配能解决字面一致的问题;第二道兜底是建同义词表做查询改写,把用户问题的同义词扩展后一起检索。这两层加上,同义词问题基本能覆盖绝大多数场景。不要指望 Embedding 模型自己解决这个问题,医疗术语的同义词网络太复杂。

5.3 表格数据切块后变成散行,语义全丢

现象:检索「头孢类药物的用法用量」,返回的切片是「成人一次0.5g」「儿童每日按体重」这样散落的行,没有上下文。

原因:切片逻辑按文本流切分,没有感知到「表格是一个完整语义单元」。Markdown 表格的每一行拆开都不是完整信息,必须整体保留。

解决:在切片阶段加一个规则:识别|开头和|---分隔符之间的连续行,整个表格作为一个 chunk,不允许在表格内部切分。如果表格过长超过切片长度限制,宁可按行组切(比如前 3 行一组、后 3 行一组),也要保证每组都有表头。

5.4 检索结果越多,答案质量反而越差

现象:top_k 从 5 调到 20 后,答案变得冗长且开始出现相互矛盾的内容。

原因:大模型的注意力在长上下文里会被无关信息稀释。你塞进去 20 条资料,只有 2 条有用时,模型很难分辨该信哪条。RAG 的核心不是「给更多资料」,而是「给最精确的资料」。

解决:重排序后强制收窄到 5 条以内,Prompt 里明确「只使用参考材料中的信息」。如果 5 条里有明显不相关的,那是重排序模型的问题,需要换更强的 reranker,而不是把 top_k 调大。

5.5 模型一本正经地编了一个不存在的文献

现象:问答系统给出了答案,但引用的来源编号对应的资料里根本没有那句话说。

原因:模型的幻觉在 RAG 里依然存在,尤其当指令约束不够强时。如果 Prompt 里没明确「只能使用参考材料」,模型会调用训练时见过的医学知识来「润色」答案。

解决:第一是 Prompt 约束(见第四章);第二是后端加一层校验——把模型输出的核心断言逐个和参考材料里的原文做一致性比对,常用的做法是让一个小模型对「断言-原文」对做二分类判断。这一步是医疗问答系统上生产环境的必备检查,毕设阶段至少要做人工抽查。

6. 评估与继续迭代:用 30 道题暴露 RAG 的真实短板

最后一个环节是评估。很多 RAG 项目交付后就没人管了,问题在于「感觉效果还行」不算验收标准。搭建一个最小评估集,能帮你回答两个关键问题:当前系统在哪些类型的问题上表现最好,哪些问题完全答不了。这决定了下一步优化往哪使劲。

6.1 构建评估集:三类问题的比例怎么定

评估集至少要覆盖三类问题:一是「直接抽取型」,比如「药品 X 的禁忌症是什么」,这类问题答案在知识库里是现成的,考验检索准不准;二是「多源综合型」,比如「高血压患者用阿司匹林需要注意什么」,需要从多条资料中综合信息;三是「超出知识库型」,比如问一个知识库里完全没有的新药,考验系统敢不敢拒答。

规模不用大,30 道题足矣,但每道题需要人工标注三个字段:标准答案、预期引用的来源文档、是否在知识库范围内。标注工作大概半天时间,但这半天价值极高。

def evaluate(rag_system, eval_set): results = [] for item in eval_set: answer, sources, has_answer = rag_system.answer(item["question"]) # 检索召回评估:答案引用的来源是否出现在预期来源中 retrieval_hit = any(str(exp) in sources for exp in item["expected_sources"]) # 正确性人工打分(1~5) correctness = item.get("human_score", 0) # 拒答准确率:库外问题应该回答 has_answer=false correct_reject = (item["in_kb"] == False) and (has_answer == False) results.append({ "question": item["question"], "retrieval_hit": retrieval_hit, "correctness": correctness, "correct_reject": correct_reject }) return results

这个评估函数拆开看就三个维度。检索命中评估的是「想要的资料有没有被检索出来」,这是 RAG 的地基,地基没打牢后面一切都不用谈。正确性需要人工打分,因为医疗答案对错不是简单字符串匹配能判定的。拒答准确率专门评估「不知道时敢不敢说不知道」,这是医疗系统和普通问答系统的分水岭。

6.2 Bad Case 归因:先分清楚是检索的锅还是生成的锅

评估做完,你会得到一批答错的题。这时候最重要的不是急着调参,而是先归因。方法很简单:看答错的那道题——把它的正确答案对应的资料单独检索一次,如果检索结果里没有,就是检索端的锅(切片、Embedding、重排,逐段排查);如果检索结果里有但生成的答案还是错的,就是生成端的锅(Prompt 约束不够、模型能力不足)。我见过大量团队在一个难缠的问题上反复调 Prompt,最后发现是切片把关键信息切断了,方向错了整个白干。

归因之后,处理路径完全不同:检索端问题改切片策略和检索参数;生成端问题改 Prompt 模板和模型选型。一次 Bad Case 分析至少能暴露一个具体环节的具体缺陷,比盲目调top_k有效得多。

6.3 进阶方向:HyDE、RAG-Fusion 和知识图谱的边界

如果基础 RAG 流程已经稳定,想继续往深处做,有两个方向值得投入。HyDE(Hypothetical Document Embeddings)是让大模型先为问题生成一个虚构的参考答案,再用这个答案去检索——在问题表述和知识库表述差距大的时候能显著提升召回率。RAG-Fusion 是同时用多个改写后的查询去检索再合并结果,适合用户提问口语化严重的时候。

这里要顺便回应一个检索热词里的常见困惑:RAG 知识库和 KG(知识图谱)知识库怎么选。RAG 适合非结构化的文档检索,你说不清楚问题会怎么问,但它能从文本里找语义;KG 适合实体关系查询,比如「哪些药物和 X 药物存在相互作用」,这类问题在普通 RAG 里很难通过文本相似度回答,但如果你预先建了药物相互作用图谱,就能直接查出来。医疗领域两者经常配合使用,所谓ontology RAG的思路就是把概念层级结构叠加到检索结果上,限制检索范围并过滤歧义。

6.4 一个值得投入的验证习惯:每次改动后固定跑评估集

我最后想分享一个工作习惯:任何改动(换模型、调参数、加数据)之后,固定跑一遍评估集,把三个维度的数字记录下来。这个习惯会让你很快建立直觉——什么改动真正有效,什么改动只是感觉上有效。我做医疗 RAG 项目时,最深的教训就是前期总在调 Prompt 和换模型,后来发现检索端的效果对答案质量的杠杆更大;改用评估集驱动之后,系统走向才开始变得可预期、可把控。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询