RAG系统准确率从60%到80%:完整优化链路与代码实践
2026/9/8 12:54:28 网站建设 项目流程

先看一个典型场景。

你基于本地知识库搭建了一个 RAG 问答系统,文档已经解析好、向量也已经存进数据库,用户随便问一句业务问题,系统却经常答非所问:要么检索出来的片段和问题完全无关,要么答案里混着好几段互相矛盾的内容,要么明明知识库里有答案,模型却回答“我不知道”。

这是很多 RAG 项目从“能跑”到“好用”之间最真实的落差。我今年在多个实际项目里踩过一遍之后,把 RAG 系统的准确率从最初的 60% 左右逐步拉到了 80% 以上。这篇文章不聊复杂的理论,只把优化链路完整拆开:为什么准确率低、怎么建立评估体系、每一步优化怎么做、完整代码怎么落地。

文章适合这几类读者:正在做知识库问答应用的开发者、想系统学习 RAG 优化方法的算法工程师、以及准备把 RAG 接入业务系统的后端同学。

1. 背景:为什么 RAG 系统“能跑但不好用”

RAG(Retrieval-Augmented Generation,检索增强生成)是目前大模型落地最常用的技术方案之一。它的核心思路并不复杂:当用户提问时,先从外部知识库中检索出与问题相关的内容片段,再把“问题 + 检索到的片段”一起交给大模型,让模型基于这些内容生成回答。

这样做的好处很明显:模型不需要记住所有知识,知识可以随时更新,回答可以溯源。因此在企业知识库问答、智能客服、文档助手、政务问答等场景里,RAG 成了主流选择。

但很多团队在 Demo 阶段很顺利,一旦进入真实业务,准确率就明显下降。常见表现有三种:

  1. 检索结果不相关:用户问“报销流程”,系统检索出来的却是“差旅标准”,回答自然偏离主题。
  2. 上下文信息不足:用户问“如何申请产品试用”,知识库里虽然存在申请入口、审核时长、试用额度等分散信息,但检索引擎只找到一个片段,模型拿到不完整素材,只能靠“脑补”回答。
  3. 答案冲突:知识库不同文档说法不一致(比如新旧制度并存),模型把多条冲突信息全部塞进上下文,生成结果前后矛盾。

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 chunks

4.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-transformers

8.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 index

8.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 优化过程中遇到过哪些“奇怪问题”?欢迎在评论区分享,一起交流排坑经验。

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

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

立即咨询