RAG 检索全对,回答全错?用位置扫描 10 分钟定位 Lost in the Middle

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 个槽位间移动,统计每个槽位的命中率。

两个决定实验有效性的细节:

  1. 干扰项必须用你自己的检索器真实返回的片段。 随机文本太容易被模型忽略,只有近似命中才会真的把模型带偏。
  2. 每个用例的干扰项顺序固定。 唯一变量只能是 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 是否落在重排器信任的候选集内

七、小结

  1. 检索的终点不是召回率,是注意力分配。 把正确的东西放进 prompt 只完成一半,另一半是决定它落在第几个槽位。
  2. 上限在入库时就定了。 解析、切分、注解、混合检索决定了组装能达到的天花板;重排和双边摆放是杠杆,杠杆撬不动烂原料。
  3. 更少的上下文通常同时意味着更准和更便宜。 这是一件"做对的事和做便宜的事指向同一方向"的少见情况。

今天就做的一件事 :挑 30 个"检索没错但答错了"的历史问题,把上面的脚本接上你自己的模型,跑一次位置扫描。如果曲线是 U 形,你已经找到了不用换模型、不用加预算就能拿到的那部分收益。

相关推荐
alonglong2 小时前
183 行 llmctl:把 4 个启动脚本收成一个 CLI,总控该管什么、不该管什么
人工智能
野生码农AI实战2 小时前
一句追问查出停滞 50 天的口径烂账,我把团队规则的锚换到了数字团队花名册
人工智能
宇擎智脑科技2 小时前
Sirchmunk 深度解析(一):一个无需向量数据库的自进化搜索引擎架构设计
人工智能·rag·sirchmunk
小魚8842 小时前
豆包工作的核心能力有哪些
人工智能
用户287043906162 小时前
给一个终端编码代理装上一颗确定性情绪内核:Persisto Mate 的实现记录
人工智能
用户202252215062 小时前
实现变便宜了,品味才是稀缺:一个 AI 产品经理的 Vibe Coding 复盘
人工智能
雪雪爱冲浪2 小时前
AI 智能体如何通过 auth.md 注册 Bright Date:完整实操指南
大数据·人工智能
会议咨询2 小时前
2026年电子工程、先进制造技术与人工智能国际会议(EATA 2026)
人工智能·电子工程·先进制造