最近做长文档问答的时候我发现一个很反直觉的现象上下文塞得越多模型回答的质量反而越差。一次测试里我喂了一整份合同加几十封往来邮件上下文总 token 量已经上万模型居然连一个明摆着的金额都提取错。后来我只保留了和问题最相关的几个段落同样的模型、同样的 prompt答案一下就对了。这个对比让我认真做了一轮“相关性裁剪”的实测本文就是把这次的思路、步骤、数据和翻车经验完整记录下来。这里先说明我说“相关性裁剪”不是让模型自己对上下文做摘要也不是用搜索召回去拼接一堆碎片而是在把文本交给模型之前先用低成本的方式对上下文做一次“相关性过滤”——只保留与当前任务有关的原文片段再按原文顺序重新拼接。整个过程保留了原文的表达没有新增信息。对于那些正在被“长上下文又贵又笨”困扰的人来说这套方法值得试一下。1. 先搞清楚为什么上下文越长模型反而越笨1.1 注意力机制不是无限带宽Transformer 的每个 token 都要和上下文里所有 token 计算注意力权重上下文越长注意力分布就越接近“一锅粥”。打个比方你坐在一个乱糟糟的会议室里人越多你越难从嘈杂的背景音里听清真正需要的那句话。从计算原理上看注意力分数经过 softmax 之后所有位置的权重总和是固定的所以无关内容每多一分关键信息能分到的权重就被稀释一分。这不是模型“不聪明”而是结构性的、可预见的退化。现在不少模型把上下文窗口做到几十万甚至百万 token容易给人一种“可以随便塞”的错觉。但窗口变大解决的是容量上限不是利用率。你在一个百万 token 窗口里塞进去 90 万无关文本模型照样会丢关键信息。这也是为什么我只把“窗口大”当作兜底能力而不是默认使用方式。上下文窗口更像是内存上限而相关性裁剪是在数据进入内存之前先做一次筛选。1.2 “Lost in the Middle”现象与长上下文幻觉业界有个著名现象叫 Lost in the Middle当关键信息位于上下文开头或结尾时模型的准确率明显更高一旦关键信息被埋在中间模型就很容易看漏。这意味着一个几万 token 的上下文真正能被模型高效利用的往往只有开头和结尾附近的部分。这跟人的阅读习惯有些类似开头和结尾的印象总比中间深刻。长上下文还有另一个隐患当模型找不到准确信息时它不会乖乖说“我不知道”而是倾向于生成一个看起来合理的幻觉内容。上下文越长“找不到但必须继续写”的状态越容易被触发因为模型内部需要维持连贯性而大量无关信息又提供了足够多的迷惑前缀。我实测里全量塞 12000 token 时准确率只有 40%很大程度就是这个问题造成的。1.3 无关片段会污染指令遵循更隐蔽的是无关文本不只是“占地方”它还会主动帮倒忙。我在一次测试里往上下文里混入了一张无关的数据表格结果模型连回答问题的格式都开始模仿表格数字对齐、表头全部跑偏。原因不复杂模型的指令遵循能力本质上是“在所有输入 token 里寻找最合理的输出模式”无关内容越多系统提示词的指导地位就越被削弱。可以把上下文中的无关内容理解为“竞争对手”它们在跟你的 system prompt 抢注意力。无论是格式、语气还是语义方向都可能被这些噪声带跑。所以清理无关上下文不只是为了省钱更是为了守住指令的主导权。这也是“上下文工程”和普通“提示词工程”最明显的分界提示词管的是怎么写上下文工程管的是喂什么。2. 相关性裁剪思路、边界和选型2.1 相关性裁剪到底在做什么相关性裁剪的做法一句话就能说清在调用大模型之前先把上下文切成块用 embedding 对每个块和当前任务算相关性然后丢弃低相关块只把剩余的高相关块按原文顺序拼接回 prompt。它和关键词匹配完全不同因为查询和文档之间往往没有字面重合需要语义层面对齐。有三个关键点。第一裁剪粒度是“块”不是“词”块太小会切断指代和逻辑块太大会让噪声混进来。第二相关性的参考对象是“当前任务”而不是简单的高频词统计。第三输出时必须按原文顺序重排不按相似度排序否则模型会失去因果感。第三点是我踩过最深的坑后面会细说。2.2 为什么裁剪比摘要和 RAG 更适合单文档如果你需要分析一份几十页的合同大概有三个候选方案第一让模型先总结全文再回答第二用 RAG 检索出若干片段再回答第三用相关性裁剪。我三套都试过结论非常明确。方案适合场景主要问题全文摘要概览、提炼主线摘要会丢失细节且摘要本身可能幻觉RAG 检索跨文档搜索、知识库问答取回的是碎片顺序和逻辑关系容易断裂相关性裁剪单文档内精确问答、合同审查依赖分块质量和阈值选择需要多调一次RAG 其实是为“检索”设计的不是为“阅读”设计的。它返回一堆相关片段但这些片段拼在一起往往缺少文档内部的因果链。相关性裁剪可以理解成“先检索、后重组”但重组时保留了完整的原文顺序模型读起来更像在读一份连续资料而不是读一堆便签。对于单文档裁剪通常比 RAG 更稳。2.3 与滑动窗口、上下文压缩的边界做长上下文方案时很多人会把滑动窗口、上下文压缩和相关性裁剪混为一谈。滑动窗口解决的是流式内容问题常见于多轮对话只保留最近 N 轮老消息直接丢掉。但它对单文档场景不好使因为关键信息可能出现在文档开头窗口早就滑过去了。上下文压缩的思路是把无关 token 用特殊符号替换保留语义骨架从而压缩 token 数。这种方法很有效但属于“损坏性压缩”模型看到的内容不再是原文字面对需要逐字比对数字、日期、条款的场景不适用。相关性裁剪对被保留的块来说是“无损”的它只是不做压缩只做选择。所以三者不是竞争关系而是不同环节的工兵滑窗管多轮对话的滚动压缩管 token 预算裁剪管内容质量。实际项目里可以先裁剪、再压缩最后用滑窗兜底。我这次的核心实测就是先把裁剪这一层的效果单拎出来看。3. 完整实操从切片、打分到重组上下文3.1 实验设计与评测指标我这次用了一个自己拼接的测试集一份模拟采购合同约 40 页加上几十封关于合同细节的往来邮件总 token 量大约 12000。我准备了 10 个问题覆盖金额提取、日期判断、责任边界、违约条款等类型包括合同总金额、首付款比例、违约金触发条件、验收日定义、争议解决方式、知识产权归属、保密期限、合同终止条件、逾期利息计算、通知送达方式。评测指标有三个答案准确率由人工对照真实文档判断关键数字、日期、主体不能错证据引用率看回答是否提到对应的原文条款编号或段落生成耗时从请求发出到首个 token 返回的近似时间。对照组设了四个全量基线 12000 token以及裁剪到约 6000、3000、1200 token 三个档位。3.2 切片与向量化的具体参数分块是第一步也是决定整个效果上限的一步。我用的规则是优先按段落切段落太长就按 600 字左右切段间重叠 50 字左右。太小的块虽然过滤精度高但会把上下文的指代关系切断太大的块又会让低相关内容和答案绑在一起过滤等于没过滤。600 字在中文场景里是一个不错的平衡点。重叠 50 字是给边界信息上保险。比如一个关键句子的前半在上一块、后半在下一块如果不重叠两边单独向量化后都可能因为语义残缺而拿不到足够高的相关度。切完块后我用 embedding 模型做向量化。实测中对比了 bge-m3 和 OpenAI 的 text-embedding-3-small两者整体趋势一致但 bge-m3 的相似度分数偏高阈值不能跨模型复用。参考代码如下from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(BAAI/bge-m3) chunks [...] # 结构为 [(order, text), ...] docs [text for _, text in chunks] # 归一化之后点积就是余弦相似度 doc_vecs model.encode(docs, normalize_embeddingsTrue) query 请找出合同中关于违约金比例、赔偿条件和触发方式的条款 query_vec model.encode([query], normalize_embeddingsTrue)[0] scores doc_vecs query_vec这里有个经验query 一定不能只写一个词。把“违约金”扩写成“请找出合同中关于违约金比例、赔偿条件和触发方式的条款”相关块的得分会明显拉开差距。因为 embedding 模型对完整句子更敏感一个孤零零的名词很难捕获任务意图。3.3 阈值选择与 Token 预算控制相关性得分出来以后怎么决定“留哪些块”我试过两种策略固定阈值法设定一个相似度阈值高于就保留缺点是分数分布受文本和模型影响很大阈值基本不能复用动态预算法先定一个目标 token 预算再按得分从高到低累加块直到接近预算这种方式更符合“上下文容量是硬约束”的真实场景。max_tokens 3000 def estimate_tokens(text): return len(text) * 1.3 # 中文粗略系数上线前请用真实 tokenizer scored sorted(zip(chunks, scores), keylambda x: x[1], reverseTrue) selected [] total 0 for (order, text), score in scored: if total estimate_tokens(text) max_tokens: continue selected.append((order, text, score)) total estimate_tokens(text) # 关键最后按原文顺序排回而不是按分数排 selected.sort(keylambda x: x[0])实际项目中建议用 tiktoken 或模型自带的 tokenizer 精确计算 token比例系数只适合快速验证。还有一点动态预算选完后最好快速人工扫一眼高分块的 top 条确认没有明显的方向性错漏防止 embedding 被某些长段落带偏。3.4 重组上下文与 Prompt 细节裁剪完不是直接把文本塞给模型就结束prompt 里最好写清楚“这些资料是筛选过的”。我用的模板大致是以下是从原始文档中抽取的相关片段按原文顺序排列。 片段可能不完整也可能遗漏原文中的其他信息。 请严格基于给定片段回答问题如果片段中没有足够信息请明确说“信息不足”。 [片段1] ... [片段2] ... 问题{question}加“片段可能不完整”这句话能明显减少模型过度自信的脑补。另一个技巧是给每个片段加编号并要求回答里注明依据来源例如“违约金比例为 3%见[片段2]”。这个习惯既能帮你排查幻觉也能让结果更可追溯。3.5 实测数据与对比结果我跑了一轮完整对照取的是三组问题的平均准确率结果如下方案平均上下文 token准确率证据引用率生成耗时全量基线约 1200040%30%1.0x基准裁剪到 50%约 600068%55%约 0.7x裁剪到 25%约 300082%80%约 0.5x裁剪到 10%约 120062%45%约 0.4x全量基线最差并不是因为模型不行而是因为正确答案只占全文极小比例其余 95% 的内容全在制造干扰。裁剪到 3000 token 时噪声被清走模型能够专注阅读真正与问题相关的段落再裁到 1200 token噪声虽然更少但真正的答案段可能也只留了一半信息不完整导致准确率又掉下来。所以相关性裁剪的本质是在“噪声”和“信息量”之间找平衡点。4. 实测翻车现场与排查心得4.1 隐性前提被裁掉的典型事故我第一次跑裁剪就翻车了。文档前半部分有一段“术语定义”写明“验收日 货物交付后 30 天”后面所有邮件都在讨论验收日但从未再重复定义。按相似度裁剪时术语定义段和当前问题的字面重合度很低分数不高被裁掉了。模型看到后面一堆“验收日”只能自己猜含义答案自然错得离谱。这个事故让我加了一条规则裁剪时永远保留文档的“硬保留区”包括开头的定义、术语、背景说明以及结尾的签署页、附件清单。因为合同生效日期、签署方全称等关键信息经常出现在开头和结尾但它们的 embedding 相似度可能并不高。先保住文档骨架再根据相关性决定保留哪些肌肉这样才不容易出现系统性遗漏。4.2 相似度分数不等于真实任务相关性embedding 的相似度衡量的是“语义相近程度”不是“这个块是否包含回答问题所需的证据”。两者绝大多数时候一致但坑很多。比如问题问“如果供货方延迟交付采购方可以主张什么责任”文档里有一段“延迟交付的例外条款”语义上和“延迟交付责任”很接近分数很高但这不是答案真正的责任条款在另一个段落分数反而不高。我的应对办法是选块时不要只取最高分要多打印几个高分段看一眼同时把 query 改成“任务描述 证据类型”让检索目标更明确。比如把 query 扩写成“找出合同中关于延迟交付后果、赔偿和采购方权利的条款”真正包含答案的块分数就会上升。这一步对最终效果的影响往往比换一个 embedding 模型还大。4.3 模型会把裁掉的内容脑补回来裁剪后模型面对的是一份缺失的文档但它的训练习惯是“有信息缺口就生成补全”。如果 prompt 里不提醒它片段不完整它很容易把这些缺口脑补出来然后一本正经地给出错误答案。实测中光是加了“片段可能不完整”这句话幻觉次数就明显减少。还有一个更稳的办法强制模型在回答中写证据编号比如“根据[片段3]乙方应在三日内赔付”。一旦要求编号模型就不得不把答案和具体片段绑定编造的成本和难度都会变高。这个方法在任何长文档问答场景下我都推荐它相当于给模型的输出加了一层引用约束。4.4 评测指标会骗人准确率上升也可能是敷衍只看“答案是否提到关键词”是不行的。裁剪后的模型可能学会一个偷懒策略直接从保留片段里抄一个看起来最像答案的句子哪怕句子回答的根本不是当前问题只要关键词对上了就算答对。这种表面正确会虚高准确率实际一复核就露馅。我的做法是人工复核时同时要求模型输出“支持这段话的原文片段编号”如果答案和编号对不上直接判错。更省力的方式是让另一个模型当裁判给它原始文档片段、问题和模型答案让它判断答案是否忠实于原文。把这段逻辑写进评测脚本比肉眼抽查可靠得多。4.5 裁剪本身也有成本但可以忽略有人会问裁剪不也要调用 embedding 吗确实有成本但 embedding 接口通常比生成 token 便宜很多而且同一个文档可以缓存。第一次处理时把分块和向量结果存到本地后面每次提问只需要算一次 query 的向量再做一次矩阵乘法耗时通常不到 50 毫秒。我同一个文档问过 20 个问题缓存后裁剪成本基本可以忽略。另一个容易被忽略的问题是第三方 embedding API 的网络延迟。如果对首 token 时间敏感建议用本地 embedding 模型如果只能用 API就把分块和向量化放到异步任务里提前算好缓存。这样裁剪就成了一个实时前置过滤环节而不是拖慢主流程的额外负担。5. 几个能直接上手的建议5.1 先给任务定一个“相关性预算”每次准备上下文前先问自己三个问题这个问题需要几个关键事实这些事实大概率在哪些区域最坏情况下要读多少才够根据回答设置 token 预算。我自己常用的参考值如下任务类型建议预算单点信息抽取金额、日期、名称1-2k token单文档问答与简单推理3-5k token多文档对比或复杂逻辑链5-8k token必须全文逐字核对10k 以上但极少见预算定好后再做裁剪就不会掉进“裁太狠把答案切了”或“不裁导致模型变笨”两个极端。这个预算本质上是给你自己一个约束逼迫你思考哪些上下文是真正必要的。5.2 两段式裁剪先粗筛再细筛面对几十页的大文档一次 TopK 容易漏掉藏在奇怪位置的答案。更稳的是两段式第一阶段按章节粗筛把相似度极低的整章扔掉保留大概 30% 的候选区域第二阶段对候选区域做细粒度切块再筛到目标 token。这样既保证了查找广度又保证最终喂给模型的都是高相关片段。这里体现了一个思想裁剪不是越细越好而是粒度要和文档结构匹配。段落边界是天然的语义单元先粗筛能快速缩小范围细筛负责在候选区里找精确证据两级配合能显著降低漏检率。5.3 裁剪后补一轮“信息充足性自检”如果担心裁太狠可以在正式回答问题前让模型先回答一个附加问题“基于目前给到的片段是否足够回答用户问题如果不够还缺少哪部分信息”把这个逻辑写成代码判断分支模型说缺信息就自动扩大保留范围最多重试两轮。“自检-补裁”循环在我看来是性价比最高的兜底手段。它把一次性的静态裁剪变成了带反馈的动态过程能有效降低预算设错导致的信息缺失。代价很小通常只需要多一次短请求但换来的是稳定性。5.4 离线缓存相关块向量长文档问答中同一个文档经常被不同问题反复查询。强烈建议第一次处理时就把分块和向量结果缓存起来。后续每次提问只需要算 query 的向量、做一次余弦距离、挑高分块。本地向量库方案可以用 sqlite 加 numpy也可以用 faiss、chroma 这类现成工具。缓存带来的不仅仅是省钱更重要的是让裁剪环节变得足够快从而可以把它嵌进交互应用的主流程。如果每次提问都要重新分块、重新 embedding很多场景根本跑不起来。提前把这一步做掉整个上下文工程才谈得上“工程”而不是“临时脚本”。6. 同样思路还能用在哪里6.1 多轮对话历史别把寒暄喂给模型相关性裁剪不只适用于长文档。多轮对话里往往塞满了“好的”“谢谢”“等下”这类无效历史全量交给模型后当前回合的判断会被稀释。我试过一种做法最近两轮保留完整原文更早的轮次先压缩成一两句摘要如果用户新问题引用了很早之前的信息再通过相关性裁剪从历史里把对应轮次捞回来。这个方案比固定滑窗更聪明。滑窗只看“时间近”不关心“内容是否相关”而裁剪是真正按当前问题往回找证据。两种可以配合先按滑窗圈定最近 N 轮再在圈定结果里做相关度排序效果会更好。6.2 Agent 工具调用返回结果Agent 场景里模型每调一次工具就会收到一大段 JSON、网页文本或日志。如果不处理下一轮推理很容易被日志里的错误信息带偏。我现在的做法是工具返回后先用轻量 embedding 对内容做相关性过滤只把和当前目标相关的片段传回给规划模型。这个过滤动作相当于给 Agent 加了一层“注意力路由”。它不只是省钱更重要的是让 Agent 在面对一长串工具输出时还能牢牢记得自己原本要解决什么问题。实测下来加了这步后 Agent 的步骤连贯性和任务完成率都有提高。6.3 批量日志与报告分析日志分析是另一个很适合相关性裁剪的场景。几万行日志里真正对应异常窗口的可能只有几十行。以往的做法是直接把整段日志塞给模型让模型自己找容易找不准现在我会先把日志按时间窗口切片用问题描述做相关性过滤只保留高相关窗口再让模型分析。这个场景里可以结合滑动窗口先按异常时间附近的固定窗口切日志再用相关性裁剪把无关行丢掉。和纯滑窗相比裁剪能保留那些虽然离异常发生点有点远、但确实包含根因信息的日志行分析结果会更接近真实原因。我个人在几次长上下文实测里印象最深的一点是真正让模型变笨的往往不是模型本身而是我在上游给它喂了太多“看起来有用、实际没用”的噪声。相关性裁剪的底层逻辑其实非常简单——把上下文当成一个需要筛选的输入信号而不是一个可以无限堆放的仓库。它未必适合所有任务但如果你在做长文档问答、合同审查、报告分析这类场景我强烈建议你先花半小时跑一次上面这套流程用数据说服自己。最后再分享一个小技巧裁剪比例不必一开始就定死先试试“只保留 25%”和“只保留 10%”两组看看答案在哪一组开始崩坏那个边界就是你这个任务的最优预算。祝大家在上下文工程上少踩坑、多产出。
阅读完成 · 觉得有帮助?