芥末
发布于 2026-04-24 / 0 阅读
0
0

RAG 检索优化的四层框架:索引、查询、召回与重排序

RAG(检索增强生成,Retrieval-Augmented Generation)的核心流程并不复杂:用户提出问题,系统从知识库里检索相关内容,再把检索结果连同问题一起交给 LLM(大语言模型,Large Language Model)生成答案。

真正决定效果的,往往不是生成阶段,而是检索阶段。

如果相关资料没有被找回来,LLM 再强也只能基于缺失的上下文猜测;如果召回内容太杂,模型会被噪声干扰;如果召回内容太碎,模型又缺少完整背景。RAG 的检索优化不能只靠“换一个 Embedding 模型”或者“调一下 chunk 大小”,更合适的做法是按链路分层拆解。

一个完整的检索优化框架可以拆成四层:

flowchart LR
    A[用户问题] --> B[查询层<br/>改写、扩展、HyDE、Step-back]
    B --> C[召回层<br/>向量检索、BM25、多路召回]
    C --> D[RRF 融合<br/>合并候选结果]
    D --> E[重排序层<br/>Cross-encoder Rerank]
    E --> F[Top-K 上下文]
    F --> G[LLM 生成答案]

    H[索引层<br/>Chunking、Parent-Child、摘要索引、多粒度索引] --> C
层次关注点解决的问题
索引层知识怎么存检索粒度和上下文完整性的矛盾
查询层问题怎么变换用户表达和知识库表达不一致
召回层从哪些路径找单一检索方式容易漏召
重排序层哪些内容最相关粗召结果里混入噪声,不能全部塞进 Prompt

这四层不是互相替代的关系,而是分工协作:索引层打基础,查询层让问题更容易命中,召回层保证覆盖面,重排序层把最终进入 Prompt 的内容筛干净。

索引层:解决“小块好检索,大块好理解”的矛盾

RAG 系统通常会先把文档切成 chunk,然后为 chunk 生成 Embedding(文本向量表示),再存入向量数据库。问题在于,chunk 同时承担两个职责:

  1. 检索时要容易被找到;
  2. 生成时要给 LLM 提供足够上下文。

这两个目标天然冲突。

Chunk 粒度优点缺点
小 chunk语义聚焦,向量匹配更准上下文不足,容易断章取义
大 chunk信息完整,LLM 更容易理解语义被稀释,细节问题不容易命中

比如一段文档前半部分定义了“退款审核周期”,后半部分才说明“特殊商品不支持极速退款”。如果只检索到后半段,LLM 可能不知道这个规则属于哪个业务场景;如果把整章都切成一个 chunk,用户问某个细节时,向量又可能被大量无关内容稀释。

Parent-Child Chunking:小块检索,大块使用

比较常见的解法是 Parent-Child Chunking,也叫 Small-to-Big。它的思路是:用小 chunk 做检索,用大 chunk 给 LLM。

flowchart TB
    A[原始文档] --> B[父 chunk<br/>约 500~1000 tokens]
    B --> C1[子 chunk 1<br/>约 100~200 tokens]
    B --> C2[子 chunk 2<br/>约 100~200 tokens]
    B --> C3[子 chunk 3<br/>约 100~200 tokens]

    C1 --> D[(向量索引)]
    C2 --> D
    C3 --> D

    C1 -. parent_id .-> B
    C2 -. parent_id .-> B
    C3 -. parent_id .-> B

    E[用户问题] --> D
    D --> F[命中子 chunk]
    F --> G[根据 parent_id 取父 chunk]
    G --> H[送入 LLM]

建库时,可以把文档切成两套结构:

  • 父 chunk:粒度较大,保留完整上下文;
  • 子 chunk:粒度较小,语义更集中;
  • 子 chunk 记录 parent_id,指向所属父 chunk;
  • 只给子 chunk 建向量索引;
  • 检索命中子 chunk 后,回表取父 chunk 作为上下文。

伪代码大致如下:

def index_document(doc):
    parent_chunks = split_into_parent_chunks(doc, size=800)

    for parent in parent_chunks:
        parent_id = save_parent_chunk(parent)

        child_chunks = split_into_child_chunks(parent, size=180, overlap=30)
        for child in child_chunks:
            vector = embedding_model.encode(child.text)
            vector_store.add(
                id=child.id,
                vector=vector,
                text=child.text,
                metadata={"parent_id": parent_id}
            )


def retrieve(query, top_k=5):
    query_vector = embedding_model.encode(query)

    child_hits = vector_store.search(query_vector, top_k=top_k)
    parent_ids = deduplicate([hit.metadata["parent_id"] for hit in child_hits])

    return load_parent_chunks(parent_ids)

这个方案的关键点是把“检索粒度”和“使用粒度”拆开。检索时要准,所以用子 chunk;生成时要完整,所以取父 chunk。

摘要索引:用更集中的语义做检索

有些文档的原始表达比较松散,直接对原文建向量索引时,Embedding 可能不够聚焦。摘要索引的做法是为每个文档片段生成摘要,然后用摘要建索引。

检索流程是:

flowchart LR
    A[原始片段] --> B[LLM 生成摘要]
    B --> C[(摘要向量索引)]
    D[用户问题] --> C
    C --> E[命中摘要]
    E --> F[取回原始片段]
    F --> G[送入 LLM]

摘要通常比原文更接近“知识点表达”,在向量空间里更容易和用户问题靠近。它适合 FAQ、产品文档、制度说明、长段落知识库等场景。

代价也很明确:建库成本更高,并且摘要质量会影响检索质量。如果摘要漏掉关键条件,检索阶段也会跟着漏。

多粒度索引:不同问题走不同粒度

问题有粗有细,单一 chunk 粒度很难兼顾所有情况。

问题类型示例更适合的索引粒度
概念性问题RAG 是什么?章节级、段落级
流程性问题退款申请怎么操作?段落级
细节性问题退款审核需要几个工作日?句子级、短 chunk
对比性问题普通退款和极速退款有什么区别?段落级、多段组合

多粒度索引会同时维护章节级、段落级、句子级索引。系统可以根据问题类型选择检索路径,也可以多路并行检索后统一融合。它覆盖面更好,但索引体积、去重逻辑和结果融合都会更复杂。

索引层还要注意几个工程细节:

  • chunk 不要机械按字符数切,尽量按标题、段落、列表、表格边界切;
  • 相邻 chunk 可以设置 overlap,避免关键信息被切断;
  • metadata 要保留来源、标题、章节、时间、权限等信息,方便过滤和溯源;
  • 表格、代码、配置项不要随意切碎,否则会破坏结构。

查询层:把用户问题变成更容易命中的检索表达

用户问题和知识库文本经常不是同一种表达方式。

用户可能问:

苹果手机咋截图?

知识库可能写:

iPhone 截屏操作方法如下……

这两句话含义接近,但一个是口语表达,一个是正式文档表达。向量检索能缓解这种差异,但不能完全消除,尤其是短 query 信息量少,几个词的差异就可能影响向量距离。

查询优化的目标是在检索前改造 query,让它更接近知识库中的表达。

Query 改写:补全上下文,消除歧义

Query 改写适合处理口语化、省略、指代不清的问题。

例如在多轮对话里,用户问:

它为什么这么贵?

单看这个问题无法知道“它”指什么。系统需要结合历史对话,把 query 改写成:

iPhone 15 Pro Max 定价偏高的原因是什么?

改写后的 query 语义更完整,也更容易命中知识库。

sequenceDiagram
    participant U as 用户
    participant Q as Query 改写模块
    participant R as 检索器
    participant K as 知识库

    U->>Q: 它为什么这么贵?
    Q->>Q: 结合对话历史补全指代
    Q->>R: iPhone 15 Pro Max 定价偏高的原因是什么?
    R->>K: 检索相关文档
    K-->>R: 返回候选内容

改写时要避免过度发挥。一个实用做法是保留原始 query,同时把改写 query 作为额外检索输入,避免改写过程丢掉用户原本的关键信息。

