先看一个典型场景。
你基于本地知识库搭建了一个 RAG 问答系统,文档已经解析好、向量也已经存进数据库,用户随便问一句业务问题,系统却经常答非所问:要么检索出来的片段和问题完全无关,要么答案里混着好几段互相矛盾的内容,要么明明知识库里有答案,模型却回答“我不知道”。
这是很多 RAG 项目从“能跑”到“好用”之间最真实的落差。我今年在多个实际项目里踩过一遍之后,把 RAG 系统的准确率从最初的 60% 左右逐步拉到了 80% 以上。这篇文章不聊复杂的理论,只把优化链路完整拆开:为什么准确率低、怎么建立评估体系、每一步优化怎么做、完整代码怎么落地。
文章适合这几类读者:正在做知识库问答应用的开发者、想系统学习 RAG 优化方法的算法工程师、以及准备把 RAG 接入业务系统的后端同学。
1. 背景:为什么 RAG 系统“能跑但不好用”
RAG(Retrieval-Augmented Generation,检索增强生成)是目前大模型落地最常用的技术方案之一。它的核心思路并不复杂:当用户提问时,先从外部知识库中检索出与问题相关的内容片段,再把“问题 + 检索到的片段”一起交给大模型,让模型基于这些内容生成回答。
这样做的好处很明显:模型不需要记住所有知识,知识可以随时更新,回答可以溯源。因此在企业知识库问答、智能客服、文档助手、政务问答等场景里,RAG 成了主流选择。
但很多团队在 Demo 阶段很顺利,一旦进入真实业务,准确率就明显下降。常见表现有三种:
- 检索结果不相关:用户问“报销流程”,系统检索出来的却是“差旅标准”,回答自然偏离主题。
- 上下文信息不足:用户问“如何申请产品试用”,知识库里虽然存在申请入口、审核时长、试用额度等分散信息,但检索引擎只找到一个片段,模型拿到不完整素材,只能靠“脑补”回答。
- 答案冲突:知识库不同文档说法不一致(比如新旧制度并存),模型把多条冲突信息全部塞进上下文,生成结果前后矛盾。
RAG 系统的准确性,并不只取决于大模型本身。它的完整链路包括:文档解析、文本分块、向量化、检索召回、重排序、提示词构造、生成回答。任何一个环节质量不够,都会把错误传递到最终答案里。
换句话说,准确率低不是某一个环节的问题,而是一整条链路的综合结果。这也是为什么很多开发者单点优化后,效果提升却很不明显。
2. RAG 准确率低,问题通常出在哪
要系统提升准确率,得先定位瓶颈。以我在项目中的经验,90% 以上的 RAG 效果问题都集中在下面五个环节。
2.1 文档解析质量差
知识库里的原始文档可能是 PDF、Word、Markdown、HTML,甚至扫描件。PDF 如果不做版面分析,单纯按文本流提取,很容易出现段落顺序错乱、表格内容丢失、标题和正文粘连等问题。这些脏数据进入分块环节后,检索到的自然也是一堆破碎内容。
2.2 分块策略与业务不匹配
分块是 RAG 中最容易被低估的环节。块太大,单个片段包含多个主题,向量表示不聚焦,检索精度下降;块太小,上下文语义不完整,模型无法理解前因后果。固定字符数切分还会把段落从中间截断,导致语义断裂。
2.3 向量检索本身有天花板
向量检索解决的是“语义相似”问题,但它对关键词、专有名词、编号规则的匹配能力很弱。比如用户问“HT-208 设备故障代码 E03”,如果知识库里写的是“型号 HT208 报错 3”,纯向量检索很难精准命中。这其实就是 RAG 里常说的“召回不足”。
2.4 检索结果没有重排序
向量检索返回的 Top-K 结果,是按向量相似度排序的。但“向量相似”不等于“对回答有用”。前几条结果可能高度相似但信息重复,真正包含关键答案的片段反而排到了后面。如果不加重排序(Rerank),大模型拿到的上下文质量就差很多。
2.5 提示词没有约束生成
最后一步是生成。如果提示词写得太随意,模型可能忽略检索内容、直接凭自身知识回答,这就完全违背了 RAG 的初衷。提示词里必须明确“只能基于给定的资料回答,资料不足时明确说明不清楚”。
理清这些瓶颈之后,我们还需要一套方法来判断优化是否有效。接下来先讲评估体系,因为没有评估体系的优化,本质上都是碰运气。
3. 优化前先搭建评估体系:没有评测就没有优化
很多团队优化 RAG,靠的是“人工看几十条问答感觉变好了”。这种方式的问题在于主观性强、不可复现,而且无法定位哪个环节出了问题。
合理做法是准备一份有标准答案的评测集,用统一指标评估每次改动前后的效果。
3.1 评测集怎么准备
从真实业务问题中抽取 50~200 条问题,覆盖不同难易程度。每条问题至少包含:
- 问题文本;
- 标准答案或关键答案片段;
- 问题所属的类别(比如:报销、售后、技术故障、政策查询);
- 复杂程度标记(单片段可回答 / 多片段融合 / 需要推理)。
评测集不需要一开始就做得很全,但一定要覆盖真实使用场景。后续可以随着线上反馈不断补充。
3.2 两个核心评估维度
| 维度 | 说明 | 简单评估方式 |
|---|---|---|
| 检索命中率 | 检索返回的 Top-K 片段中,是否包含能回答问题的关键片段 | 人工判断或 LLM 判断,计算命中比例 |
| 最终答案正确率 | 模型基于检索内容生成的答案,与标准答案相比是否正确 | 人工打分或 LLM 打分,分 0/1 或 0~5 分 |
其中检索命中率更值得优先关注。如果检索命中率不高,把大模型换成更强的版本也很难救回来;如果检索已经命中,但答案仍然不对,问题则更多出在提示词或模型能力上。
3.3 用脚本批量评测
实际项目中我习惯把评测集写成 JSON 文件,用自动化脚本跑完整链路,输出每个问题的检索结果和生成答案,再统一打分。
[ { "question": "员工报销差旅费需要哪些材料?", "reference": "报销差旅费需要提供发票、行程单和审批单", "category": "报销", "difficulty": "single" }, { "question": "如果同时申请试用和购买,价格怎么计算?", "reference": "试用期间免费,购买按正式报价计算", "category": "售前", "difficulty": "multi" } ]有了这个评测文件,后面每做一个改动,都可以直接对比“改动前 vs 改动后”的准确率。推荐的优化顺序是:先修解析和分块,再换/调 Embedding,然后加检索策略,接着加重排序,最后优化提示词。下面按这个顺序逐步拆解。
4. 方法一:文档解析与分块策略优化
4.1 文档解析:先结构化,再向量化
原始文档不能直接“一股脑”塞给分块器。对于 PDF,建议先做版面分析,识别标题、段落、表格、页眉页脚,再按阅读顺序提取文本。对于 Word 文档,尽量利用标题样式和段落结构。对于 HTML,先去除导航、广告、脚本等无关内容,再提取正文。
这一步的意义是:只有拿到结构清晰的正文文本,分块才不会截断语义。
4.2 分块策略:不要只用固定长度
固定字符分块虽然简单,但对中文场景效果并不稳定。中文没有天然空格,固定 500 字切分很容易把一句话、一个表格或者一条完整流程从中间切断。
我比较推荐“结构化优先”的分块思路:
- 优先按文档的标题层级切分(Markdown 标题、Word 标题样式);
- 每一级标题下的内容作为一个候选块;
- 如果候选块太长,再按段落或者句子边界二次切分;
- 如果候选块太短,则与相邻内容合并,避免单块信息量不足。
这里给一个基于标题和段落的分块示例:
from typing import List def split_by_headings_and_paragraphs(text: str, max_chunk_size: int = 800) -> List[str]: """ 按标题层级和段落边界进行分块。 简单实现:以 ### 或 ## 为第一层切分点,然后在块内按空行切分段落。 """ import re # 按标题切分(这里以 ## 和 ### 为例) sections = re.split(r"(?m)^(#{2,3})\s+", text) chunks: List[str] = [] for i in range(1, len(sections), 2): heading = sections[i] body = sections[i + 1] if i + 1 < len(sections) else "" current_chunk = f"{heading}{body}".strip() # 如果整个小节仍然过长,再按段落切分 if len(current_chunk) <= max_chunk_size: chunks.append(current_chunk) continue paragraphs = re.split(r"\n\s*\n", body) buffer = "" for para in paragraphs: para = para.strip() if not para: continue if len(buffer) + len(para) <= max_chunk_size: buffer += f"\n\n{para}" else: if buffer: chunks.append(f"{heading}{buffer}".strip()) buffer = para if buffer: chunks.append(f"{heading}{buffer}".strip()) return chunks4.3 分块参数怎么定
分块大小没有统一最优值,取决于知识库文档类型和模型上下文窗口。按照经验可以从以下参数试起:
- 通用文档:400~600 字;
- 问答对类型的知识库:200~300 字;
- 长文报告:800~1200 字,并且需要带标题。
关键原则是:一块只讲一个主题,且首尾语义完整。另外建议在分块时把标题信息嵌入到块内容前部,让向量能感知到上下文主题。比如原始块内容是“报销标准:普通员工每天 150 元”,加上标题后变为“差旅费报销制度 > 报销标准:普通员工每天 150 元”,检索效果往往会有明显提升。
5. 方法二:Embedding 模型选择与向量检索优化
5.1 Embedding 模型决定语义理解上限
Embedding(文本向量化)模型负责把文本转换成向量,是向量检索效果的核心。不同模型的语义理解能力差异很大,尤其对中文长文本、专业术语、同义改写等场景。
选择 Embedding 模型时的几个判断维度:
- 中文效果:优先测试中文语料上的表现;
- 支持的最大输入长度:比如 512 还是 8000 token;
- 向量维度:会影响存储与检索性能;
- 是否支持领域微调:垂直场景下可微调会比通用模型更准。
在实际项目中,建议准备一组“检索命中率”评测集,用同一个测试集跑两三个候选模型,直接对比命中率,而不是只看网上口碑。
5.2 向量检索参数注意事项
向量检索时,Top-K 的选择也很关键。K 太小容易漏掉关键信息,K 太大又可能让不相干内容占据上下文。常见设置是召回 Top-20~50,经过重排序后再取 Top-3~5 给大模型。千万不能把 Top-K 直接当成最终给模型的片段数量。
另外要关注相似度阈值。如果检索结果整体相似度都很低,说明知识库里可能根本没有相关内容。此时与其强行生成,不如让系统返回“当前知识库暂未覆盖该问题”。
下面是一个使用 FAISS 做向量存储和检索的最小示例(仅演示核心逻辑):
import numpy as np import faiss # 假设 vectors 是已经计算好的文本向量 vectors = np.random.rand(1000, 384).astype("float32") # 建立索引 index = faiss.IndexFlatIP(384) # 内积相似度,使用归一化向量等价于余弦相似度 faiss.normalize_L2(vectors) index.add(vectors) # 检索:query_vector 也需归一化 query_vector = np.random.rand(1, 384).astype("float32") faiss.normalize_L2(query_vector) scores, ids = index.search(query_vector, k=20) print("Top-20 相似度分数:", scores) print("Top-20 文档编号:", ids)5.3 关键词召回与向量召回结合
纯向量检索对专有名词、编号、缩写不敏感。实际系统中比较推荐“向量召回 + 关键词召回”的多路召回方式:
- 向量召回:适合语义相近但表达不同的情况;
- BM25/Elasticsearch 关键词召回:适合精确匹配型号、人名、编号的情况;
- 两路结果合并后,用重排序模型统一打分。
多路召回会增加工程复杂度,但能明显提高检索命中率。尤其是知识库中包含大量产品型号、工单编号、法规条款时,关键词召回几乎是刚需。
6. 方法三:查询改写与多路召回
用户的问题往往不是“最适合检索的 query”。比如用户问“我要报打车费走什么流程”,直接拿整句话去做向量检索,效果不一定好。把问题改写成更利于检索的形式,是提升召回率性价比很高的一步。
6.1 查询改写方式
常见的改写策略包括:
- 去口语化:将“我想问一下咱们公司报销打车费是咋弄的?”改写成“公司打车费报销流程是什么?”;
- 提取关键词:把问题中的实体拆出来,比如“HT-208 报错 E03”作为检索关键词;
- 生成子问题:把复杂问题拆成多个简单子问题,分别检索,再合并结果;
- 补充上下文:多轮对话场景下,把指代信息补充完整。比如用户说“那这个能报多少钱”,改写为“打车费报销能报多少钱”。
下面是一个简单的查询改写示例,思路是通过大模型生成检索用关键词,同时保留原始问题:
def rewrite_query(original_query: str, llm_call) -> str: """ 简单查询改写:让大模型输出更适合检索的关键词。 llm_call 可以是 OpenAI/本地模型的调用封装,这里只演示思路。 """ prompt = f"""你是一个检索词改写助手。 请把用户的问题改写成适合搜索引擎和向量库检索的关键词形式,尽量保留专有名词和编号。 只输出改写结果,不要输出解释。 用户问题:{original_query} 改写结果:""" return llm_call(prompt).strip()6.2 多轮对话中的改写
如果你的 RAG 系统支持多轮对话,建议先做指代消解。例如:
- 用户问:“试用期是多久?”
- 用户追问:“那收费呢?”
如果不做改写,第二问单独检索会丢失“试用”这个主题。正确做法是把对话历史拼进改写 Prompt,让模型把指代补全成“试用期收费吗”再去检索。
6.3 混合检索实现思路
在代码层面,可以先把关键词召回和向量召回的文档 ID 合并去重,再进入重排序阶段。这里需要注意权重问题:向量分数和关键词分数量纲不同,不能直接相加,通常需要先各自归一化,或者直接把合并结果交给重排序模型处理。
7. 方法四:重排序与 Prompt 构造优化
7.1 为什么需要重排序
向量检索阶段追求的是“不要漏”,所以候选集可以放宽一些;但大模型上下文有限,不能把几十个片段都塞进去。重排序模型的作用,是对候选片段进行精细打分,把真正能回答问题的片段排到前面。
目前比较常见的做法是使用专门训练的 Cross-Encoder 重排序模型。和向量检索的双塔架构不同,Cross-Encoder 会把“问题 + 文档片段”拼在一起输入模型,交互更深,排序效果通常更好,但速度相对较慢。因此重排序一般只作用于向量召回后的几十条候选集,不会对全库执行。
7.2 重排序后的片段选择
重排序返回后,建议先做一个简单的规则过滤:
- 去掉与问题完全无关的片段;
- 去掉互相重复的片段;
- 优先保留包含具体数据、编号、时间、金额等事实信息的片段;
- 如果发现多个片段互相矛盾,可以在 Prompt 中提示模型注意版本差异。
7.3 Prompt 构造:最后一道护栏
即使检索结果已经很准,Prompt 写得不好也会前功尽弃。推荐的核心要点:
- 明确告诉模型“只能使用给定资料回答”;
- 要求模型在资料不足时,直接说“根据当前知识库无法回答”;
- 要求模型回答时标注来源片段编号;
- 如果资料存在冲突,让模型如实说明“不同资料说法不一致”。
下面是一个比较通用的 RAG Prompt 模板:
你是一个企业知识库问答助手。请根据以下资料回答用户问题。 要求: 1. 只能使用资料中的信息回答,不要使用你自身记忆中的内容。 2. 如果资料中没有足够信息,请回答“根据当前知识库无法回答该问题”。 3. 回答时在句末标注来源片段编号,例如 [1][2]。 4. 如果不同资料存在冲突,请分别列出不同说法,并注明对应来源。 资料: [1] 差旅费报销制度:普通员工每天住宿标准150元。 [2] 差旅费报销流程:报销需提供发票、行程单及审批单。 用户问题:员工出差住宿能报销多少钱?模型输出示例:
根据差旅费报销制度,普通员工每天住宿标准为150元 [1]。报销时需提供发票、行程单及审批单 [2]。这种 Prompt 的好处是:既约束了模型不乱编,也方便用户验证答案来源,后续排查 RAG 问题也会容易很多。
8. 实战:完整项目示例
下面把前面讲的优化思路落成一个可运行的完整示例。这里使用 Python + FAISS + 本地/远程 Embedding 的方式,重点演示检索链路。
注意:示例依赖版本需要根据你的环境调整。本文以常见实现为参考,重点演示方案思路,不是固定教程模板。
8.1 项目结构与依赖
rag_demo/ ├── data/ │ └── knowledge.md ├── splitter.py ├── embedder.py ├── retriever.py ├── generator.py └── main.py依赖建议:
pip install numpy faiss-cpu openai如果使用本地 Embedding,还可以安装 sentence-transformers:
pip install sentence-transformers8.2 文档加载与分块
这里直接用 Markdown 文档来演示。
# 差旅费报销制度 ## 住宿标准 普通员工每天住宿标准为 150 元,部门经理每天 300 元。 ## 交通标准 市内交通凭票报销,长途交通需提前审批。 # 报销流程 ## 提交材料 报销差旅费需要提供发票、行程单和审批单。 ## 审批时限 审批通常需要 3 个工作日。加载与分块代码如下:
# splitter.py from typing import List def load_markdown(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() def split_by_headings(text: str) -> List[str]: lines = text.splitlines() chunks = [] current_heading = "" current_lines = [] for line in lines: if line.startswith("#"): if current_lines: chunks.append(f"{current_heading}\n" + "\n".join(current_lines).strip()) current_heading = line.lstrip("#").strip() current_lines = [] else: if line.strip(): current_lines.append(line.strip()) if current_lines: chunks.append(f"{current_heading}\n" + "\n".join(current_lines).strip()) return [c for c in chunks if c.strip()]8.3 向量化与存储
# embedder.py import numpy as np import faiss from sentence_transformers import SentenceTransformer model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") def embed_texts(texts): return model.encode(texts, normalize_embeddings=True) def build_index(texts): vectors = embed_texts(texts).astype("float32") index = faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors) return index8.4 完整检索与生成
# main.py import numpy as np from embedder import model from retriever import retrieve_top_k from generator import generate_answer # 1. 加载知识库并分块 from splitter import load_markdown, split_by_headings text = load_markdown("data/knowledge.md") chunks = split_by_headings(text) # 2. 建立索引 vectors = model.encode(chunks, normalize_embeddings=True).astype("float32") import faiss index = faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors)检索函数:
# retriever.py import numpy as np def retrieve_top_k(query, chunks, index, model, k=3): query_vec = model.encode([query], normalize_embeddings=True).astype("float32") scores, ids = index.search(query_vec, k=k) results = [] for score, doc_id in zip(scores[0], ids[0]): results.append({ "score": float(score), "chunk": chunks[doc_id] }) return results生成函数(示意,需要替换为你实际的模型调用):
# generator.py def generate_answer(prompt: str, llm_call) -> str: return llm_call(prompt)8.5 运行验证
在 main.py 里传入一个问题:
query = "员工出差住宿能报销多少钱?" results = retrieve_top_k(query, chunks, index, model, k=3) print("检索结果:") for r in results: print(r["score"], r["chunk"])预期检索结果中会包含“住宿标准”对应的分块。最终生成的答案,应当基于这些分块内容给出,而不是模型自行编造。
如果检索结果不理想,优先检查分块是否完整、Embedding 模型对领域语料是否适配、是否需要加入 BM25 关键词召回。
9. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 检索到的内容明显不相关 | 分块粒度过大或过小,语义被截断 | 按标题/段落分块,调整块大小,增加重叠 |
| 专有名词、编号检索不到 | 纯向量检索对精确匹配较弱 | 增加 BM25 关键词召回,做多路召回 |
| 相似度分数整体偏低 | Embedding 模型能力不足或领域不适配 | 更换大模型/领域模型,或对 Embedding 做微调 |
| 检索结果对但答案错误 | Prompt 没有约束、模型忽略资料 | 强化 Prompt 指令,要求标注来源,并限制只能基于资料回答 |
| 相同问题每次答案不稳定 | 模型采样参数过高、Prompt 不够明确 | 降低 temperature,固定 Prompt,并做评测对比 |
| 知识库文档更新后效果变差 | 没有重建索引或旧文档残留 | 建立版本管理,更新后重新向量化并清理旧索引 |
| 多轮对话第二问检索不到 | 未做指代消解和查询改写 | 对第二问做上下文补全后再检索 |
排查时建议按“数据 → 分块 → Embedding → 检索 → 重排序 → Prompt → 生成”的顺序逐层检查,而不是一上来就换大模型。
10. 工程落地的几点建议
10.1 建立知识库版本管理
知识库不是静态文件,企业文档会不断更新。建议给每个文档加版本号,向量库按版本重建。否则旧文档内容残留会导致新旧制度同时命中,回答冲突。
10.2 监控召回质量而非只看最终答案
很多团队只盯着最终回答效果,发现问题后很难定位。建议在检索链路里打印每个问题的 Top-K 结果,把“检索是否命中关键片段”当作一个独立指标来监控。可以通过日志或离线评估任务来实现。
10.3 注意生成模型幻觉风险
RAG 本身不能彻底消除大模型幻觉,只能降低幻觉概率。即使优化后准确率到了 80% 以上,仍要保留“无法回答”的兜底路径。在金融、医疗、法律等高风险场景,建议对关键答案增加人工审核或答案规则校验环节,同时做好权限控制与操作审计,遵守最小权限原则。
10.4 不要忽略成本与延迟
多路召回、重排序、长上下文都会增加延迟和成本。上线前要做压测,明确每条链路的耗时预算。如果用户对实时性要求高,可以只对高置信度问题启用全链路优化,其他问题走简化链路。
10.5 从 Agentic RAG 看下一步
当单轮 RAG 的效果稳定后,可以进一步尝试 Agentic RAG 方向:让大模型根据问题自动选择检索工具、判断是否需要二次检索、多步推理后再回答。这种方案在复杂问题、跨文档融合问题上表现更好,但工程复杂度也更高。建议先从稳定的单轮 RAG 开始,再逐步引入 Agent 能力。
11. 总结
RAG 准确率从 60% 提升到 80% 以上,并不是靠某一个“神技”实现的,而是对整条链路的持续打磨:先建立评测集,再按照“文档清洗 → 结构分块 → Embedding 选择 → 多路召回 → 重排序 → Prompt 约束”的顺序逐项优化。
如果你现在正在做一个效果不理想的 RAG 项目,建议不要急着换大模型。先把 20~50 条典型问题整理成评测集,跑一遍当前链路,定位是“检索不到”还是“生不成”。大部分情况下,问题都出在检索侧,而不是生成侧。
最后给你一个实操建议:每周抽一点时间回看系统回答较差的案例,把新问题补充进评测集。RAG 系统不是一次上线就结束的,它需要依靠真实反馈持续迭代。只要评估集在增长,系统的准确率就还有提升空间。
如果这篇文章对你有帮助,可以收藏备用。你在 RAG 优化过程中遇到过哪些“奇怪问题”?欢迎在评论区分享,一起交流排坑经验。