封面图:RAG 混合检索系统把关键词召回、向量召回、元数据过滤、融合排序和 reranker 组合成可评测闭环

系列:生产级 LLM 应用方法论 07
日期:2026-06-21
适合读者:正在建设企业知识库问答、客服助手、内部搜索、研究助理或 Agent 检索层的工程师、产品负责人和技术负责人。

摘要

第六篇讲了知识库摄取与 chunking:把原始文档处理成可检索、可引用、可过滤、可审计的证据单元。

这一篇解决下一个问题:证据单元已经入库了,怎样把正确证据稳定排到模型面前?

很多 RAG 系统的第一个版本都是“向量检索 top-k + prompt”。这个方案适合 demo,但很快会在真实场景里暴露问题:用户问错误码、合同条款、产品型号、表格字段、专有名词时,向量相似度未必可靠;用户问概念性问题、同义改写、跨文档总结时,纯关键词又太僵硬;候选召回到了以后,如果没有重排,前 5 条经常被相似但不支持答案的 chunk 占掉。

生产级 RAG 的检索层不应该被理解成一个向量库调用,而应该被理解成一条排序流水线:

Query Understanding
  -> Metadata Filter
  -> Sparse Retrieval + Dense Retrieval
  -> Hybrid Fusion
  -> Candidate Pool
  -> Reranker
  -> Diversity and Policy Filters
  -> Context Packing
  -> Eval and Fix Loop

本文给出一套可落地的混合检索与重排方法,包含 sparse 与 dense 各自负责什么、如何做 RRF 融合、候选池取多大、reranker 放在哪里、怎样处理低置信度、如何按查询类型评测,以及一个最小 Python 排序管线示例。

目录

1. 不要把 RAG 检索理解成向量 top-k

向量检索很有用,但它不是完整的检索系统。

假设用户问:

订单命中 E7782 时是否还能自动退款?

这个问题里有一个很强的关键词信号:E7782。如果知识库里有一段:

错误码 E7782 表示支付渠道返回风险拦截。命中该错误码后,退款申请必须进入人工审核,不得自动退款。

纯语义向量检索不一定把它排第一。因为向量模型可能更重视“自动退款”“人工审核”这些语义相近的段落,而不是错误码本身。这个时候 BM25、字段匹配、短语匹配和精确匹配非常重要。

再看另一个问题:

客户说到账时间太久,客服应该怎么解释跨境退款?

这里用户没有直接说“跨境支付订单需人工审核”“1-5 个工作日”“原支付渠道”,但语义上明显在问这些政策。纯关键词检索可能错过,向量检索更适合。

生产 RAG 的关键不是在关键词和向量之间二选一,而是承认它们解决的问题不同。

检索方式 擅长 不擅长
关键词 / BM25 / sparse 错误码、产品型号、合同编号、专有名词、原文短语、字段名 同义改写、概念性问题、跨句语义
向量 / dense 同义表达、意图匹配、概念相关、自然语言问题 精确符号、短字符串、罕见名词、最新术语
元数据过滤 权限、租户、版本、语言、时间、文档类型 判断内容是否真正回答问题
reranker 细粒度相关性、证据是否支持答案、排序纠偏 处理全库召回,成本和延迟较高

所以检索层的目标不是“取 top-k”,而是:从全量知识库中构造一个足够全、足够准、可解释、可审计的证据候选集合,再把最能支持答案的证据放进上下文。

2. 一条生产检索链路长什么样

框架图:RAG 检索排序流水线从 Query、Metadata Filter、Sparse Search、Dense Search 到 Hybrid Fusion、Reranker、Context Pack 和 Eval Loop

一条更可靠的 RAG 检索链路通常包含这些阶段。