Multi-Query:从多个角度检索同一个问题

用户和文档的表达角度可能不一致。

用户问:

怎么退货?

知识库写:

售后申请流程

这类问题不一定靠单次改写就能解决。Multi-Query 会让 LLM 生成多个等价或相关问法,然后分别检索,再合并结果。

原始问题:怎么退货?

扩展 query:
1. 如何申请退货?
2. 售后申请流程是什么?
3. 商品退款退货需要哪些步骤?
4. 订单退货入口在哪里?

检索列表里要保留原始问题,因为扩展 query 可能改变侧重点。扩展数量也不能太多,一般 3~5 个比较合适;数量过多会增加检索成本,还会带来更多噪声。

HyDE:先生成假设答案,再用答案检索

HyDE(假设文档嵌入,Hypothetical Document Embeddings)的思路比较特别:不用问题本身去检索,而是先让 LLM 根据问题生成一段“可能的答案”,再对这段假设答案生成向量,用它去匹配知识库。

正常检索是“问题向量匹配文档向量”,HyDE 变成了“答案风格文本匹配文档文本”。因为两边都是陈述性文本,向量空间里的距离可能更近。

flowchart LR
    A[用户问题] --> B[LLM 生成假设答案]
    B --> C[对假设答案生成 Embedding]
    C --> D[(向量数据库)]
    D --> E[召回相关文档]

HyDE 适合领域边界清晰的知识库,比如技术文档、产品说明、制度问答。风险是 LLM 生成的假设答案如果方向错了,检索也会被带偏,所以通常需要和原始 query 检索结果一起融合。

Step-back Prompting:把具体问题抽象成背景问题

有些问题太具体,知识库里没有完全匹配的答案,但有相关背景知识。

例如:

为什么 Transformer 的 attention 要除以 sqrt(d_k)?

知识库可能没有这句话的直接答案,但有“Scaled Dot-Product Attention 的数学原理”。Step-back Prompting 会先把具体问题抽象成更通用的问题:

Transformer 中注意力分数的缩放有什么作用?

先检索背景知识,再结合原问题回答,命中率会更高。

查询层常用方法可以这样选择:

方法适合场景主要代价
Query 改写多轮对话、指代不清、口语化问题需要额外调用 LLM
Multi-Query用户表达和文档表达差异大检索次数增加,噪声变多
HyDE问题短、文档偏陈述性、领域清晰假设答案可能带偏
Step-back具体问题查不到,但有背景知识需要额外的抽象和二次推理

召回层:用多路检索减少漏召

单一路径检索很容易有盲区。

向量检索擅长语义相似,但对精确词匹配不一定稳定。比如用户问:

M4 Pro 芯片跑分是多少?

这里的“M4 Pro”是精确型号。向量模型可能理解它和“苹果芯片”“高性能处理器”相关,但未必能稳定命中包含“M4 Pro”字样的片段。

BM25 是一种经典关键词检索算法,基于词频、逆文档频率和文档长度计算相关性。它对精确词很敏感,但不理解同义表达。用户问“怎么退货”,文档写“售后申请”,如果没有共享关键词,BM25 可能完全召不回来。

两者的能力刚好互补。

检索方式擅长不擅长
向量检索同义表达、语义相近、概念匹配型号、编号、专有名词、精确短语
BM25精确词、产品名、错误码、编号口语化表达、同义词、语义泛化
元数据过滤权限、时间、分类、租户隔离不能单独判断语义相关性

一个常见的多路召回结构如下:

flowchart LR
    A[Query] --> B[向量检索 Top-N]
    A --> C[BM25 Top-N]
    A --> D[元数据过滤 / 结构化检索]

    B --> E[候选集合]
    C --> E
    D --> E

    E --> F[RRF 融合排序]
    F --> G[粗召 Top-K]

多路召回会带来一个新问题:不同检索器的分数不能直接比较。向量检索可能返回余弦相似度,BM25 返回关键词相关性分数,两者量纲不同,简单归一化并不稳定。

