本文基于一个完整的 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% 的问题。


参考文献

  1. Lewis, P., et al. (2020). "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks." NeurIPS 2020.
  2. Robertson, S., & Walker, S. (1994). "Some simple effective approximations to the 2-Poisson model for probabilistic weighted retrieval." SIGIR 1994.
  3. Cormack, G. V., et al. (2009). "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods." SIGIR 2009.
  4. Gao, Y., et al. (2024). "Retrieval-Augmented Generation for Large Language Models: A Survey." arXiv preprint.
  5. Asai, A., et al. (2024). "Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection." NAACL 2024.