阶段 目标 关键问题
Query Understanding 理解用户意图、实体、时间、约束 用户在问规则、事实、流程、对比还是总结?
Metadata Filter 先收紧可访问范围 租户、权限、语言、版本、状态是否正确?
Sparse Retrieval 召回精确词面匹配 错误码、术语、标题、字段名能否命中?
Dense Retrieval 召回语义相似内容 同义表达和概念相关内容能否命中?
Hybrid Fusion 合并不同检索器结果 不同分数尺度如何融合?重复 chunk 如何处理?
Candidate Pool 给 reranker 足够候选 召回池是否足够大但不失控?
Reranker 重新判断候选是否真正回答问题 证据和问题之间是否有直接支持关系?
Diversity Filter 避免同源重复挤占上下文 是否需要来源上限、MMR、相邻 chunk 补全?
Context Packing 把证据组织给生成模型 顺序、引用、预算、冲突证据怎么处理?
Eval Loop 发现失败模式并修复 哪类问题掉召回、掉排序、掉引用?

这里最容易被忽略的是两个事实。

第一,metadata filter 不是可选项。企业 RAG 里,权限和版本应该在检索前参与过滤,而不是把不该看的证据召回后交给模型“自觉不要用”。

第二,reranker 不是召回器。reranker 更擅长在几十条候选里做精排,不适合直接面对百万级语料。召回阶段如果没有把正确证据放进候选池,reranker 再强也救不了。

3. Sparse Retrieval:关键词不是落后方案

Sparse retrieval 通常指 BM25、倒排索引、短语匹配、字段加权、词项扩展等关键词检索方法。它看起来不如向量检索“智能”,但在很多生产问题里反而是最可靠的底座。

3.1 什么时候必须保留关键词检索

这些查询强烈依赖词面匹配:

  • 错误码:E7782HTTP 429ORA-00001
  • 产品型号:XG-2400Pro MaxSKU-9812
  • 字段名:refund_statuseffective_date
  • 合同条款:第 6.2 条force majeure
  • 法规编号:GDPR Article 15SOX 404
  • 内部项目代号:AuroraBridge-v2
  • 人名、地名、机构名、报告标题

这些词的语义空间未必稳定。一个错误码可能没有自然语言含义,一个 SKU 可能只是一串字符。向量检索如果没有足够训练语境,很难保证精确召回。

3.2 关键词检索不要只用正文

上一篇文章强调 chunk metadata。这里它会直接影响关键词召回质量。

推荐把这些字段纳入 sparse index,并设置不同权重:

字段 作用 建议
source_title 文档级主题 高权重
hierarchy_path 章节路径 高权重
text 证据正文 标准权重
keywords 人工或自动抽取术语 中高权重
product_line 产品线 用于过滤或加权
version 版本 用于过滤,不建议只靠排序
status active/deprecated 用于过滤

如果只把 chunk 正文放进索引,很多短 chunk 会失去上下文。例如正文只有:

不得自动退款。

但它的标题路径是:

退款政策 > 风险拦截 > E7782 处理

检索时标题路径比正文更关键。

3.3 Sparse 检索的工程细节

生产里建议至少支持:

  • 精确短语匹配:适合错误码、条款号、标题;
  • 字段加权:标题和章节路径权重大于正文;
  • analyzer 策略:中文分词、英文大小写归一、符号保留;
  • 同义词表:业务别名、旧称、新称;
  • 停用词控制:不要把“退款”“订单”这类领域高频词全部丢掉;
  • query boost:当识别出错误码、SKU、字段名时,提高 sparse 权重。

关键词检索不是“传统方案”,它是 RAG 对现实世界符号系统的尊重。

4. Dense Retrieval:语义召回解决改写和概念匹配

Dense retrieval 使用 embedding 把 query 和 chunk 映射到向量空间,再按相似度召回。它的价值在于处理用户不会逐字复述文档的情况。

例如知识库里写的是:

跨境支付订单需进入人工审核。审核完成后,退款通常在 1-5 个工作日内退回原支付渠道。

用户可能会问:

海外订单退款为什么不能马上到账?

这时没有明显的错误码或条款号,dense retrieval 更容易召回正确证据。

4.1 Dense 检索依赖 chunk 质量

