最近做长文档问答的时候,我发现一个很反直觉的现象:上下文塞得越多,模型回答的质量反而越差。一次测试里,我喂了一整份合同加几十封往来邮件,上下文总 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_embeddings=True) query = "请找出合同中关于违约金比例、赔偿条件和触发方式的条款" query_vec = model.encode([query], normalize_embeddings=True)[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), key=lambda x: x[1], reverse=True) 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(key=lambda x: x[0])实际项目中建议用 tiktoken 或模型自带的 tokenizer 精确计算 token,比例系数只适合快速验证。还有一点,动态预算选完后,最好快速人工扫一眼高分块的 top 条,确认没有明显的方向性错漏,防止 embedding 被某些长段落带偏。
3.4 重组上下文与 Prompt 细节
裁剪完不是直接把文本塞给模型就结束,prompt 里最好写清楚“这些资料是筛选过的”。我用的模板大致是:
以下是从原始文档中抽取的相关片段,按原文顺序排列。 片段可能不完整,也可能遗漏原文中的其他信息。 请严格基于给定片段回答问题,如果片段中没有足够信息,请明确说“信息不足”。 [片段1] ... [片段2] ... 问题:{question}加“片段可能不完整”这句话,能明显减少模型过度自信的脑补。另一个技巧是给每个片段加编号,并要求回答里注明依据来源,例如“违约金比例为 3%,见[片段2]”。这个习惯既能帮你排查幻觉,也能让结果更可追溯。
3.5 实测数据与对比结果
我跑了一轮完整对照,取的是三组问题的平均准确率,结果如下:
| 方案 | 平均上下文 token | 准确率 | 证据引用率 | 生成耗时 |
|---|---|---|---|---|
| 全量基线 | 约 12000 | 40% | 30% | 1.0x(基准) |
| 裁剪到 50% | 约 6000 | 68% | 55% | 约 0.7x |
| 裁剪到 25% | 约 3000 | 82% | 80% | 约 0.5x |
| 裁剪到 10% | 约 1200 | 62% | 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%”两组,看看答案在哪一组开始崩坏,那个边界就是你这个任务的最优预算。祝大家在上下文工程上少踩坑、多产出。