RAG 重排后上下文预算分配:相关性不等于信息增量

举报
上云 发表于 2026/11/14 09:31:37 2026/11/14
【摘要】 重排后的片段选择常按相关性分数截断,在有限 token 预算下会重复购买同一份信息。本文拆解 MMR、去冗余与按增益/token 贪心三种策略的适用边界,给出可量化的度量与判据。

重排把 50 个候选收敛到 8 个,然后按分数从高到低塞进 4k token 的上下文——这是多数 RAG 系统上线时的默认动作,也是它在真实流量下开始抖动的起点。相关性分数回答的是"这段和 query 有多像",不是"这段能为答案补上多少新东西"。预算有限时,这两个问题的答案经常相反。

预算分配到底在优化什么

把它写成优化式:给定候选集 C(重排后的 top-N,每项带 score 和 token 数)、预算 B,选子集 S,使 token 总和不超过 B,并最大化答案质量 Q(S)。Q 不可直接优化,工程上有三种替代目标:

替代目标 隐含假设 对应做法
Q(S) = Σ score 片段间信息独立 按分数排序截断
Q 取决于相关性与多样性 距离远 ≈ 信息不同 MMR
Q 是覆盖函数(子模) 增益可度量、边际递减 贪心最大化边际增益

第一种的假设在真实语料里很容易被打破:分块有 overlap、同一份文档存了多个版本、同一个问题的不同问法落在不同块里。于是你用预算买了三份相同信息,付出了 token,还引入了"同一个数字出现三次但两次是旧版"的风险。

先划清边界:重排模型怎么选、batch 多大、延迟多少属于上一环;本文从"拿到带 score 的候选列表"开始,只讨论这之后怎么花预算。

定义两个量便于后续讨论。片段 s 相对已选集 S′ 的信息增量:

I(s | S') = U(s) - overlap(s, S')

U(s) 是潜在有用信息量,overlap 是与已选集的重复部分。两者都不可直接算,后面几节分别给可计算的代理和失效场景。

还有一个反直觉但必须明确的前提:预算不是硬填满。填满有成本——延迟,以及无关上下文稀释注意力。判据是边际增益曲线,如果它在预算的 60% 处已经趋平,就该把预算降下来。

先做去冗余,再谈排序

顺序很重要。去冗余是集合级操作,成本低、收益确定;信息增益排序是精细操作,成本高。先做粗的。

重复的来源大致五类:分块 overlap(相邻块共享 10-20% 文本)、文档版本(同一手册的 v1 与 v3)、多问法(FAQ 里"如何退款"和"退款流程是什么")、父子块同命中(小片检索、大片喂模型)、多源转载。

度量分三层,可靠性和成本递增:

  • 结构化:doc_id + 版本号 + 章节路径。最可靠,前提是元数据干净。
  • 字面:shingle Jaccard(n=3~5)或 MinHash。快,适合抓转载与 overlap。
  • 语义:embedding 余弦。适合抓多问法,但对"同主题不同侧面"误杀率高。

实践中的组合是三级串联,阈值从严:

import re

def shingles(text: str, n: int = 4) -> set[str]:
    toks = re.findall(r"\w+", text.lower())
    return {" ".join(toks[i:i+n]) for i in range(len(toks) - n + 1)}

def jaccard(a: set, b: set) -> float:
    return len(a & b) / len(a | b) if a and b else 0.0

def dedup(chunks, thr=0.6):
    kept = []
    for c in sorted(chunks, key=lambda x: -x["score"]):  # 高分优先保留
        sh = shingles(c["text"])
        if any(jaccard(sh, k["_sh"]) > thr for k in kept):
            continue
        c["_sh"] = sh
        kept.append(c)
    return kept

保留策略有个细节:按 score 降序保留的是高分那份,但若两份内容相同而一份元数据更全(有标题、有日期、有出处),应按信息完整性保留。小规模下无所谓,需要引用溯源的场景会影响答案可验证性。

一个反直觉的点:别把冗余去干净。留 10-15% 预算给"重复但一致的证据"是有价值的——两个独立来源说同一件事,能明显降低编造概率,也方便做引用。去冗余的目标是去掉低价值重复,不是追求零重复。判据很简单:如果某类重复片段在离线评测里带来过答案修正(原本错的被纠正),就额外留一份。