如果 chunk 太长,embedding 会把多个主题混在一起;如果 chunk 太短,语义不完整。向量召回的质量不是只由 embedding model 决定,还由这些因素共同决定:

  • chunk 是否语义完整;
  • 标题路径是否进入索引文本;
  • 表格是否被转成可理解的文本;
  • 旧版本是否被过滤;
  • 同一文档是否重复入库;
  • 用户 query 是否需要改写或拆解。

这也是为什么第六篇先讲摄取和 chunking。没有好的证据单元,检索算法只能在噪声里排序。

4.2 Dense 检索的参数起点

可以从下面的配置起步,再用评测集调整。

场景 dense top-n 说明
小型 FAQ / 文档少 20-40 候选充足即可
中型企业知识库 50-100 给融合和重排留空间
多租户 / 多产品线 每个过滤范围 30-80 先过滤再召回
长文档研究助理 80-150 需要更强去重和多样性控制

不要一开始就把 top_k=5 作为检索结果直接喂给模型。top-k 是最终上下文数量,不应该等于召回数量。召回阶段要更宽,精排和上下文阶段再收紧。

5. Hybrid Fusion:把不同检索器变成一个候选池

Hybrid search 的基本思路是:同一个 query 同时跑 sparse 和 dense,再把两个结果列表融合成一个候选池。

困难点在于,BM25 分数和向量相似度不是同一个尺度。BM25 可能是 12.7,cosine similarity 可能是 0.83。直接相加通常没有意义。

5.1 推荐从 RRF 开始

一个实用起点是 Reciprocal Rank Fusion,简称 RRF。它不直接比较原始分数,而是比较排名位置。

直觉很简单:

  • 同时在 sparse 和 dense 里排名靠前的 chunk,应该更靠前;
  • 只在一个通道里排名很靠前的 chunk,也应该保留;
  • 排名越靠后,贡献越小。

常见形式:

score(doc) = sum( weight_i / (k + rank_i(doc)) )

其中 rank_i 是某个检索器里的排名,k 是平滑常数,常用 60 左右。权重可以按 query 类型调整。

5.2 权重不应该固定不变

同一个系统里,不同 query 应该有不同策略。

Query 类型 sparse 权重 dense 权重 原因
错误码 / SKU / 条款号 精确符号优先
FAQ 自然语言问题 用户表达经常改写
标题或文档名搜索 低中 标题词面很强
概念解释 / 原理总结 语义相关更重要
多条件政策问题 中高 中高 两者都需要

OpenAI Retrieval 文档中也提供了混合检索相关的 ranking 配置:可以通过 ranking_options.hybrid_search 调整 embedding 语义匹配和 sparse 文本匹配的权重,并通过 score_threshold 控制返回结果的最低分数。具体产品参数会变化,设计上要把这些当成可评测、可回滚的检索策略,而不是一次性写死。

5.3 融合前后都要去重

同一个证据可能以多个形式出现:

  • 同一 source 的相邻 chunk;
  • 同一文档不同版本;
  • FAQ 和政策正文重复;
  • 多语言翻译内容重复;
  • 摘要页和原文页重复。

融合后必须按 chunk_idcontent_hashsource_id + locator 等字段去重。否则前 10 条看似很多,实际上全是同一段话的重复版本。

6. Candidate Pool:重排前要先给足候选

reranker 的前提是正确证据已经在候选池里。候选池太小,是很多 RAG 排序失败的根因。

一个稳妥的起点:

sparse top 80
dense top 80
metadata filter before retrieval
RRF fusion to top 80
dedupe and source cap to top 60
rerank top 60
context pack top 6-12 chunks

数字不是标准答案,但体现了一个原则:召回宽,重排窄,上下文更窄。

6.1 为什么不能只 rerank top 10

如果 sparse 和 dense 初排都不完美,正确证据可能排在第 18、33 或 47。只 rerank top 10,会让 reranker 看不到正确答案。

在延迟允许的情况下,建议至少 rerank 30-80 条。对于高价值、低频、长答案问题,可以 rerank 100 条以上;对于客服实时场景,可以用缓存、轻量 reranker 或分层 rerank 控制延迟。

6.2 候选池要保留解释字段

每个候选不仅要有文本,还要保留排序证据:

{
  "chunk_id": "refund_policy#sec_4#chunk_02",
  "source_id": "refund_policy_2026_03",
  "sparse_rank": 3,
  "dense_rank": 18,
  "fusion_score": 0.0271,
  "rerank_score": 0.84,
  "matched_terms": ["E7782", "自动退款"],
  "metadata": {
    "version": "2026-03",
    "status": "active",
    "access_scope": ["customer_service"]
  }
}

这些字段是调试和评测的生命线。线上出现错答时,如果日志里只有最终 prompt,很难知道是没召回、排错了,还是生成模型没用证据。

7. Reranker:把“像”变成“能支持答案”

初始召回判断的是“这个 chunk 和 query 是否相似”。reranker 应该更进一步:判断“这个 chunk 是否能直接支持回答这个 query”。

例如用户问:

命中 E7782 后能否自动退款?

候选 A:

自动退款适用于低风险订单,系统会在审核通过后按原支付渠道退款。

候选 B:

错误码 E7782 表示支付渠道返回风险拦截。命中该错误码后,退款申请必须进入人工审核,不得自动退款。

A 和 query 都包含“自动退款”,相似度可能很高。但 B 才是真正支持答案的证据。reranker 的价值就在这里。

7.1 常见 reranker 类型

类型 思路 优点 代价
Cross-encoder reranker 同时输入 query 和 chunk,输出相关性分数 精度强,适合精排 每个候选都要跑模型
LLM reranker 让 LLM 判断候选是否支持答案 可解释,能处理复杂条件 成本和延迟高,要防格式漂移
Late interaction 例如 ColBERT 风格的 token-level 交互 兼顾召回和细粒度匹配 系统实现更复杂
规则 reranker 用实体、时间、版本、标题等规则加权 便宜、稳定、可控 覆盖不了复杂语义

工程上常见组合是:

RRF fusion -> rule boosts -> neural reranker -> final policy filters

规则不是为了替代模型,而是把确定性约束先处理掉。例如:

  • active 版本优先;
  • 用户所在产品线优先;
  • 标题精确命中加权;
  • 旧版本降权或过滤;
  • 同一来源最多保留 2-3 条;
  • 表格证据必须带行列标题。

7.2 reranker 的输出不要只存一个分数

如果使用 LLM 或可解释 reranker,建议输出结构化字段:

{
  "chunk_id": "refund_policy#sec_4#chunk_02",
  "relevance": 0.92,
  "support": "direct",
  "reason": "The chunk explicitly states that E7782 requires manual review and automatic refund is not allowed.",
  "missing": []
}

其中 support 可以分成:

  • direct:直接回答;
  • partial:回答一部分;
  • background:只是背景;
  • conflicting:与其他证据冲突;
  • irrelevant:无关。

最终进上下文的应该优先是 direct 和高质量 partial,而不是所有“看起来相关”的材料。

8. Query Rewrite 与 Query Decomposition

用户 query 往往不是最适合检索的形式。检索前可以做 query rewrite,但要控制边界。

8.1 Query Rewrite 适合什么

适合:

  • 口语化问题改写成标准术语;
  • 省略上下文补全;
  • 中英文术语映射;
  • 拼写纠错;
  • 把长问题压成检索关键词;
  • 生成同义 query 做多路召回。

例如:

用户问题:海外订单退款为什么慢?
检索 query 1:跨境支付订单 退款 人工审核 到账时间
检索 query 2:海外订单 退款 1-5 个工作日 原支付渠道
检索 query 3:跨境退款 审核完成后多久到账

OpenAI Retrieval 文档中提供了 rewrite_query 一类能力,用于在检索前改写 query。无论使用托管能力还是自建能力,都要把 rewrite 结果记录到日志里,否则很难解释为什么召回了某些证据。

8.2 Query Decomposition 适合复杂问题

复杂问题经常包含多个子问题:

企业客户取消年付套餐时,退款金额怎么计算,是否会影响已开票金额?

这可能拆成:

  1. 年付套餐取消规则;
  2. 退款金额计算;
  3. 发票和财务处理;
  4. 企业客户是否有特殊条款。

