本文基于一个完整的 RAG 系统(nano-rag)的开发经验,系统梳理 RAG 检索环节最常见的 5 种失败模式,并为每种模式给出针对性的优化方案。
前置知识:了解 LLM 基本概念,知道 Embedding 是什么。
简述
RAG(Retrieval-Augmented Generation)系统的质量取决于整条链路中最弱的一环。在文档解析 → 分块 → 检索 → 重排 → 生成这条链路中,检索环节是最容易出问题的。本文总结 5 种典型的检索失败模式——语义鸿沟、分块截断、噪声淹没、多跳推理、时效性错乱——并为每种模式给出可落地的优化方案。
0. RAG 全链路回顾
在讨论失败模式之前,先统一 RAG 的标准流程:
用户提问
↓
Query 处理(改写/扩展)
↓
检索(BM25 + Dense + RRF 融合)
↓
重排(Cross-Encoder Reranker)
↓
生成(LLM + 检索到的上下文)
↓
输出答案
| 环节 | 核心组件 | 作用 |
|---|---|---|
| 文档解析 + 分块 | PDF/Markdown 解析器、分块策略 | 将长文档切成适合检索的小块 |
| 检索 | BM25(稀疏)+ Embedding 模型(稠密) | 从知识库中找出相关文档片段 |
| 融合 | RRF(Reciprocal Rank Fusion) | 合并多路检索结果 |
| 重排 | Cross-Encoder | 对候选文档精排,提升相关性 |
| 生成 | LLM(如 DeepSeek、GPT-4) | 根据检索到的上下文生成答案 |
核心组件简介
BM25(稀疏检索):基于关键词匹配的经典算法。对问题中的每个词,计算它在文档中的 TF-IDF 权重,加权求和得到相关性分数。优势是精确匹配关键词(对专有名词、人名特别准),劣势是不理解同义词和语义改写。
Dense Retrieval(稠密检索):用 Embedding 模型将问题和文档编码为高维向量,通过余弦相似度衡量语义相关性。优势是理解语义("校长" ≈ "最高行政负责人"),劣势是对专有名词不敏感。
RRF(Reciprocal Rank Fusion):按排名融合多路检索结果,不依赖原始分数:
$$\text{RRF\_score}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)}$$
其中 $k$ 通常取 60,$\text{rank}_i(d)$ 是文档 $d$ 在第 $i$ 路检索中的排名。两路检索中排名都高的文档,RRF 分数也高。
Cross-Encoder Reranker:将问题和文档拼接后一起送入 Transformer,输出相关性分数。比 Bi-Encoder(Dense 检索)更准,但计算量大,适合对 Top-K 候选做精排。
1. 失败模式 1:语义鸿沟(Semantic Gap)
现象
用户的问题用词和知识库文档的用词不同,但语义相同或相近,导致检索不到正确的文档。
用户问:"怎么缓解头疼"
知识库中:
Chunk A: "偏头痛的治疗方法包括布洛芬和对乙酰氨基酚" ← 应该被检索到
Chunk B: "头疼的常见原因有睡眠不足、压力过大..." ← 也能匹配
BM25 结果:
Chunk A: 未命中 → "头疼" 和 "偏头痛" 是不同的词
Chunk B: 命中 → 包含 "头疼"
Dense 结果:
取决于 Embedding 模型质量
好的模型能识别 "头疼" ≈ "偏头痛"
差的模型可能也无法匹配
根本原因
BM25 是纯词面匹配,不具备语义理解能力。Dense Retrieval 虽然有语义理解能力,但受限于 Embedding 模型的质量。用户的提问方式和文档的表述方式之间的差异,形成了一道"语义鸿沟"。
对症优化
方案 A:Query 改写与扩展
用 LLM 将用户问题改写为多个语义等价但用词不同的版本:
def expand_query(query: str, llm) -> list[str]:
"""用 LLM 生成多个改写版本"""
prompt = f"""请将以下问题改写为 3 个语义相同但用词不同的版本:
原问题:{query}
要求:
- 使用不同的同义词和表达方式
- 保持原意不变
- 每行一个改写版本"""
response = llm.generate(prompt)
return [query] + response.strip().split("\n")
# 使用示例
queries = expand_query("怎么缓解头疼")
# → ["怎么缓解头疼",
# "头疼怎么办",
# "头痛的治疗方法",
# "偏头痛的缓解措施"]
# 用多个查询分别检索,合并结果
all_results = []
for q in queries:
all_results.extend(search(q, top_k=5))
final_results = rrf_fusion(all_results)
方案 B:选择更好的 Embedding 模型
Embedding 模型的语义理解能力直接决定了 Dense Retrieval 的效果。在 MTEB(Massive Text Embedding Benchmark)上排名靠前的模型通常表现更好:
| 模型 | 维度 | 特点 |
|---|---|---|
| bge-large-zh-v1.5 | 1024 | 中文效果好,开源 |
| gte-large-zh | 1024 | 社区贡献,中文优化 |
| text-embedding-3-large | 3072 | OpenAI,多语言 |
| jina-embeddings-v3 | 1024 | 多语言,支持任务类型 |
方案 C:混合检索
BM25 + Dense 至少有一路能命中。BM25 负责精确匹配,Dense 负责语义匹配,用 RRF 融合结果。这是最通用也最稳健的方案。
2. 失败模式 2:分块截断(Chunk Truncation)
现象
关键信息被分块策略切断,分散在两个相邻的 chunk 中,导致检索到的 chunk 缺少必要上下文。
原始文档:
"厦门大学现任校长为张荣,他于 2023 年就任。
学校设有 30 个学院,涵盖文、理、工、商等学科门类。"
固定大小分块后(在句号处切断):
Chunk 1: "厦门大学现任校长为张荣,他于 2023 年就任。"
Chunk 2: "学校设有 30 个学院,涵盖文、理、工、商等学科门类。"
用户问:"厦门大学有几个学院?"
→ Chunk 2 包含答案 "30 个学院"
→ 但 Chunk 2 中没有 "厦门大学" 这个关键词
→ BM25 可能检索不到
→ Dense 也可能因为缺少上下文而排名靠后
根本原因
固定大小的分块策略(Fixed-size Chunking)按字符数切分,不考虑语义完整性。一个完整的论述被切成两半后,每个 chunk 独立来看都缺少上下文。
对症优化
方案 A:父子分块(Parent-Child Chunking)
class ParentChildChunker:
def __init__(self, parent_size=2048, child_size=256, overlap=50):
self.parent_size = parent_size
self.child_size = child_size
self.overlap = overlap
def chunk(self, text: str) -> list[dict]:
# 1. 切大 chunk(parent)
parents = split_by_size(text, self.parent_size)
chunks = []
for i, parent in enumerate(parents):
# 2. 每个 parent 切成小 chunk(child)
children = split_by_size(parent, self.child_size, self.overlap)
for j, child in enumerate(children):
chunks.append({
"content": child, # 检索用 child(粒度细)
"parent": parent, # 返回时用 parent(上下文完整)
"parent_id": i,
"child_id": f"{i}_{j}",
})
return chunks
检索时用 child chunk 做匹配(粒度细,命中率高),命中后返回对应的 parent chunk 给 LLM(上下文完整)。
方案 B:语义分块(Semantic Chunking)
按文档的自然结构切分——段落、标题、章节边界:
def semantic_chunk(markdown_text: str) -> list[str]:
"""按 Markdown 标题和段落切分"""
chunks = []
current_section = ""
for line in markdown_text.split("\n"):
if line.startswith("#"):
# 遇到新标题,保存当前 section
if current_section:
chunks.append(current_section)
current_section = line + "\n"
else:
current_section += line + "\n"
if current_section:
chunks.append(current_section)
return chunks
方案 C:Chunk 重叠(Overlap)
相邻 chunk 之间重叠一部分字符,减少边界信息丢失:
无重叠:
Chunk 1: [0:512]
Chunk 2: [512:1024]
Chunk 3: [1024:1536]
→ 512 和 1024 处的信息可能被切断
有重叠(overlap = 50):
Chunk 1: [0:512]
Chunk 2: [462:974] ← 和 Chunk 1 重叠 50 字符
Chunk 3: [924:1436] ← 和 Chunk 2 重叠 50 字符
→ 边界处的信息在两个 chunk 中都出现
3. 失败模式 3:噪声淹没(Noise Flooding)
现象
检索返回了大量"看起来相关但实际没用"的 chunk,正确答案被淹没在噪声中。
用户问:"厦门大学 2024 年福建省录取分数线"
Top-5 检索结果:
1. "厦门大学是中国最美大学之一,校园依山傍海..."
→ 介绍性内容,没有分数线 ❌
2. "厦门大学 2023 年福建省录取分数线:理科 635 分..."
→ 去年的数据,不是 2024 年 ❌
3. "福建省 2024 年高考本一线公布:理科 530 分..."
→ 福建省的分数线,不是厦大的 ❌
4. "厦门大学招生办主任做客直播间,解读招生政策..."
→ 新闻稿,没有具体分数线 ❌
5. "厦门大学 2024 年各省录取分数线汇总..."
→ 这个才是正确答案 ✅(但被排到第 5 位)
根本原因
检索器的目标是找"相关"的文档,而不是找"准确"的文档。上面的 5 个 chunk 都和"厦门大学 + 分数线"有关键词重叠,检索器无法区分"有分数线的文档"和"只是提到厦门大学的文档"。
对症优化
方案 A:Cross-Encoder Reranker 重排
Reranker 将问题和文档拼接后联合编码,能捕捉更细粒度的相关性:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("bge-reranker-v2-m3")
# 初始检索 Top-20
candidates = search(query, top_k=20)
# 构造 (query, document) 对
pairs = [(query, c["content"]) for c in candidates]
# 重新打分
scores = reranker.predict(pairs)
# 按新分数排序,取 Top-5
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
final_results = [r[0] for r in ranked[:5]]
# 正确答案从第 5 位 → 第 1 位
方案 B:相似度阈值过滤
设置最低相似度阈值,低于阈值的直接丢弃:
def search_with_threshold(query: str, threshold: float = 0.7) -> list:
results = search(query, top_k=20)
# 过滤掉相似度低于阈值的 chunk
return [r for r in results if r["score"] >= threshold]
方案 C:Metadata 过滤
在检索前用结构化条件缩小范围:
# ChromaDB 支持 metadata 过滤
results = collection.query(
query_embeddings=[query_embedding],
where={
"$and": [
{"year": {"$eq": 2024}},
{"province": {"$eq": "福建"}},
]
},
n_results=5
)
4. 失败模式 4:多跳推理(Multi-hop Failure)
现象
用户的问题需要跨多个文档的推理才能回答,但单次检索无法找到同时包含所有信息的 chunk。
用户问:"厦门大学创办人的家乡在哪里?"
需要两步推理:
Step 1: 厦门大学创办人是谁? → 陈嘉庚(Chunk A)
Step 2: 陈嘉庚的家乡在哪里? → 福建集美(Chunk B)
单次检索 "厦门大学创办人的家乡在哪里":
→ 没有 chunk 同时包含 "厦门大学" + "创办人" + "家乡"
→ 检索失败
根本原因
答案信息分散在多个 chunk 中,需要跨 chunk 的链式推理。单次检索只能找到与问题字面相关的 chunk,无法完成多步推理。
对症优化
方案 A:Query 分解(Query Decomposition)
用 LLM 将复杂问题拆解为多个子问题,依次检索:
def decompose_and_search(query: str, llm, search_fn) -> str:
"""将多跳问题分解为子问题,逐步检索"""
# Step 1: 用 LLM 分解问题
sub_queries = llm.generate(f"""将以下问题分解为多个子问题:
问题:{query}
输出格式(每行一个子问题):
子问题 1: ...
子问题 2: ...""")
# Step 2: 依次检索子问题
context = ""
for sub_q in sub_queries:
results = search_fn(sub_q, top_k=3)
context += "\n".join([r["content"] for r in results])
# Step 3: 用 LLM 综合所有检索结果回答原问题
answer = llm.generate(f"""根据以下资料回答问题:
{context}
问题:{query}""")
return answer
# 执行过程:
# 子问题 1: "厦门大学的创办人是谁" → 检索 → 得到 "陈嘉庚"
# 子问题 2: "陈嘉庚的家乡在哪里" → 检索 → 得到 "福建集美"
# 综合回答: "厦门大学创办人陈嘉庚的家乡是福建集美。"
方案 B:迭代检索(Iterative Retrieval)
每一轮检索后,根据已获得的信息构造新的查询,直到收集到足够的信息:
def iterative_search(query: str, llm, search_fn, max_rounds=3) -> str:
context = ""
for i in range(max_rounds):
# 根据已有上下文生成新的查询
if context:
new_query = llm.generate(f"""已有信息:{context}
原始问题:{query}
为了回答原始问题,还需要查找什么信息?
输出一个搜索查询:""")
else:
new_query = query
# 检索
results = search_fn(new_query, top_k=3)
context += "\n".join([r["content"] for r in results])
# 检查信息是否足够
sufficient = llm.generate(f"""已有信息:{context}
问题:{query}
这些信息足够回答问题吗?回答"是"或"否":""")
if "是" in sufficient:
break
return llm.generate(f"根据以下资料回答问题:\n{context}\n问题:{query}")
5. 失败模式 5:时效性错乱(Temporal Mismatch)
现象
知识库中同时存在不同时间段的文档,检索器无法区分新旧信息,可能返回过时的答案。
用户问:"现任美国总统是谁?"
知识库:
Chunk A (2020): "唐纳德·特朗普是美国第 45 任总统,于 2017 年就任。"
Chunk B (2025): "唐纳德·特朗普是美国第 47 任总统,于 2025 年就任。"
Chunk C (2023): "乔·拜登是美国第 46 任总统,于 2021 年就任。"
检索结果:
三个 chunk 都和 "美国总统" 高度相关
BM25 和 Dense 都可能返回全部三个
LLM 看到矛盾的信息,可能给出错误答案
根本原因
检索器只衡量"相关性",不理解"时效性"。多个时间段的文档可能都和查询高度相关,但只有最新的信息才是正确的答案。
对症优化
方案 A:时间戳标注 + 过滤
在文档入库时记录时间元数据,检索时按时间过滤或加权:
# 入库时标注时间
collection.add(
documents=[chunk_text],
metadatas=[{
"source": "wikipedia",
"created_at": "2025-01-20",
"is_latest": True,
}],
ids=[chunk_id]
)
# 检索时优先返回最新文档
results = collection.query(
query_embeddings=[query_embedding],
where={"is_latest": True},
n_results=5
)
方案 B:去重策略
对内容相似的 chunk 做去重,只保留最新版本:
from difflib import SequenceMatcher
def deduplicate_by_time(chunks: list[dict]) -> list[dict]:
"""对相似 chunk 去重,保留最新的"""
keep = []
for chunk in sorted(chunks, key=lambda x: x["created_at"], reverse=True):
is_duplicate = False
for kept in keep:
similarity = SequenceMatcher(
None, chunk["content"], kept["content"]
).ratio()
if similarity > 0.8: # 相似度 > 80% 视为重复
is_duplicate = True
break
if not is_duplicate:
keep.append(chunk)
return keep
方案 C:Prompt 中强调时效性
在生成阶段通过 Prompt 引导 LLM 优先使用最新信息:
Prompt:
"根据以下资料回答问题。
注意:资料中可能包含不同时间段的信息,
请优先使用最新的资料回答。
[Chunk A - 2020年] ...
[Chunk B - 2025年] ...
[Chunk C - 2023年] ...
问题:现任美国总统是谁?"
优化效果对比
基于 nano-rag 系统的实测数据(知识库:技术文档 + 百科数据,共 500+ 文档):
| 优化手段 | 解决的问题 | Top-5 召回率提升 | 实现难度 |
|---|---|---|---|
| 混合检索(BM25 + Dense + RRF) | 语义鸿沟 | 显著提升 | 中 |
| 父子分块(Parent-Child) | 分块截断 | 中等提升 | 低 |
| Cross-Encoder Reranker | 噪声淹没 | 显著提升 | 低 |
| Query 分解 | 多跳推理 | 显著提升(多跳场景) | 中 |
| 时间戳过滤 | 时效性错乱 | 场景依赖 | 低 |
工程实践建议
推荐的 RAG 技术栈
# 最小可行 RAG 系统
技术栈:
向量数据库:ChromaDB(本地)或 Milvus(分布式)
Embedding:bge-large-zh-v1.5 或 gte-large-zh
Reranker:bge-reranker-v2-m3
LLM:DeepSeek API 或本地部署 Qwen3
异步框架:aiohttp(并发请求加速)
分步优化路线
Phase 1(MVP):
固定大小分块 + Dense 检索 + LLM 生成
→ 先跑通,再优化
Phase 2(提升检索质量):
+ BM25 + RRF 混合检索
+ 父子分块
→ 解决语义鸿沟和分块截断
Phase 3(精排和过滤):
+ Cross-Encoder Reranker
+ Metadata 过滤(时间戳、来源等)
→ 解决噪声淹没和时效性
Phase 4(高级能力):
+ Query 改写/分解
+ 迭代检索
→ 解决多跳推理
关键指标
| 指标 | 含义 | 怎么测 |
|---|---|---|
| Recall@K | Top-K 中包含正确答案的比例 | 构建评测集,计算命中率 |
| MRR | 正确答案的平均排名倒数 | 评估排序质量 |
| 端到端准确率 | LLM 最终回答的正确率 | 人工评测或 LLM-as-Judge |
| 检索延迟 | 从提问到检索完成的时间 | 性能监控 |
总结
RAG 系统的 5 种检索失败模式及对症优化:
| 失败模式 | 一句话总结 | 首选方案 |
|---|---|---|
| 语义鸿沟 | 用词不同但语义相同 | 混合检索 + Query 改写 |
| 分块截断 | 关键信息被切断 | 父子分块 |
| 噪声淹没 | 相关但不精准的太多 | Cross-Encoder Reranker |
| 多跳推理 | 需要跨文档链式推理 | Query 分解 + 迭代检索 |
| 时效性错乱 | 过时信息干扰答案 | 时间戳过滤 + 去重 |
核心原则:先跑通最小系统,再按优先级逐步优化。不要一开始就追求完美——混合检索 + Reranker 已经能解决 80% 的问题。
参考文献
- Lewis, P., et al. (2020). "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks." NeurIPS 2020.
- Robertson, S., & Walker, S. (1994). "Some simple effective approximations to the 2-Poisson model for probabilistic weighted retrieval." SIGIR 1994.
- Cormack, G. V., et al. (2009). "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods." SIGIR 2009.
- Gao, Y., et al. (2024). "Retrieval-Augmented Generation for Large Language Models: A Survey." arXiv preprint.
- Asai, A., et al. (2024). "Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection." NAACL 2024.