RRF:用排名融合多路结果

RRF(倒数排名融合,Reciprocal Rank Fusion)是一种常用融合算法。它不关心各路检索的原始分数,只关心排名。

公式如下:

score(d) = Σ 1 / (k + rank_r(d))

含义是:

  • d 表示某个候选文档或 chunk;
  • rank_r(d) 表示它在第 r 路检索结果里的排名;
  • 如果某一路没有召回它,这一路贡献 0;
  • k 是平滑参数,常用值是 60。

排名越靠前,贡献越大;一个 chunk 如果在多路检索里都排名靠前,融合后的总分就会更高。

Python 实现可以很短:

from collections import defaultdict

def rrf_fusion(result_lists, k=60, top_k=20):
    """
    result_lists: [
        ["doc1", "doc2", "doc3"],  # 向量检索结果,按相关性降序
        ["doc2", "doc4", "doc1"],  # BM25 结果,按相关性降序
    ]
    """
    scores = defaultdict(float)

    for results in result_lists:
        for rank, doc_id in enumerate(results, start=1):
            scores[doc_id] += 1.0 / (k + rank)

    return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]

RRF 的优点是简单、稳定、不需要训练,也不依赖不同检索器分数的可比性。对于“向量检索 + BM25”的混合召回,它通常是工程实现里的低成本选择。

重排序层:从粗召候选里挑出真正相关的上下文

多路召回会扩大覆盖面,但也会带来噪声。假设召回了 30 个 chunk,直接全部放进 Prompt 会有几个问题:

  • token 成本上升;
  • 不相关内容会干扰回答;
  • 上下文过长时,LLM 可能忽略中间位置的信息,也就是 Lost in the Middle;
  • 多个 chunk 之间可能互相重复,占用上下文窗口。

Rerank 的作用是把粗召结果重新精排,从几十个候选里选出最相关的 3~5 个。

Bi-encoder 和 Cross-encoder 的区别

向量检索通常使用 Bi-encoder 结构:query 和 chunk 分别编码成向量,再计算相似度。

Rerank 常用 Cross-encoder 结构:把 query 和 chunk 拼在一起输入模型,让模型直接判断这一对文本的相关性。

flowchart TB
    subgraph Bi-encoder
        A1[Query] --> B1[Encoder]
        C1[Chunk] --> D1[Encoder]
        B1 --> E1[Query 向量]
        D1 --> F1[Chunk 向量]
        E1 --> G1[余弦相似度]
        F1 --> G1
    end

    subgraph Cross-encoder
        A2[Query + Chunk] --> B2[同一个模型联合编码]
        B2 --> C2[相关性分数]
    end
模型结构工作方式优点缺点适合位置
Bi-encoderquery 和 chunk 分别编码快,chunk 向量可提前计算交互信息弱,相关性判断较粗大规模召回
Cross-encoderquery 和 chunk 一起编码能细粒度判断匹配关系慢,每个候选都要跑一次小规模精排

Cross-encoder 更准,是因为它能看到 query 和 chunk 内部词语之间的交互关系。例如 query 里问“退款多久到账”,模型可以重点判断 chunk 是否真的包含“到账时间”,而不是只看整体语义是否接近“退款”。

典型流程是:

flowchart LR
    A[多路召回候选<br/>20~50 个 chunk] --> B[构造 query-chunk 对]
    B --> C[Cross-encoder Rerank]
    C --> D[按相关性分数排序]
    D --> E[取 Top-3 到 Top-5]
    E --> F[拼入 Prompt]

常见 Rerank 模型包括:

  • BGE-Reranker-v2:中英文场景都常用;
  • BCE-Reranker:中文场景可选;
  • Cohere Rerank、Jina Reranker:适合直接使用 API(应用程序编程接口,Application Programming Interface)的场景。

Rerank 不适合替代召回。它计算成本比向量检索高得多,所以更适合处理已经缩小范围的候选集。比较合理的结构是:召回层负责从海量文档里找出几十个候选,Rerank 负责把几十个候选排成更可信的 Top-K。