每个子 query 单独召回,再融合和重排,通常比把原问题直接检索更稳定。

但 decomposition 有一个风险:它会引入模型臆测的子问题。建议把拆解结果和原始 query 一起记录,并在 reranker 阶段要求证据仍然支持原始问题,而不是只支持某个被改写后的问题。

9. 多样性、权限、版本和新鲜度

排序不是只看相关性。RAG 的证据上下文还要满足工程约束。

9.1 权限必须在检索前过滤

如果用户无权访问某个文档,最好在检索层就过滤掉,而不是召回后再让模型忽略。

必备字段:

  • tenant_id
  • access_scope
  • owner_team
  • classification
  • status
  • effective_at
  • expires_at

托管检索能力通常也会支持 metadata filtering。比如 OpenAI File Search 和 Retrieval 文档都强调可以基于文件或属性 metadata 做过滤。自建系统也应该把 metadata filter 设计成检索 API 的一等参数。

9.2 版本和状态要硬过滤

如果文档有 active / deprecated / draft 状态,默认只检索 active。不要把“新版本更高分”交给排序模型自然学会。

常见策略:

状态 默认处理
active 可召回
deprecated 默认过滤;用户明确问历史版本时才召回
draft 只对内部授权用户召回
archived 默认过滤

9.3 多样性控制避免上下文被同一来源占满

如果 top 10 全来自同一个长文档的相邻 chunk,模型可能看不到另一个关键来源。可以设置:

  • 每个 source_id 最多保留 2-3 个 chunk;
  • 同一章节相邻 chunk 合并或补全;
  • 使用 MMR 类策略兼顾相关性和差异;
  • 对冲突证据保留至少一条反例;
  • 对表格证据保留完整行列上下文。

多样性不是为了“平均分配”,而是为了让上下文覆盖回答所需的不同证据角色。

10. 分数阈值与低置信度策略

很多团队只关心“召回了什么”,不关心“什么时候不该回答”。

实际产品里,低置信度问题必须有策略:

  • 没有任何候选超过阈值:拒答或追问;
  • 候选分数接近但互相冲突:说明冲突并请求确认;
  • 只有背景证据,没有直接证据:给出保守回答;
  • 需要用户身份、时间或产品线才能判断:追问;
  • 命中旧版本而新版本缺失:提示无法确认最新规则。

阈值不要只用一个全局数字。不同 query 类型可以有不同阈值。

Query 类型 阈值策略
错误码 / 条款号 要求精确命中或强支持
概念解释 可以接受多个 partial 证据
合规 / 法务 阈值更高,冲突时不强答
客服话术 允许引用政策后生成表达
数据事实 必须命中明确来源和时间

一个成熟的 RAG 系统,不是永远回答,而是知道什么时候回答、什么时候追问、什么时候拒答。

11. 一个最小混合检索示例

下面是一个不依赖具体数据库的最小排序框架。真实系统里,sparse_search 可以来自 Elasticsearch / OpenSearch / Vespa / PostgreSQL FTS,dense_search 可以来自向量库或托管检索,rerank 可以来自 cross-encoder、LLM 或自建模型。

from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional


@dataclass
class Candidate:
    chunk_id: str
    source_id: str
    text: str
    metadata: Dict[str, str]
    sparse_rank: Optional[int] = None
    dense_rank: Optional[int] = None
    fusion_score: float = 0.0
    rerank_score: float = 0.0
    reasons: List[str] = field(default_factory=list)


def rrf_fuse(
    result_lists: Dict[str, List[Candidate]],
    weights: Dict[str, float],
    k: int = 60,
) -> List[Candidate]:
    merged: Dict[str, Candidate] = {}

    for channel, results in result_lists.items():
        weight = weights.get(channel, 1.0)
        for rank, item in enumerate(results, start=1):
            if item.chunk_id not in merged:
                merged[item.chunk_id] = item

            candidate = merged[item.chunk_id]
            candidate.fusion_score += weight / (k + rank)

            if channel == "sparse":
                candidate.sparse_rank = rank
            elif channel == "dense":
                candidate.dense_rank = rank

            candidate.reasons.append(f"{channel}:rank={rank}")

    return sorted(merged.values(), key=lambda x: x.fusion_score, reverse=True)


