
做RAG问答系统那段时间我栽过一个和Contextual Compression上下文压缩有关的跟头。最初我天真地以为只要检索出来的文档足够多、足够全把Top-K文档全部拼进prompt里大模型就能给出高质量回答。结果实测下来文档拼得越多回答质量反而越差有一轮问答甚至因为token超限直接报错。后来我才搞清楚一个关键道理检索只是把信息搬过来真正决定答案质量的是“搬过来的东西怎么用”。而Contextual Compression解决的就是“怎么用”的问题——在把检索结果喂给生成模型之前先做一轮基于当前问题的针对性压缩只留下真正有价值的证据。这篇文章我不会讲太虚的概念而是把我实际踩过的坑、验证过的路线、最后沉淀下来的可复现管线都写出来。1. 检索结果不能全塞进上下文一份来自实测的失败记录1.1 我的原始做法与它为什么翻车第一版系统非常朴素向量检索召回Top-K个文档块我图省事直接把所有块按相关性分数从高到低拼成一个超长上下文一口气塞给生成模型。一开始我以为“信息越全答案越准”但很快就连续吃了几个教训。第一个教训是token超限。文档知识库里的内容长短不一我设的Top-K是12每个块平均250到400个token拼起来动辄4000 token起步。遇到长文档切分比较碎的5000、6000 token也是常事。对上下文窗口有限制的模型来说这已经逼近甚至超过上限。为了兼容我的配置我一度被迫反复调低K值结果检索召回不足很多正确答案在源头就被丢掉了。第二个教训更隐蔽噪声直接干扰判断。我把一堆相关性只有0.3、0.4的文档块也拼了进去结果模型生成答案时明显被那些弱相关的段落带偏。最典型的一次用户问“某功能的异常恢复机制”系统拼进去了一段讲同模块历史版本迭代的内容生成模型硬生生把“异常恢复”回答成了“历史版本迁移”错得离谱。这不是模型能力问题是我把未经筛选的垃圾也当成了证据。第三个教训是成本。上下文越长每次调用的token费用越高而且这个开销是线性增长的。如果你的系统要面对高频调用哪怕每次多出2000个token累积起来也是一笔不小的账单。1.2 不要混淆压缩与截断很多人的第一反应是既然塞不下那就截断呗。这是我在组里反复纠正过的误区。截断是从头部或尾部硬切完全不考虑被切掉的内容里有没有答案压缩则是以用户问题为参照把文档里“与问题无关”的部分拿掉把“与问题相关”的部分保留下来。用买菜来类比截断相当于“不管今天要做什么菜进了菜市场只逛前三家摊位剩下的摊位一律不看”压缩相当于“手里拿着购物清单在菜市场里只挑清单上需要的食材”。前者省事但经常买不到真正需要的东西后者要多做一步筛选但买回来的基本都是能用的。这也是Contextual Compression名字里的Contextual上下文相关最关键的地方——压缩不是做通用摘要而是“针对当前这个问题”来压缩。同一个文档块面对不同问题时被保留的内容应该完全不同。2. Contextual Compression的两种主流实现路线我在项目中试过两种主流的压缩实现方式它们原理不同优劣也完全不同。理解了这两条路才能在你自己的系统里选对方案。2.1 路线一以LLM抽取为核心的压缩器这条路线是让一个语言模型通常是通用大模型API扮演压缩器的角色。输入是“用户问题检索到的原始文档块”输出是“从原始文档中抽取出来的相关句子集合”。我在实际使用中会要求压缩结果严格满足几个约束只从原文中抽取不自己改写按原文顺序输出保留数字、日期、专有名词等关键实体与问题无关的内容直接丢弃。下面是一个我常用的Prompt模板你是一个上下文压缩器。下面的内容是从文档库中检索到的原始片段。 你的任务不是改写而是从这些片段中挑出与用户问题直接相关的句子 按原文顺序输出。必须保留所有数字、日期、专有名词和否定关系。 如果片段与问题无关直接丢弃。 用户问题{question} 检索片段 {context} 压缩结果这种方式的优点是压缩质量上限很高因为模型能理解语义能判断哪些句子真正支撑答案缺点是压缩本身要消耗一次LLM调用会引入额外延迟和token费用。后面我会专门聊这个成本问题。2.2 路线二以Embedding相似度为核心的过滤器第二条路线不调用LLM而是用Embedding模型把文档块按句子或更细粒度分块然后计算每个小块与用户问题的向量相似度低于设定阈值的直接丢弃高于阈值的保留。这条路线本质上是在做“相关度过滤”工程上很好落地。我用它做过一个很实用的中间版本把文档按句子切分计算每个句子和用户问题的余弦相似度再按原顺序把过线的句子拼起来。但它的局限也很明显向量相似度是一种表层语义匹配遇到需要跨句推理、存在指代词、或者隐含否定关系的内容过滤结果经常会“漏”。比如“该功能不支持批量删除”这句话如果只算相似度它和“如何批量删除”可能非常相似反而会被错误保留或错误丢弃。所以我后来更倾向把它当作粗筛手段而不是唯一方案。2.3 两条路线的取舍一张对比表对比维度LLM抽取式压缩Embedding相似度过滤压缩质量上限高能理解语义关联中低依赖向量匹配额外延迟高需要一次LLM调用低基本可忽略额外成本每次压缩都会吃token几乎为零部署复杂度依赖LLM API稳定性只依赖Embedding模型对长文多跳问题的表现较好较差适合场景对回答质量要求高的正式环境高频、低延迟、成本敏感的环境我实测下来的最终选择是混合路径先用Embedding过滤把明显无关的块丢掉把候选从12个降到4到5个再对这4到5个块做LLM抽取压缩。这样既控住了压缩成本又保证了压缩质量。这也是我第三个章节要讲的实际管线。3. 一条可直接落地的压缩管线从召回、切分、打分到拼装3.1 完整管线长什么样我现在搭建的系统里Contextual Compression不是单点操作而是一条完整链路。核心流程如下用户问题输入向量检索召回Top-K文档块。对每个文档块做句子级切分记录每个句子所属的原始块ID和原始顺序。用Embedding模型计算每个句子和用户问题的相似度做一次粗筛过滤。如果过滤后仍超过预设条数对候选句子做一次重排序。将候选句子连同用户问题交给LLM做抽取式压缩。按原始文档顺序拼接压缩结果作为最终上下文喂给生成模型。每一步看起来都不难但我在前几步里踩过的细节坑值得单独展开。3.2 分段与打分过滤器的小细节先说切分。最初我直接按文档原有的段落边界切分后来发现检索结果里经常出现“整段不相关但段中一句相关”的情况。比如一份用户手册里有一段讲的是常见错误码列表大部分内容和你问的“超时重试机制”无关但列表最后一行恰好提到超时错误码的处理建议。按整段去过滤这段很可能会被整体丢掉。所以我后来统一改成句子级切分必要时合并成不超过3句话的小片段。这个粒度不会丢掉关键的单句证据又比单句粒度更稳——有些信息要前后两句才能读懂。再说阈值。阈值不能拍脑袋定要根据你的Embedding模型的分数分布来调。我在自己的数据集上统计过人工标注为“相关”的句子相似度分数大都在0.42以上而明显无关的句子通常在0.3以下。所以我最初把阈值定在0.45过滤后每轮问答平均留下5到7个句子。这个值可以先用一批带标注的数据测出来而不是凭感觉。还有一个小细节过滤后我坚持按原文顺序排序而不是按相关度分数降序排列。原因是自然语言里的因果链和时间线往往依赖语序如果按分数从高到低拼接会把“因为”和“所以”拆散反而让压缩器看不懂上下文。这个坑我实际遇到过按分数排序后LLM压缩器把后置的结果句子保留了前置的原因句子被丢弃生成模型给出的答案方向就错了。3.3 抽取压缩的Prompt模板与代码骨架下面的代码骨架是我目前在用的一条完整压缩链路的简化版本你可以直接参考复现def compress_documents(query, docs, filter_model, llm, threshold0.45): # 1. 句子级切分保留原顺序和原块ID segments [] for doc in docs: for sentence in split_sentences(doc): segments.append(sentence) # 2. Embedding粗筛 scores filter_model.calculate_scores(query, segments) kept [seg for seg, s in zip(segments, scores) if s threshold] # 3. 粗筛后还原为原文顺序 kept.sort(keylambda x: x.original_order) # 4. 如果候选仍然过多做一次重排只保留最相关的N条 if len(kept) max_segments: kept rerank(query, kept)[:max_segments] # 5. LLM抽取式压缩 prompt build_compress_prompt(query, kept) compressed llm.chat(prompt) return parse_compressed_output(compressed)注意一个关键点我把LLM抽取压缩放在过滤之后而不是检索之后立刻做。这样做的原因是LLM压缩的延迟和token成本都不低把有限的资源花在已经被粗筛过的候选内容上性价比才最高。这也是我前面说的混合路线相对纯LLM抽取式压缩成本更低的核心原因。4. 压多狠才算合适压缩率、信息失真与多跳问题4.1 压缩率不是越大越好刚开始调参时我追求极致压缩希望把上下文压到原来的10%左右。结果在单跳问题上效果还行一碰到多跳问题就露馅。所谓多跳问题就是答案需要从两个或多个文档块里综合推理而不是从一个块里直接找到。举个例子某个知识库里有两条记录一条写“某系统在2021年上线”另一条写“该系统次年引入了自动回滚机制”。用户问“自动回滚机制是哪一年引入的”答案需要先推出“次年”指2022年再把两条信息拼起来。如果压缩率过大第二条记录里“该系统”这个主语很可能因为和问题相似度不够高而被丢弃或者压缩器为了精简把“次年引入了自动回滚机制”压成“引入了自动回滚机制”丢掉了“次年”这个关键时间线索。结果就是答案缺失或者错误。所以我后来把压缩目标从“保留尽量少的内容”改成了“保留回答这个问题所需的最小但完整证据集合”。压缩率是一个需要反复调参的权衡值不是越小越好。同时对于可能涉及多跳信息的问题我会在压缩前做一次分组合并把相似度高、可能共同支撑答案的句子组交给LLM联合压缩避免逐句独立压缩把逻辑链切断。4.2 LLM压缩器会带头“改写原文”这条是我吃过亏之后总结出来的。很多压缩模型在生成输出时为了让语句更通顺会不自觉地替换原文用词这在大模型时代尤为常见。我遇到过最典型的一个真实案例原始文档写的是“该接口在2021年之前仅支持XML格式2021年之后支持JSON格式”。压缩器输出的结果是“该接口仅支持XML格式”直接把后半句的时间限定给丢了。用户问“该接口支持JSON吗”系统给出了否定回答完全说反了。就是因为压缩器在“精简”时把关于JSON格式支持的那半句当作次要信息删除了。从此以后我在Prompt里强制加了“只做抽取不做改写保留所有数字、日期、专有名词和否定关系”这几条硬约束。同时如果条件允许建议对压缩结果做一次原文一致性校验至少把核心数字和专有名词抽出来和原文比一遍。做知识库问答时数据准确性永远是第一优先级宁可上下文长几百个token也不能让压缩器把关键实体改错。4.3 检索错误的放大效应与混合策略还有一个容易被忽略的问题压缩器默认输入是“与问题相关的”但检索阶段可能本身就有噪声。如果你的向量检索召回了一批相关度不高的块压缩器依然会“尽职尽责”地从里面抽取内容结果就是把无关噪声包装成了“精心筛选过的证据”误导生成模型。这个问题的解法不是让压缩器更聪明而是要让压缩器别接触到噪声。我采用的办法是在进入压缩器之前先加一道硬性的相关度门槛把明显无关的块直接挡在外面。宁可漏掉一些边缘性相关内容也不能让大量噪声进入压缩环节。这个思路和“输入决定输出”的理念一致——压缩器只是在有限输入里做筛选输入脏了输出不可能干净。5. 压缩结果怎么验收延迟变化、成本账与质量检查单5.1 端到端延迟与成本变化加了压缩之后系统肯定比原来慢。我在一套模拟环境里分别测了三种方案截图了相对数据拆开来看会更直观方案平均端到端延迟单轮输入token用量我对这个方案的判断不压缩全量拼接1.0x基准3000质量不稳定成本高仅Embedding过滤1.05x基准800~1200性价比最高过滤LLM抽取压缩1.3x~1.8x基准400~700质量上限最高如果你做的是实时交互类产品用户等不了超过3秒的响应那LLM抽取压缩这笔额外的延迟就要慎重。这时我建议先用“仅Embedding过滤”的轻量版把上下文降到合适的范围牺牲一点质量上限换取响应速度。如果是离线批量处理或者对质量要求很高的正式场景再把LLM抽取压缩打开。5.2 质量验收清单我每次调参都在跑的项目压缩做得好不好不能只看上下文少了多少关键看压缩后信息是否还能支撑正确答案。我会用下面这张清单来验收每一次调参结果查询里的关键实体是否在压缩结果中保留核心数字、时间、价格是否原样出现且没有被拆散否定关系和排除条件是否保留完整跨句子的逻辑连接因果、时序、转折是否还在专用名词是否被改写成同义表达这个清单配合一个小型评测集效果很好。我在系统里维护了30条左右覆盖单跳、多跳、否定条件、时间限定等类型的典型问题每次调完阈值或者改完Prompt都跑一遍这批问题拿压缩后的上下文和真实答案对照。这样做比凭感觉看一两个例子靠谱得多。5.3 一个很实用但容易被忽略的起步建议如果你现在正要给RAG系统加入Contextual Compression我给的建议是不要一上来就直接上“过滤LLM抽取”的全部链路而是先只做Embedding相似度过滤用“仅过滤”的版本跑通全流程确认检索链路没有明显问题再根据实际质量瓶颈决定要不要上LLM抽取压缩。我现在做任何新项目时都是这个顺序确认瓶颈在上下文冗余再上重方案。早点把这个顺序定下来后面能省下很多调试时间。