四层策略怎么组合

不同系统的问题不一样,不需要一上来把所有策略都堆满。可以根据症状选择优化入口。

现象优先排查层次可能方案
明明知识库有答案,但检索不到索引层、召回层调整 chunk、Parent-Child、多路召回
检索结果语义接近但不是答案重排序层加 Cross-encoder Rerank
用户口语化问题命中率低查询层Query 改写、Multi-Query
专有名词、型号、错误码经常漏召回层BM25 + 向量混合检索
检索片段太碎,LLM 回答不完整索引层Small-to-Big、父子 chunk
候选内容太多,成本高且干扰大重排序层Rerank 后只取 Top-K

比较稳妥的生产级组合是:

flowchart LR
    A[文档] --> B[Parent-Child Chunking]
    B --> C[(子 chunk 向量索引)]
    B --> D[(父 chunk 原文存储)]

    E[用户问题] --> F{是否需要查询优化}
    F -->|口语化/指代不清| G[Query 改写或 Multi-Query]
    F -->|表达清晰| H[保留原始 Query]
    G --> I[混合召回]
    H --> I

    I --> J[向量检索 + BM25]
    J --> K[RRF 融合]
    K --> L[取候选父 chunk]
    L --> M[Cross-encoder Rerank]
    M --> N[Top-K 上下文]
    N --> O[LLM 生成]

这个组合里的每一层都有明确职责:

  • Parent-Child Chunking 解决检索粒度和上下文完整性的冲突;
  • 向量检索负责语义相似;
  • BM25 负责精确词匹配;
  • RRF 负责融合多路排名;
  • Rerank 负责从候选里挑出最相关内容;
  • Query 改写和 Multi-Query 只在用户问题质量不稳定时启用。

落地时要关注评估指标

检索优化不能只凭肉眼观察几条样例。需要准备一批带标准答案或标准证据片段的评测集,然后看检索链路是否真的找到了正确上下文。

常见指标包括:

指标含义
Recall@KTop-K 结果里是否包含正确证据,衡量有没有召回
MRR正确结果排名越靠前分数越高,衡量首个正确结果的位置
nDCG考虑多个相关结果和排序质量
Rerank 前后命中率判断精排是否把正确内容提前
答案引用准确率判断生成答案是否基于检索内容

RAG 的问题经常不是单点问题。只看最终答案,可能分不清是没召回、召回了但排序低、排序对了但 Prompt 组织差,还是 LLM 生成时没有使用证据。把链路拆开评估,才能知道应该优化哪一层。

常见坑

几个问题在工程里很常见:

  1. 只调 chunk 大小,不看文档结构
    按固定 token 数硬切,容易把标题、表格、列表、代码块切碎。更好的方式是优先按语义边界切,再用长度做兜底。

  2. 只用向量检索,不加关键词召回
    向量检索不等于万能检索。产品型号、订单号、错误码、函数名、配置项这类内容,BM25 往往更可靠。

  3. Multi-Query 数量过多
    扩展 query 太多会召回大量边缘内容,后面的 Rerank 压力也会变大。一般控制在 3~5 个,并保留原始 query。

  4. HyDE 不做约束
    假设答案如果生成偏了,检索方向也会偏。领域知识边界不清晰时,要谨慎使用 HyDE,或者和原始 query 结果融合。

  5. Rerank 候选集过大
    Cross-encoder 精度高但速度慢。让它处理几百个候选,延迟和成本都会上升。更合理的是粗召 20~50 个,再精排 Top-K。

  6. 只追求召回数量,不控制上下文质量
    塞给 LLM 的内容不是越多越好。高质量、少量、排序合理的上下文,通常比一大段混杂材料更可靠。

RAG 检索优化的核心不是堆工具,而是按链路定位问题:知识存得不好,就改索引;问题表达不清,就做查询优化;单路检索漏召,就做混合召回;候选太杂,就加重排序。把索引、查询、召回、重排序四层串起来,RAG 系统的回答质量才会稳定。


评论