def apply_policy_filters(
    candidates: Iterable[Candidate],
    user_scope: str,
    max_per_source: int = 3,
) -> List[Candidate]:
    kept: List[Candidate] = []
    source_counts: Dict[str, int] = {}

    for item in candidates:
        if item.metadata.get("status") != "active":
            continue
        if user_scope not in item.metadata.get("access_scope", "").split(","):
            continue
        if source_counts.get(item.source_id, 0) >= max_per_source:
            continue

        source_counts[item.source_id] = source_counts.get(item.source_id, 0) + 1
        kept.append(item)

    return kept


def rerank(query: str, candidates: List[Candidate]) -> List[Candidate]:
    # Replace this stub with a cross-encoder, LLM reranker, or managed reranking API.
    # Keep the interface stable so retrieval experiments are easy to compare.
    query_terms = set(query.lower().split())
    for item in candidates:
        text_terms = set(item.text.lower().split())
        overlap = len(query_terms & text_terms)
        item.rerank_score = 0.7 * item.fusion_score + 0.3 * overlap
    return sorted(candidates, key=lambda x: x.rerank_score, reverse=True)


def retrieve_for_rag(query: str, user_scope: str) -> List[Candidate]:
    sparse = sparse_search(query, top_n=80)
    dense = dense_search(query, top_n=80)

    weights = choose_weights(query)
    fused = rrf_fuse(
        {"sparse": sparse, "dense": dense},
        weights=weights,
        k=60,
    )

    filtered = apply_policy_filters(fused[:100], user_scope=user_scope)
    ranked = rerank(query, filtered[:60])

    return ranked[:10]

这个例子刻意保留了几个接口:

  • choose_weights(query):按 query 类型调整 sparse / dense 权重;
  • apply_policy_filters(...):把权限、版本、来源上限放在排序链路中;
  • rerank(...):把精排能力和召回能力解耦;
  • reasons:记录候选为什么被召回,便于调试。

真实系统上线前,还应该把每次请求的 query、rewrite query、filter、sparse rank、dense rank、fusion score、rerank score、最终引用都写入可检索日志。

12. 如何评测混合检索是否真的更好

第四篇讲过 RAG 评测,这里聚焦检索排序本身。

不要只看“感觉答案更好”。要把检索链路拆开比较:

实验组 目的
sparse only 关键词基线
dense only 向量基线
sparse + dense fusion 验证混合召回是否提升 recall
fusion + reranker 验证精排是否提升 top-k
fusion + reranker + diversity 验证上下文质量是否提升
rewrite + fusion + reranker 验证 query rewrite 是否有收益

12.1 指标分层

指标 看什么 典型用途
Recall@50 正确证据是否进入候选池 诊断召回问题
Recall@10 正确证据是否靠前 诊断初排和融合
MRR 第一个正确证据排第几 问答首证据质量
nDCG@k 多条证据排序质量 多证据答案
Citation Support Rate 最终引用是否支持答案 连接检索和生成
Empty / Abstain Rate 系统拒答频率 低置信度策略
Latency p95 用户体验 线上 SLA
Cost per Query 推理和检索成本 规模化成本

12.2 必须按 query 类型切分

总平均值会掩盖问题。至少按这些类型切:

  • 精确实体类:错误码、SKU、条款号;
  • 政策规则类:能不能、是否允许、怎么计算;
  • 流程类:先做什么、后做什么;
  • 对比类:A 和 B 有什么区别;
  • 摘要类:总结某文档或某主题;
  • 多跳类:需要多个来源组合;
  • 低置信度类:知识库没有答案或权限不足。

一个系统可能总体 Recall@10 提升 3%,但错误码类下降 20%。如果不分桶,你会误以为优化成功。

12.3 评测样本从线上失败里来

