RAG 重排后上下文预算分配:相关性不等于信息增量
重排把 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 | 固定排序键,改用稳定排序 |
| 长尾问题答错率高 | 槽位抽取覆盖不到长尾 | 回退纯分数截断兜底 |
最后一条尤其重要:任何复杂选择器都要有"纯分数截断"的兜底开关。当依赖的元数据(槽位、章节路径)缺失时,回退比硬算安全。
落地建议
- 先量后调:在离线集上画出边际增益曲线,用趋平点定预算,而不是先定 4k 再找理由。
- 按顺序上线并逐层评测:结构化去重 → 字面去重(Jaccard 0.6)→ 语义去重(余弦 0.92)→ 性价比贪心。任一层带来的收益小于 1 个点就砍掉。
- 默认排序键用增益/token,配套三件套:min_tokens 门槛、绝对增益下限、确定性 tie-break。
- 保留 10-15% 预算给一致性证据,别把冗余去干净;引用溯源场景尤其如此。
- 设兜底与监控:元数据缺失或槽位抽取置信度低时回退纯分数截断,并把回退率作为线上指标持续观察。
- 点赞
- 收藏
- 关注作者
评论(0)