RAG 上下文剪枝实战:用小模型砍掉 68% 废话还保住 96% 召回
动机 · 为什么固定 top-N 不行 · Listwise grader 方法 · 本地复现的实测数字 · 分层归因 · 落地优化 · 何时用/不用
本篇是 RAG 主线 检索后 / 上下文构造环节的实现级深潜,主线只做概览、剪枝细节在此展开。
🧠 一句话记忆锚点
固定 top-N 截断是"逐点"打分,处理不了 chunk 之间有依赖的多跳问题。让一个小 LLM 一次"看完所有候选再打分"(listwise),自适应丢掉冗余、保住互补——实测砍掉 ~68% 上下文、召回反升到 ~96%。pruner 是 reranker 的放大器,不是替代品。
名词速查
首次阅读可跳过本表,直接看「场景问题」,遇到生词再回查。
本篇是检索里「检索后 / 上下文构造」子域的名词归属页。下面前四个词的完整解释只在这里;主线的 chunking、HNSW、Rerank 等词在 RAG 主线。
| 名词 | 后台类比 | 在系统里干什么 | 类比失效边界 |
|---|---|---|---|
| Listwise 打分 | 批量校验而非逐条校验——把整批一起看,才能发现"这两条重复了""这条缺了那条就没意义" | 让小模型一次看完全部候选 chunk 再统一打分,因此能识别候选之间的冗余与互补关系 | 批量校验通常只是把单条逻辑循环一遍,结果与逐条一致;listwise 的结果依赖候选一起出现——同一个 chunk 在不同候选集里会得到不同分数。代价是候选集必须整体塞进 pruner 的上下文,候选数受它的窗口限制 |
| Pointwise 打分 | 逐条独立校验——每条只看自己,判完就下一条 | 传统 rerank 的方式:每个 chunk 单独与 query 打分,彼此不可见 | 逐条校验在无状态场景完全正确;pointwise 的盲区是它无法知道两个高分 chunk 说的是同一件事(冗余),也无法知道某个低分 chunk 是另一个的必要补充(多跳依赖)。这正是固定 top-N 在多跳问题上失灵的根因 |
| 自适应保留数 | 动态扩缩容 / 按负载调整批大小——而不是写死一个固定值 | 按本次候选的实际质量分布决定保留几个 chunk:简单问题留 2 个,多跳问题留 8 个 | 扩缩容有明确的容量指标(CPU/QPS)可依;这里的"该留几个"由模型判断,没有客观标准——它可能在长尾 query 上留得过少而砍掉关键证据。所以必须配一个保底下限,不能让它自由降到 0 |
| 上下文剪枝(Context Pruning) | 响应字段裁剪(GraphQL 只取需要的字段)——传输前先砍掉无用负载 | 在召回之后、塞进 prompt 之前砍掉废话,降低 token 成本、缓解"迷失在中间" | 字段裁剪由调用方精确指定,砍掉的一定不需要;剪枝是模型的概率判断,砍错了就是永久丢失证据且下游无从察觉——生成侧只会看到"资料里没提",不会知道"资料被砍了" |
本篇引用的其他上下文名词
| 名词 | 在本篇里的角色 | 完整解释去哪读 |
|---|---|---|
| Chunking / Rerank / Cross-Encoder / 迷失在中间 / 召回率 | 剪枝所处的主线环节与上下游 | RAG 主线 · 名词速查 |
| token 经济 / 上下文膨胀 | 剪枝要省的那笔成本 | 成本与延迟 · 名词速查 |
| context(注意力窗口) | 剪枝要适配的物理上限 | 推理与微调优化 · 名词速查 |
本文是一次可复现实验的沉淀:在本地笔记本上复现 kapa.ai 的 listwise 上下文剪枝方法——训练/调用一个小 LLM 评判每条检索到的 chunk 对当前问题是否真有用,从而丢掉 ~68% 冗余上下文、同时把答案召回率维持在 ~96%。是 RAG 检索增强生成 里"进阶范式"的展开。
场景问题
检索回来的一堆 chunk,大部分并没被答案用到
先看一次典型 RAG generator 调用里 token 花在哪。以实测的监控语料为例(top_m=10,未剪枝),generator prompt 中位数是 902 token:
一次未剪枝的 generator 调用(实测中位数 902 tokens)
┌─────────────────────────────────────────────────────────────┐
│ system prompt ~40 tokens │
│ 用户问题 ~25 tokens │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 检索上下文 ~830 tokens│
│ (10 条 chunk,每条 80–120 tokens) │
└─────────────────────────────────────────────────────────────┘
这一大块检索上下文里,大部分 chunk 并不真被答案用到。你既在为无关信息付账,又在给模型制造长上下文噪声——注意力被稀释(对应 RAG 主文 里的"迷失在中间"),答案质量反而可能下降。剪枝后中位 prompt 掉到 196 token(挤掉 78%),后面会看到 recall 不降反升。
下图:8 条候选 chunk 全部交给 listwise grader"通读打分",保留 3 条真有用的(转绿),冗余的 5 条淡出丢弃——不是按固定名次一刀切,而是按内容自适应。
为什么"把 top_m 从 8 改成 3"这种一刀切不行
最直觉的做法是把保留数调小,一刀切掉一半。这里藏着一个致命假设:rerank 分数是 well-calibrated 的——"第 3 名和第 4 名之间有明确分界线"。真实 rerank 打分完全不是这样:
- Rerank 是 pointwise:对每条
(question, chunk)单独打分,两条分数接近的 chunk 之间无法互相比较。 - 于是两种情况分数上长得一模一样,却要相反处理:
- 情况 A:两条内容重复 → 保留一条就够,另一条纯浪费。
- 情况 B:两条各含答案的一半 → 两条都得留,砍掉就丢答案。
光看分数分不出 A / B。任何"固定阈值 / 固定 N"策略在情况 B 上必然翻车——这正是主文召回漏斗里说的"多跳问题上单纯调 rerank 无效"。得有个能"看完所有候选再打分"的机制,也就是 listwise。
实现方案
在 rerank 与 generator 之间插一个 listwise grader
Grader 是个小 LLM,system prompt 就是这张打分表:
| 分数 | 名称 | 含义 |
|---|---|---|
| 5 | ESSENTIAL | 没有这条就答不出来 |
| 4 | CONTRIBUTING | 和别的 chunk 组合才提供必要信息 |
| 3 | SUPPORTING | 相关,但不是必须 |
| 2 | TANGENTIAL | 只是术语沾边 |
| 1 | UNRELATED | 完全无关 |
两个关键参数:
- threshold:主旋钮,压缩率 ↔ recall 的取舍。默认 3(≥ SUPPORTING 保留)。
- keep_top_k:兜底,永远保留 rerank 排名前 K 条,无论 grader 打几分——防止 grader 抽风。默认 2。
kapa 生产数字:压缩 68% chunk、recall 保 96%、加 0.7s 延迟但净省 34% per-query 成本。数字漂亮,但值不值得信要在自己的语料 + 自己的模型上跑一遍。
打个比方:上下文剪枝就像做菜前的配菜——一大堆食材从冷柜拿出来(rerank top_m 候选),按菜谱只挑真正下锅的那几味(listwise grader 通读打分 1~5),剩下的整整齐齐放回去;固定 top-N 相当于闭着眼数三个食材,简单菜够用、多味杂菜不是漏关键就是塞太满。类比失效边界:现实里锅越大装越多,模型的上下文窗口却是硬约束且有"迷失在中间"——长上下文中段最容易被模型忽略,塞得越满召回反而越低;所以剪枝不只是省 token,更要把真正的关键证据放到 prompt 头尾、把冗余从中段挤走,且 pruner 是 reranker 的放大器——上游没召回的关键 chunk,剪枝层再聪明也变不出来(呼应"下游只能删不能补")。
复现的组件选型
| 组件 | 选型 |
|---|---|
| 向量库 | zvec 0.5.1(in-process,默认 HNSW,"向量领域的 SQLite") |
| Embedding | all-MiniLM-L6-v2(384 维,L2 归一化取 cosine) |
| Reranker | cross-encoder/ms-marco-MiniLM-L-6-v2(22.7M,pointwise) |
| Grader / 生成 | Ollama 上 qwen3.6:latest(本机现有,非最优,见优化章) |
| 语料 | 40 chunk × 2 套 + 15 题人工标注 gold_chunk_ids(单跳/双跳/多跳) |
| 策略对照 | naive(按 rerank 顺序留前 N 条) vs listwise(threshold=3, keep_top_k=2) |
| 硬件 | Apple Silicon 笔记本,纯 CPU,Ollama 串行 |
抽象设计——换向量库/换策略/换模型都只改一层,pipeline 零改动:
class VectorStore(Protocol): # 换 Milvus / Qdrant 只改实现
def upsert(self, ids, texts, vectors): ...
def query(self, vector, top_n) -> list[Hit]: ...
class Pruner(Protocol): # 换策略、加实验都在这层
def prune(self, question, chunks) -> PruneResult: ...
class LLMClient(Protocol): # 换 grader / 换生成端都在这层
def chat(self, messages, response_format=None) -> str: ...
评价口径(避免"数字好看就等于方法胜出")
对每题:
- recall@needed =
|gold ∩ kept| / |gold|(保住了多少必要 chunk) - compression =
1 - kept_count / rerank_count(砍掉多少) - tokens_saved_net = generator prompt 省下的 token 减去 grader 消耗的 token
判定分三层假设,混淆这三层是错误结论的最常见来源:
- H1(代码正确):所有分支、parser 回退、CLI 端到端——由
pytest保证,与模型无关。 - H2(方法有效):同一前置流水线下,listwise 的压缩率不低于 naive、recall 也不低于 naive。依赖模型与数据。
- H3(成本正负):含 grader 消耗的净节省是否为正——这是模型选型问题,不是方法本身的问题。
典型误判:「pytest 全绿所以方法有效」(H1→H2)、「这台机器净成本为负所以方法没用」(H3→H2)。
为什么这么做(实测数据)
简单语料上打平,多跳语料上双赢
第一份(Python/HTTP/RAG,单跳为主):naive 与 listwise 打平——recall 都 96.7%,压缩率 60.8% vs 62.5%。简单事实题 naive top-3 已够用,自适应优势体现不出来。
第二份(后台监控知识库,多跳占 10/15):主题覆盖 Prometheus/PromQL/Alertmanager/SLO/Grafana/日志/OTel/SRE golden signals/K8s 探针/事故复盘/容量规划。多个 chunk 概念互相引用("降告警风暴"同时涉及 Alertmanager inhibit 和 SRE alert fatigue)。
| 指标 | Naive top-4 | Listwise(thresh=3, keep_top_k=2) | Δ |
|---|---|---|---|
| 平均压缩率 | 0.600 | 0.693 | +9.3pt ↑ |
| 平均 recall@needed | 0.778 | 0.889 | +11.1pt ↑ |
| 完整保留 gold 集比例 | 66.7% | 73.3% | +6.6pt ↑ |
| 平均保留 chunk 数 | 4.00(恒定) | 3.07(区间 2–6) | 更狠 |
| Generator prompt 剪枝前后中位 | 902 → 902 | 902 → 196 | −78% |
| Net 平均节省 token(扣 grader) | +545.6 | −62.2 | grader 选型问题(H3) |
这次双赢:压缩率和 recall 同向改善 → 按判定标准达成 H2 强通过。净 token 为负是因为用了 23GB 通用大模型当 grader(H3 未过),换小模型即翻正。
分层看最有说服力:按 gold_ids 数量拆开
gold=1 (单跳事实, n=5): naive 1.00 | listwise 1.00 ← 打平(简单题不需要 listwise)
gold=2 (双跳组合, n=7): naive 0.71 | listwise 0.86 ← +15pt
gold=3 (多跳/泛问, n=3): naive 0.56 | listwise 0.78 ← +22pt
(recall@needed,按 gold 数量分层)
问题越依赖多条 chunk 协同,naive 失效面越大,listwise 相对收益越大。方法有效性的门槛是问题复杂度,不是数据量——n=15 就能看出结构性趋势,bootstrap 重采样给出 recall 差异的 90% 置信区间 [+3pt, +19pt],不跨 0。
自适应的具体面貌
naive 保留数: [4]×15 ← 恒定
listwise 保留数:{2: 7题, 3: 1题, 4: 4题, 5: 1题, 6: 2题}
简单事实题(PromQL / golden signals / K8s 探针):留 2 条 ← 主动压得更狠
多跳题(事故复盘 / 监控成本 / 长期存储): 留 4–6 条 ← 主动加多
它不是"更聪明的截断阈值",是给每题算一个合适的 N。naive 无论怎么调 keep_n 都做不到:调大则简单题多留无关块,调小则多跳题直接崩。listwise 是这个二元选择之外的第三条路。
为什么别的选择不行
两条容易被忽视的判断(面试追问必答)
判断 1:listwise 是 reranker 的放大器,不是替代品。
15 题里有 1 题 listwise 也救不回——gold chunk 根本没进 rerank 的 top-10。剪枝层只能删已有的,找不回上游丢的(呼应主文召回漏斗"下游只能删不能补")。推论:想让 listwise 上限更高,先提升 reranker 质量;上 listwise 时可以顺手把 top_m 加大(8→10),给 grader 更宽的自助餐让它自己挑——同样加大 top_m 对 naive 顶多是纯亏。
判断 2:延迟数字要看架构,不看绝对值。 本机 grader 单次 ~38s 看着吓人,但意义取决于位置:
- 同步单次 RAG:pruner 在关键路径。预算 3s 时多花 0.7s 只有压缩收益极大才划算 → 多半 no。
- Agentic 多工具循环:一个问题触发 3–10 次检索,0.7s×5 相对生成的几十秒可忽略 → 多半 yes。
决策树不是"listwise 比 naive 好吗",而是"以我的架构每回合做几次检索为前提,拿到的压缩值不值得这些延迟和 grader token"。
每题诊断:naive 塌陷的 5 题
| 问题 | naive | listwise | 原因 |
|---|---|---|---|
| Grafana 面板慢 | 0.00 | 0.50 | 需 recording-rule + panel-cache,后者被 rerank 挤到第 5 位 |
| 事故复盘做什么 | 0.33 | 0.67 | 需 postmortem + MTTR + runbook 三条 |
| 监控存储成本 | 0.33 | 0.67 | 需 monitoring-cost + prom-tsdb + logs-retention |
| 如何避免告警风暴 | 0.50 | 1.00 | 需 inhibit + alert-fatigue,listwise 完全救回 |
| blackbox vs whitebox | 0.50 | 0.50 | gold 根本没进 rerank top-10(上游召回失败,pruner 无能为力) |
naive 塌陷的 5 题里 listwise 救回 3 题;最后 1 题任何 pruner 都救不了——根因在剪枝层之上,再次印证判断 1。
沉淀结论
落地优化(按预期收益排序)
- 【最高】换真正的小模型当 grader:
qwen3.6(23GB)单次 ~38s 是"模型规模对不上任务"——5 档打分是简单 classification,300M–3B 足够。换qwen2.5:0.5b/phi3:mini→ 延迟降到亚秒级、net token 从 −62 翻正到 +400 以上(H3 通过),recall 仅跌 1–3pt。 - 【次高】把 rerank score 喂给 grader:
_format_chunks里带上rerank_score=...并在 prompt 说明"降序排列,作为软先验但按内容判断"。多跳题 recall 再涨 5–10pt。 - 【中】加大 top_m:让 grader 多看几条,upstream recall 涨,listwise 自适应把无关的丢掉;naive 反而要相应加大
keep_n、压缩率掉——差距进一步拉大。 - 【长期】蒸馏成专用小分类器:采集
(question, chunk, rerank_score, grader_score)四元组,5 万条训一个 bi-encoder 版分类器替换 LLM 调用 → pruner 延迟秒级降毫秒级、成本账彻底翻正、recall 掉 ~1pt。 - 【运维】把 pruner 放到 agentic 检索工具链,而非同步 API 单次调用——不改算法,只换部署位置,延迟从"用户能感觉到"变"感觉不到"。
什么时候用 / 不用
该用:多次检索的 agentic 流水线(延迟能摊薄);成本敏感、检索上下文占 token 大头;查询复杂度差异大;多跳/泛问占比高的知识库(本次核心证据)。
不该用:秒级同步 RAG UI + 已经很锐利的 reranker(多 0.7s 用户能感觉到);检索集本来就小(top-3/4,pruner 只能删不能补);还没有标注评测集(旋钮没有锚点,先建 20–100 题再调参)。
心法总结
问题:top-N 截断是逐点打分,处理不了"chunk 之间有依赖"的多跳问题。方法:一次调用小 LLM 给所有 chunk 打 5 档分,threshold + keep-top-K 自适应过滤。证据:多跳语料 recall +11pt、压缩率 +9pt,且复杂度越高收益越大。落地:先换小 grader 让成本翻正 → 喂 rerank score 提 recall → 蒸馏到本地小模型。警惕:pruner 是 reranker 的放大器不是替代品——先确认上游 rerank top-M 的 gold coverage 再加这层。
面试常见问题清单(按主题分类)
动机
- Q:检索都做了 rerank,为什么还要剪枝? A:rerank 是 pointwise(对每条单独打分),分不出"两条重复只留一条"与"两条各含答案一半都得留";且长上下文稀释注意力(迷失在中间),还多花 token。
- Q:为什么不直接把 top_m 调小? A:那等于假设 rerank 分数 well-calibrated、名次间有清晰分界。多跳问题里答案分散在多条互补 chunk,一刀切必然丢答案。
方法
- Q:listwise 和 pointwise 的本质区别? A:listwise 让模型一次看完所有候选再打分,能利用 chunk 间的冗余/互补关系;pointwise 各打各的、互不可比。
- Q:grader 用多大的模型? A:5 档打分是简单分类,300M–3B 足够(甚至可蒸馏成 bi-encoder 分类器),不该用 20B+ 大模型——否则延迟/成本账翻不正。
落地与评测
- Q:剪枝会不会把召回搞低? A:实测多跳语料 recall +11pt、压缩率同时 +9pt;简单语料打平。关键是先有标注评测集给旋钮定锚点。
- Q:什么时候不该上剪枝? A:秒级同步 UI + 已很锐利的 reranker(多 0.7s 用户有感);检索集本来就小(pruner 只能删不能补);还没有评测集。
延伸阅读:RAG 检索增强生成 · 推理与微调优化 · Agent 开发
记忆口诀
- 病根:rerank 逐点打分 / 重复与互补同分 / 多跳一刀切必丢答案
- 药方:小 LLM 通读全部候选 / 5 档打分 / threshold + keep_top_k 自适应留 N
- 三层判定:H1 代码对(pytest)/ H2 方法赢(压缩↑且 recall↑)/ H3 成本正(换小 grader)
- 铁律:pruner 是 reranker 的放大器不是替代品 / 只能删不能补 / 先保上游 gold coverage
内容来源
本地复现实验(rag_pruner/ + OpenSpec change add-listwise-context-pruner),原方法出自 kapa.ai — How we prune RAG context(2026-07 复现;数字为本机 CPU + 小型 grader 实测,换模型/换 reranker 会漂,方向性结论以分层归因为准)。
自测:合上资料能说清楚吗?
- 检索已经做了 rerank,为什么还要再插一层剪枝?
参考答案
rerank 是 pointwise:对每条 chunk 单独打分,分不出「两条重复只留一条」与「两条各含答案一半都得留」;且过长上下文稀释注意力(迷失在中间)、多花 token。
- 对比 pointwise rerank 与 listwise grader 的本质差异,为什么后者能处理多跳问题?
参考答案
pointwise 各打各的、分数互不可比;listwise 一次通读全部候选再打分,能利用 chunk 间的冗余/互补关系,为每题算出合适的 N,从而在多跳题保住互补 chunk。
- threshold 和 keep_top_k 各管什么?为什么要两个旋钮?
参考答案
threshold(默认 3)是主旋钮,调压缩率↔recall 取舍;keep_top_k(默认 2)是兜底,永远保留 rerank 前 K 条,防 grader 抽风误删。
- 实验里「pytest 全绿」和「本机净 token 为负」为什么都不能否定方法?
参考答案
三层假设别混:H1 代码正确(pytest 保证,与模型无关);H2 方法有效(压缩↑且 recall↑,已强通过);H3 成本正负是模型选型问题——用 23GB 大模型当 grader 才为负,换小模型即翻正。
- 什么场景不该上 listwise 剪枝?
参考答案
秒级同步 UI + 已很锐利的 reranker(多 0.7s 用户有感);检索集本来就小(top-3/4,pruner 只能删不能补);还没有标注评测集(旋钮无锚点)。核心:pruner 是 reranker 的放大器不是替代品。