初始 gold set 可以人工构造,但真正有价值的样本来自线上失败:

  • 用户追问“不是这个意思”;
  • 人工客服改写了答案;
  • 用户点击了“没有帮助”;
  • 引用被用户打开后没有支持答案;
  • reranker 高分候选被人工判为无关;
  • 模型回答“无法确认”但知识库其实有答案。

每周把失败样本加入评测集,再比较检索策略,就能形成真正的排序闭环。

13. 生产架构建议

一个可维护的 RAG 检索层可以拆成这些模块。

Client / Agent
  -> Retrieval API
      -> Query Analyzer
      -> Metadata Policy Service
      -> Sparse Index
      -> Vector Index
      -> Fusion Service
      -> Reranker Service
      -> Context Packer
      -> Retrieval Logger
  -> Generation API
  -> Evaluation Pipeline

13.1 Retrieval API 要稳定

不要让业务代码直接拼多个数据库调用。建议提供一个稳定接口:

{
  "query": "命中 E7782 后能否自动退款?",
  "user": {
    "tenant_id": "global",
    "access_scope": ["customer_service"]
  },
  "filters": {
    "status": "active",
    "language": "zh-CN"
  },
  "strategy": "hybrid_rerank_v3",
  "max_context_chunks": 8
}

返回:

{
  "strategy": "hybrid_rerank_v3",
  "rewritten_queries": [
    "E7782 自动退款 人工审核",
    "错误码 E7782 退款处理规则"
  ],
  "contexts": [
    {
      "chunk_id": "refund_policy#sec_4#chunk_02",
      "source_id": "refund_policy_2026_03",
      "text": "错误码 E7782 表示支付渠道返回风险拦截。命中该错误码后,退款申请必须进入人工审核,不得自动退款。",
      "score": 0.92,
      "support": "direct",
      "citation": {
        "title": "退款政策 2026-03 版",
        "section": "4.2",
        "page": 4
      }
    }
  ]
}

这样生成层不需要知道底层是 Elasticsearch、向量库还是托管 file search。检索策略可以迭代,调用契约保持稳定。

13.2 策略要可版本化

每次检索策略调整都应该有版本号:

  • dense_only_v1
  • hybrid_rrf_v1
  • hybrid_rrf_rerank_v2
  • hybrid_rrf_rerank_rewrite_v3

日志和评测都记录策略版本。否则线上表现变好或变坏时,你无法定位是哪次权重、过滤、reranker 或 chunk 变更造成的。

13.3 上下文打包是排序的一部分

最后放进 prompt 的不只是 top-k 文本。Context packing 要处理:

  • 证据顺序:先直接证据,再背景证据;
  • 引用格式:每条证据有 source、page、section;
  • 相邻补全:必要时带前后 chunk;
  • 冲突证据:明确标注不同版本或不同条件;
  • token 预算:长表格、长段落要压缩但不能丢关键字段;
  • 去重:重复证据不要浪费上下文窗口。

检索排序的终点不是列表,而是一个可被生成模型可靠使用的证据包。

14. 常见误区

14.1 误区一:只要 embedding 模型更强,就不需要关键词

embedding 再强,也不应该承担所有精确匹配任务。错误码、合同编号、字段名和条款号天然适合 sparse 检索。

14.2 误区二:把 raw BM25 分数和 cosine 分数直接相加

不同检索器分数尺度不同,直接相加会让某一侧支配排序。先用 RRF 这类基于排名的融合,或者做经过验证的分数归一化。

14.3 误区三:召回 top 5 直接给模型

top 5 适合最终上下文,不适合召回。召回要宽,重排要准,上下文要精。

14.4 误区四:reranker 输入太少

reranker 只看 top 10,很容易错过排在 20-50 的正确证据。先把候选池做宽,再让 reranker 工作。

14.5 误区五:权限和版本放到 prompt 里提醒

这会制造安全和一致性风险。权限、租户、版本、状态应该在检索层过滤。

14.6 误区六:只评测最终答案,不评测检索过程

