TL;DR
- 一个反直觉的事实:检索召回率做到 100%,端到端准确率依然可能很难看。
- 原因是 Lost in the Middle:decoder-only 模型对 prompt 首尾注意力强、中间弱,准确率沿位置呈 U 形。
- 诊断方式不是"感觉",是位置扫描(position sweep)------固定干扰项,只移动 gold 片段。
- 修复按收益排序:重排到更少片段 > 双边摆放 > 问题后置 > 先引用再作答。
- 一个容易被忽略的结论:更大的上下文窗口不是解药,它在几千 token 就会发作。
一、问题现场:检索日志说我对,答案说我错
我们线上一个客服问答,用户问某类申请的有效期。
检索器返回 10 段,第 7 段就是原句 。模型的回答来自第 2 段------一个语义上更"近"但事实错误的段落。
排查路径很容易走偏:
- ❌ 换 embedding 模型 → 没用,召回本来就是对的
- ❌ 加更多片段 → 更糟,干扰项变多
- ❌ 换更大的上下文窗口 → 最贵且最没用的一条路
真正的原因是位置 。Liu et al. 2023 的《Lost in the Middle》指出:decoder-only 模型对开头与紧邻问题的内容注意力最强,中间最弱。更极端的发现是------在某些设置下,把正确答案放在中间,表现甚至不如不给任何文档,因为"看起来相关的干扰项"造成的伤害超过了正确答案带来的收益。
为什么会这样?三个机制
| 机制 | 作用 |
|---|---|
| 因果注意力的首因效应 + attention sink | decoder 中每个 token 都能看到之前的 token,首段被写进后续所有表示;大量注意力会停在开头 token |
| RoPE 的距离衰减 | 注意力随相对距离衰减,靠近问题的片段天然占优 |
| 训练数据形状 | 指令在前、待回应内容在后;"关键事实居中的长文档 + 评分信号"极罕见 |
三条叠加,中间成为两端拉力都最弱的位置。
二、先做一个能跑的诊断器
与其争论"是不是这个问题",不如量出来。核心思路:固定问题和干扰项,只把含答案的片段在 k 个槽位间移动,统计每个槽位的命中率。
两个决定实验有效性的细节:
- 干扰项必须用你自己的检索器真实返回的片段。 随机文本太容易被模型忽略,只有近似命中才会真的把模型带偏。
- 每个用例的干扰项顺序固定。 唯一变量只能是 gold 的位置。
下面是完整可运行脚本(纯标准库)。call_llm 是可注入的------请替换成你自己的模型调用。
python
# -*- coding: utf-8 -*-
"""
上下文组装三件套(纯标准库,可直接运行)
1) rrf() dense + BM25 两路排名融合,RRF 免归一化
2) edge_order() 最强证据摆首尾,最弱的沉中间
3) position_sweep() 位置扫描诊断 harness(call_llm 可注入)
注意:本演示用「按 U 形注意力权重抽样」的玩具模型替代真实 LLM,
目的是验证 harness 逻辑与曲线形状;数字是模拟值,不代表任何真实模型表现。
请在自有语料上换成真实 call_llm 复测。
"""
import random
import zlib
def _seed(text):
"""用 crc32 从 prompt 派生稳定种子,保证跨进程可复现(hash() 有随机化)"""
return zlib.crc32(text.encode("utf-8")) & 0xFFFFFFFF
def rrf(rankings, k=60):
"""多路排名融合(Reciprocal Rank Fusion),免归一化分数"""
score = {}
for r in rankings:
for pos, doc in enumerate(r, start=1):
score[doc] = score.get(doc, 0.0) + 1.0 / (k + pos)
return [d for d, _ in sorted(score.items(), key=lambda kv: (-kv[1], kv[0]))]
def edge_order(chunks):
"""已按相关性降序 -> 最好的放首尾,最弱的沉中间。[1..5] -> [1,3,5,4,2]"""
front, back = [], []
for i, c in enumerate(chunks):
(front if i % 2 == 0 else back).append(c)
return front + back[::-1]
def build_prompt(chunks, question, question_last=True):
docs = "\n\n".join(f"<doc id={i+1}>\n{c}\n</doc>" for i, c in enumerate(chunks))
if question_last:
return f"{docs}\n\nQuestion: {question}\nAnswer using the documents."
return f"Question: {question}\n\n{docs}\nAnswer using the documents."
def u_attention(n, floor=0.18, gamma=1.1):
"""模拟 decoder-only 位置偏置:首尾高、中间低"""
if n == 1:
return [1.0]
return [floor + (1 - floor) * (abs(2 * (i / (n - 1)) - 1) ** gamma) for i in range(n)]
def flat_attention(n, floor=0.55):
"""修复后的注意力:谷底抬高、整体平坦(少片段 + 问题后置)"""
if n == 1:
return [1.0]
return [floor + (1 - floor) * (abs(2 * (i / (n - 1)) - 1) ** 0.6) for i in range(n)]
def position_sweep(cases, k, call_llm):
"""固定干扰项,只把 gold 片段在 k 个槽位间移动,统计每个槽位的命中率"""
hits = [0] * k
for case in cases:
others = case["distractors"][:k - 1]
for pos in range(k):
chunks = others[:pos] + [case["gold"]] + others[pos:]
if call_llm(build_prompt(chunks, case["question"]), chunks) == case["gold"]:
hits[pos] += 1
return [h / len(cases) for h in hits]
def report(tag, curve):
edge = (curve[0] + curve[-1]) / 2
mid = sum(curve[1:-1]) / max(len(curve[1:-1]), 1)
avg = sum(curve) / len(curve)
print(f"[SWEEP] {tag}")
print(" " + " ".join(f"槽{i+1}:{v:.2f}" for i, v in enumerate(curve)))
print(f" 平均 {avg:.2f} | 首尾均值 {edge:.2f} / 中间均值 {mid:.2f} "
f"| 最差槽位 {min(curve):.2f}\n")
def make_cases(n=120, seed=7):
"""构造 n 个互不相同的用例,保证每次抽样的 prompt 唯一、抽样独立"""
rng = random.Random(seed)
cases = []
for i in range(n):
gold = f"【条款{i}】该项申请在 {rng.choice([14, 15, 7, 30])} 天后失效。"
distractors = [f"【片段{i}-{j}】关于适用范围、时效与责任边界的通用说明{j}。"
for j in range(9)]
cases.append({"question": f"第{i}号申请的有效期是多久?",
"gold": gold, "distractors": distractors})
return cases
if __name__ == "__main__":
dense = ["d3", "d1", "d9", "d7", "d2"]
bm25 = ["d7", "d9", "d1", "d4", "d3"]
print("[RRF] dense =", dense, " (语义近,漏精确型号)")
print("[RRF] bm25 =", bm25, " (精确命中 ERR_00231 所在段)")
print("[RRF] fused =", rrf([dense, bm25]), " # d7/d9 两路共现被顶前\n")
print("[EDGE] 原序 1..5 ->", edge_order([1, 2, 3, 4, 5]), "\n")
cases = make_cases()
def llm_naive(prompt, chunks):
w = u_attention(len(chunks))
rng = random.Random(_seed(prompt))
return chunks[rng.choices(range(len(chunks)), weights=w, k=1)[0]]
def llm_fixed(prompt, chunks):
w = flat_attention(len(chunks))
rng = random.Random(_seed(prompt + "#fixed"))
return chunks[rng.choices(range(len(chunks)), weights=w, k=1)[0]]
before = position_sweep(cases, 10, llm_naive)
after = position_sweep(cases, 4, llm_fixed)
report("修复前 · 取 10 段 · 问题在前", before)
report("修复后 · 取 4 段 · 问题后置", after)
rng = random.Random(1)
naive_slots = [rng.randrange(10) for _ in cases]
naive_acc = sum(before[s] for s in naive_slots) / len(naive_slots)
fixed_acc = (after[0] + after[-1]) / 2 # edge_order: 最强证据必落首或尾
print("[流水线] 朴素 top-10 按原序 : {:.2f}".format(naive_acc))
print("[流水线] 重排 top-4 + 双边摆放 + 后置: {:.2f}".format(fixed_acc))
print("[结论] 最差槽位 {:.2f} -> {:.2f};首尾与中间落差 {:+.2f} -> {:+.2f}".format(
min(before), min(after),
(before[0] + before[-1]) / 2 - sum(before[1:-1]) / 9,
(after[0] + after[-1]) / 2 - sum(after[1:-1]) / 2))
实机运行输出
ini
[RRF] dense = ['d3', 'd1', 'd9', 'd7', 'd2'] (语义近,漏精确型号)
[RRF] bm25 = ['d7', 'd9', 'd1', 'd4', 'd3'] (精确命中 ERR_00231 所在段)
[RRF] fused = ['d7', 'd1', 'd9', 'd3', 'd4', 'd2'] # d7/d9 两路共现被顶前
[EDGE] 原序 1..5 -> [1, 3, 5, 4, 2]
[SWEEP] 修复前 · 取 10 段 · 问题在前
槽1:0.25 槽2:0.14 槽3:0.13 槽4:0.11 槽5:0.03 槽6:0.01 槽7:0.05 槽8:0.06 槽9:0.07 槽10:0.18
平均 0.10 | 首尾均值 0.22 / 中间均值 0.08 | 最差槽位 0.01
[SWEEP] 修复后 · 取 4 段 · 问题后置
槽1:0.27 槽2:0.20 槽3:0.20 槽4:0.36
平均 0.26 | 首尾均值 0.31 / 中间均值 0.20 | 最差槽位 0.20
[流水线] 朴素 top-10 按原序 : 0.10
[流水线] 重排 top-4 + 双边摆放 + 后置: 0.31
[结论] 最差槽位 0.01 -> 0.20;首尾与中间落差 +0.15 -> +0.11
怎么读这个输出:
- 修复前是标准的 U 形:槽 1 是 0.25、槽 6 掉到 0.01 、槽 10 回到 0.18。最差槽位和最好槽位差了 25 倍。
- 修复后(4 段 + 问题后置):最低也是 0.20,平均从 0.10 抬到 0.26。
- 流水线口径:把"gold 随机落在 10 个槽位"换成"用 edge_order 保证它落在首或尾",端到端从 0.10 → 0.31。
⚠️ 数字纪律:上表用的是按 U 形权重抽样的模拟模型 ,用来验证 harness 逻辑与曲线形状,不是任何真实模型的实测值 。把你自己的
call_llm接进去,才是有意义的数字。另外注意zlib.crc32派生种子是为了跨进程复现 ------如果你直接对prompt调hash(),Python 的哈希随机化会让每次运行结果都不同。
三、RAG 的两条腿:为什么 BM25 省不掉
上面 RRF 那段不是凑数的示例,它对应一个很实际的翻车场景。
企业知识库里大量内容是型号、编号、错误码、配置路径 :ERR_00231、A100-X3、v2.3-beta。向量模型对这类短码的语义理解很弱------用户问 ERR_00231,dense 检索召回的是一堆"语义相近"的错误处理片段,就是命不中目标条目。
BM25 治的正是这个病:它奖励精确词命中,尤其是稀有词。
所以生产级方案是混合检索 :dense 管语义、BM25 管精确,各召回 30--50 条再融合。融合方式推荐 RRF ,因为它不需要归一化不同检索器的分数 ,直接按排名累加 1/(k+rank),工程上最省心。
看上面输出:dense 的 top-1 是 d3、BM25 的 top-1 是 d7,两路各有偏好;融合后 d7、d9 因为两路共现被顶到最前 ------这正是 RRF 的价值:同时被两条腿认可的结果,比只在一条腿里排第一的结果更可信。
四、重排:收益最高的一步,也是最常被省掉的一步
第一阶段的向量打分衡量的是"语义相近程度",而 "语义相近"不等于"能回答问题"。重排用 cross-encoder 把问题和每条候选拼在一起做精细打分,这就是它"能把相关变成最相关"的原因。
一个国内团队的实测参照(作者自测):在 Dify 检索节点后挂一个重排节点,用 bge-reranker-v2-m3 重新打分,30 个测试问题里"一次就对 "的比例从约 40% 提升到约 65%。作者的评价是"它几乎是免费的效果增益"。
参数经验 :Top-K 设 5,重排后取前 3 送入上下文。K 太小信息量不足,K 太大噪声爆炸。常见工程结构是双塔粗排 + 交叉编码器精排的两段式。
但重排不是"无脑加"------什么时候该跳过
一份 2026 年的 RAG 架构决策记录列得很清楚,以下情况应该跳过重排:
- 语料是干净同质的散文,dense recall@20 已经很高;
- 端到端延迟预算很紧(语音助手、自动补全,几百毫秒就是全部预算);
- 评测显示融合后的 top-5 已几乎每题都含答案;
- 以及最关键的:你还测不了检索质量的时候。 因为你分不清"有用的重排"和"只是换了个顺序的重排"。先建评测集,再决定要不要重排。
五、踩坑记录(7 条,都是真踩过的)
1. 元数据过滤不能放在融合之后。 对融合后的 top-50 做后置过滤,多租户场景可能只剩 3 条甚至 0 条。过滤必须前置到两条检索腿内部。
2. 重排器是第二个数据泄漏点。 多租户系统务必确认 reranker 永远看不到调用者无权阅读的段落------它拿到的候选集合,就是一个新的泄漏面。
3. 索引漂移会让融合系统性偏心。 一条文档 embedding 了但没进词法索引(或反之),RRF 就会稳定偏向另一条腿。需要一个每日对账 document id 的任务,别等线上乱了才查。
4. cross-encoder 有最大序列长度,且是静默截断。 长 chunk + query 超过限制会被默默截断,不报错。必须查清你所用的 reranker 的文档限制,并据此控制 chunk 尺寸。
5. BM25 完全依赖 analyzer,换多语言 embedding 救不了它。 词干化策略、停用词表、以及 E4012、v2.3-beta 这类标识符的切分方式,直接决定精确匹配能不能命中。多语言语料要按语言配 analyzer。
6. 更大的窗口不解决问题。 很多人的第一反应是"换个 200K 窗口的模型"。这只会让你装更多片段,得到一条更长更深的中间 。窗口大小是容量限制,Lost in the Middle 是使用问题,几千 token 就会发作。
7. 评测顺序错了会白干半个月。 不要先看"回答好不好看"。先单独评估检索 (recall@20/50、MRR、nDCG@10),检索拿到高分,再评估生成(faithfulness、answer relevance)。公开基准(BEIR、MTEB)只用于初筛,你自己的语料才是那个 benchmark。
六、修复清单(可直接抄进 PR)
css
1. 入库层
☐ 文档解析:表格/阅读顺序/图表必须显式处理(PDF 只是绘图指令,不报错但会乱序)
☐ 语义感知切分:按结构切,超长按句子边界,overlap 10%-20%
☐ chunk 300-800 token,overlap 50-100 字符(多组对比,用召回率说话)
☐ chunk 必带元数据:来源文档 / 章节路径 / 页码 / 时间戳 / 权限级别
☐ 给每个 chunk 附一小段来源注解(Contextual Retrieval),向量与词法索引都用它
2. 检索层
☐ 混合检索:dense + BM25,各召回 30-50 条
☐ 融合用 RRF(k=60),免归一化
☐ 元数据过滤前置到两条腿内部,禁止后置过滤
3. 重排层
☐ cross-encoder 精排,top-50 → top-4
☐ 确认 reranker 不是新的数据泄漏面
☐ 确认 reranker 的最大序列长度,同款控制 chunk 尺寸
4. 组装层
☐ edge_order:最好的放首、次好的放尾
☐ 问题放在文档之后(模板翻转)
☐ 要求先输出 <quote> + doc id,再作答
☐ 数量下限:有效片段 < 2 条直接拒答
☐ 阈值分业务:合同/财务/法务/人事调高,客服/产品介绍可放宽
5. 评测层
☐ 先测检索(recall@k / MRR / nDCG),再测生成(faithfulness)
☐ 跑一次位置扫描,确认曲线是否 U 形
☐ 校验:模型引用的 doc id 是否落在重排器信任的候选集内
七、小结
- 检索的终点不是召回率,是注意力分配。 把正确的东西放进 prompt 只完成一半,另一半是决定它落在第几个槽位。
- 上限在入库时就定了。 解析、切分、注解、混合检索决定了组装能达到的天花板;重排和双边摆放是杠杆,杠杆撬不动烂原料。
- 更少的上下文通常同时意味着更准和更便宜。 这是一件"做对的事和做便宜的事指向同一方向"的少见情况。
今天就做的一件事 :挑 30 个"检索没错但答错了"的历史问题,把上面的脚本接上你自己的模型,跑一次位置扫描。如果曲线是 U 形,你已经找到了不用换模型、不用加预算就能拿到的那部分收益。