MMR 的边界:它优化多样性,不优化信息量

MMR 的目标函数是:

argmax_{d ∈ R\S} [ λ · sim(q, d) - (1-λ) · max_{s∈S} sim(d, s) ]

它把信息增量近似成"与已选集最大相似度的补"。这个近似有两个明确的失效场景。

**失效一:同主题不同侧面。**一份 API 文档里的"鉴权"和"限流"两节,embedding 余弦常在 0.85 以上,但信息几乎不重叠。MMR 会把第二节压下去,这是 RAG 里最常见的误杀。

**失效二:不同主题但都无用。**两个都跟 query 弱相关的片段,彼此余弦 0.2,MMR 判定"多样",于是都选进来,把预算花在噪声上。

所以 MMR 的适用面是:候选集高度同质,且同质的原因是来源重复而非主题邻近。典型场景是同一文档的相邻块、同一问题的多个相似问法。一旦候选集跨文档、跨章节,多样性代理就不可靠了。

λ 的取值别照抄经验值:

  • λ = 1.0 退化为分数截断,可以作为基线。
  • 在离线集上扫 λ ∈ {0.5, 0.6, 0.7, 0.8, 0.9, 1.0},看证据覆盖率和答案正确率。
  • 实用判据:若 λ=0.9 与 λ=0.6 的正确率差小于 1 个点,说明冗余问题不严重,不要为 MMR 引入额外复杂度。

成本是 O(k·N) 次相似度计算。N=50、k=8 是 400 次,随便算;N=500 是 4000 次,如果每次都走 embedding 服务就不可接受,必须预计算候选集内的相似度矩阵(500×500 的 float32 约 1MB,可缓存)。

import numpy as np

def mmr(query_vec, cand_vecs, k, lam=0.7):
    # cand_vecs: (N, d),已 L2 归一化
    sim_q  = cand_vecs @ query_vec
    sim_cc = cand_vecs @ cand_vecs.T        # 预计算并缓存
    selected, rest = [], list(range(len(cand_vecs)))
    while rest and len(selected) < k:
        div = sim_cc[np.ix_(rest, selected)].max(axis=1) if selected else 0.0
        val = lam * sim_q[rest] - (1 - lam) * div
        i = int(np.argmax(val))
        selected.append(rest[i]); rest.pop(i)
    return selected

注意 sim_q 用的是余弦,不是重排 score。二者不能混用:重排 score 是 cross-encoder 输出,分布不可比。要混就先在候选集内做 min-max 归一化再加权,但这引入了新超参,收益通常不抵成本。更稳的做法是:MMR 只用余弦,重排 score 只用来决定候选集的截断位置 N。

按信息增益排序:什么时候值得上

候选集跨文档时 MMR 不可靠,该显式建模增益。三种代理,成本递增。

(1)查询侧覆盖——推荐作为起点。把 query 拆成子问题、实体、约束(不涉及改写,只是从 query 里抽槽位),增益等于新覆盖槽位的权重和:

def coverage_gain(chunk, covered, slot_weights):
    new_slots = set(chunk["slots"]) - covered
    return sum(slot_weights[s] for s in new_slots)

这是最大覆盖问题,贪心有 1-1/e 近似保证。便宜、可解释,失败模式也清楚:槽位抽取错了就全错。适合 query 结构清晰的场景,比如多约束检索("2023 年后、华东区、金额 > 100 万")、对比型问题。

(2)集合侧新颖度——把 MMR 的多样性项单独拎出来,但不再线性加权而是相乘:gain = rel^α · (1 - max cos)^β。乘法的好处是任一因子为 0 时增益为 0,避免"高相关低多样"被"低相关高多样"反超。

(3)生成侧增益——成本最高,慎用。让模型判断"这段是否提供了已有上下文没有的新事实"。别放在在线主链路:N=50 逐片段判断就是 50 次小模型调用。可行路径是离线用它标注一批数据、蒸馏成打分器,线上直接推理;或只对边界片段做判断。

预算约束下的贪心,关键不是按增益排序,而是按增益/token 排序:

def budget_greedy(cands, budget, gain_fn, min_tokens=32):
    chosen, spent, covered = [], 0, set()
    ids = set()
    while True:
        best, best_ratio = None, 0.0
        for c in cands:
            if c["id"] in ids or c["tokens"] < min_tokens:
                continue
            if spent + c["tokens"] > budget:
                continue
            g = gain_fn(c, covered)
            if g <= 0:
                continue
            ratio = g / c["tokens"]
            if ratio > best_ratio or (ratio == best_ratio and best and c["score"] > best["score"]):
                best, best_ratio = c, ratio
        if best is None:
            break
        chosen.append(best); ids.add(best["id"])
        spent += best["tokens"]
        covered |= set(best.get("slots", []))
    return chosen

这是背包问题的贪心近似——不是 1-1/e,那个保证只对纯子模的覆盖函数成立,带权乘积形式不一定子模,别把理论保证当护身符。三个坑要提前填:

  • 短片段刷分:20 token 的片段 ratio 天然高。加 min_tokens 门槛,同时设绝对增益下限。
  • 次序偏差:ratio 贪心会错过"高增益高成本"的关键片段。若某片段是唯一覆盖某槽位的,给稀缺槽位加权。
  • 结果抖动:ratio 接近时结果不稳定,同样的输入两次跑出不同上下文,排查问题极其痛苦。必须加确定性 tie-break。

还有一处容易忽略的裁剪:把"整块"和"块内句子"分开选。很多时候一个 512 token 的块里只有两句话有用。按段落切或者用句子级重排能显著提升预算利用率,但别切太碎,丢了上下文会让模型误读。

怎么判断选得对不对

边际增益曲线是首要工具。对每个 query,按选择顺序逐个加入片段,记录加入第 i 个后答案是否正确,汇总得到 p(i)。健康曲线在前 3-4 个片段快速上升然后趋平;一直线性上升说明排序没起作用,等价于随机;第 5 个之后还在涨,说明预算太小或关键片段被排太后。这条曲线还直接回答"预算给多少":取趋平点 +1。

证据覆盖率:人工标注每个问题"答案必须依赖哪几个片段"(通常 1-3 个),算这些片段进入最终上下文的比例。它比 context precision 更有用,因为直接测的是"信息有没有进预算"。

冗余率:最终上下文里两两 shingle Jaccard > 0.6 的片段对占比,健康值低于 10%,超过 30% 说明去重没生效或阈值太松。

token 效率:正确率 / 平均上下文 token。用来横向比策略——MMR 把 token 降了 30%、正确率只掉 0.5 个点,就是净赢。

对照表:

症状 可能原因 动作
预算翻倍正确率不涨 候选集无增量信息,或总选同一批 先查证据覆盖率,再看重排 N 是否太小
加 MMR 后正确率下降 跨文档场景,余弦近但信息不同 换覆盖式增益,或把 λ 提到 0.9
上下文里明显重复 overlap 分块 + 父子块同命中 补结构化去重与 shingle 去重
关键片段排在预算外 按 score 而非增益/token 排序 换性价比贪心,加稀缺槽位权重
小流量好、大流量抖 贪心缺确定性 tie-break 固定排序键,改用稳定排序
长尾问题答错率高 槽位抽取覆盖不到长尾 回退纯分数截断兜底

最后一条尤其重要:任何复杂选择器都要有"纯分数截断"的兜底开关。当依赖的元数据(槽位、章节路径)缺失时,回退比硬算安全。

落地建议

  1. 先量后调:在离线集上画出边际增益曲线,用趋平点定预算,而不是先定 4k 再找理由。
  2. 按顺序上线并逐层评测:结构化去重 → 字面去重(Jaccard 0.6)→ 语义去重(余弦 0.92)→ 性价比贪心。任一层带来的收益小于 1 个点就砍掉。
  3. 默认排序键用增益/token,配套三件套:min_tokens 门槛、绝对增益下限、确定性 tie-break。
  4. 保留 10-15% 预算给一致性证据,别把冗余去干净;引用溯源场景尤其如此。
  5. 设兜底与监控:元数据缺失或槽位抽取置信度低时回退纯分数截断,并把回退率作为线上指标持续观察。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。