最终答案错了,原因可能是没召回、排错、证据冲突、上下文打包错误,也可能是生成模型没遵守证据。只看答案会让修复方向变得模糊。

14.7 误区七:所有 query 用同一套权重

错误码查询和概念解释查询不应该使用同一套 sparse / dense 权重。至少要按 query 类型做简单路由。

15. 落地路线图

第 1 周:建立可观测基线

  • 接入 sparse 和 dense 两条召回;
  • 每条候选记录 sparse rank、dense rank、source、version、access scope;
  • 建立 100-300 条人工 gold query;
  • 跑 sparse only、dense only、hybrid 三组评测。

第 2 周:加入融合和过滤

  • 实现 RRF 或托管 hybrid ranking;
  • 加入 metadata filter;
  • 加入 source cap 和去重;
  • 按 query 类型调整 sparse / dense 权重;
  • 输出 Recall@50、Recall@10、MRR、nDCG。

第 3 周:加入 reranker

  • 选择 cross-encoder、LLM reranker 或托管 rerank 能力;
  • rerank 30-80 条候选;
  • 记录 rerank score 和 support 类型;
  • 比较 hybrid 与 hybrid + rerank。

第 4 周:打通上下文和引用

  • 把 rerank 后证据打包成结构化上下文;
  • 接入引用与归因校验;
  • 对低置信度问题设计拒答和追问;
  • 建立线上失败样本回流流程。

第 5 周:优化延迟和成本

  • 缓存热门 query 和 query rewrite;
  • 对简单 query 跳过重 reranker;
  • 使用分层 rerank;
  • 控制每个来源和每个答案的 token 预算;
  • 按业务价值设置不同 SLA。

16. 发布前自检清单

上线前至少检查这些问题。

召回

  • sparse 和 dense 都可以单独运行;
  • 错误码、SKU、条款号能被关键词稳定召回;
  • 自然语言改写能被向量召回;
  • 正确证据在 Recall@50 中表现稳定;
  • chunk 标题路径进入索引文本。

融合

  • 没有直接相加不同检索器 raw score;
  • 使用 RRF 或经过验证的归一化;
  • query 类型会影响 sparse / dense 权重;
  • 融合后按 chunk_id、source、hash 去重;
  • 记录每个候选来自哪些通道。

重排

  • reranker 至少看到 30-80 条候选;
  • rerank score 和支持类型写入日志;
  • 评测 hybrid 与 hybrid + rerank 的差异;
  • 低分候选不会进入最终上下文;
  • 高风险场景有更严格阈值。

权限和版本

  • tenant、access scope、status、version 在检索层过滤;
  • deprecated 文档默认不召回;
  • 用户明确问历史版本时才放开版本过滤;
  • 文档权限变化后索引能及时更新;
  • 日志不会泄漏无权访问内容。

上下文

  • 每条上下文都有 citation;
  • 相邻 chunk 补全不会引入无关内容;
  • 同一来源不会挤占全部上下文;
  • 冲突证据能被标记;
  • 生成模型能看到足够直接证据。

评测

  • 按 query 类型分桶;
  • 同时看 Recall@k、MRR、nDCG、引用支持率;
  • 记录 latency p50/p95 和成本;
  • 线上失败样本能回流到评测集;
  • 检索策略有版本号并可回滚。

17. 总结

RAG 的检索层不是一个向量库 top-k 参数,而是一条排序闭环。

Sparse retrieval 负责精确符号和词面证据,dense retrieval 负责语义改写和概念匹配,hybrid fusion 把多个通道变成候选池,reranker 再判断哪些证据真正支持用户问题。metadata filter、版本控制、权限、新鲜度、多样性和上下文打包,同样是排序质量的一部分。

一个实用原则是:

召回要宽,重排要准,上下文要精,评测要分桶。

如果系统只在“向量 top-k”上调参,问题会反复出现:召回不到、排错、引用不支持、旧版本混入、低置信度强答。把检索层工程化以后,RAG 才能从 demo 走向可维护的生产系统。

下一篇可以继续写:RAG 的低置信度与拒答策略怎么做:什么时候回答、追问和转人工

参考资料