☰
RAG上下文压缩实战:从全量拼接巨坑到混合压缩管线
2026/10/20 21:12:53 网站建设 项目流程

做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不是单点操作,而是一条完整链路。核心流程如下:

  1. 用户问题输入,向量检索召回Top-K文档块。
  2. 对每个文档块做句子级切分,记录每个句子所属的原始块ID和原始顺序。
  3. 用Embedding模型计算每个句子和用户问题的相似度,做一次粗筛过滤。
  4. 如果过滤后仍超过预设条数,对候选句子做一次重排序。
  5. 将候选句子连同用户问题交给LLM做抽取式压缩。
  6. 按原始文档顺序拼接压缩结果,作为最终上下文喂给生成模型。

每一步看起来都不难,但我在前几步里踩过的细节坑,值得单独展开。

3.2 分段与打分:过滤器的小细节

先说切分。最初我直接按文档原有的段落边界切分,后来发现检索结果里经常出现“整段不相关但段中一句相关”的情况。比如一份用户手册里有一段讲的是常见错误码列表,大部分内容和你问的“超时重试机制”无关,但列表最后一行恰好提到超时错误码的处理建议。按整段去过滤,这段很可能会被整体丢掉。

所以我后来统一改成句子级切分,必要时合并成不超过3句话的小片段。这个粒度不会丢掉关键的单句证据,又比单句粒度更稳——有些信息要前后两句才能读懂。

再说阈值。阈值不能拍脑袋定,要根据你的Embedding模型的分数分布来调。我在自己的数据集上统计过,人工标注为“相关”的句子,相似度分数大都在0.42以上;而明显无关的句子通常在0.3以下。所以我最初把阈值定在0.45,过滤后每轮问答平均留下5到7个句子。这个值可以先用一批带标注的数据测出来,而不是凭感觉。

还有一个小细节:过滤后我坚持按原文顺序排序,而不是按相关度分数降序排列。原因是自然语言里的因果链和时间线往往依赖语序,如果按分数从高到低拼接,会把“因为”和“所以”拆散,反而让压缩器看不懂上下文。这个坑我实际遇到过,按分数排序后,LLM压缩器把后置的结果句子保留了,前置的原因句子被丢弃,生成模型给出的答案方向就错了。

3.3 抽取压缩的Prompt模板与代码骨架

下面的代码骨架是我目前在用的一条完整压缩链路的简化版本,你可以直接参考复现:

def compress_documents(query, docs, filter_model, llm, threshold=0.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(key=lambda 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抽取压缩。

我现在做任何新项目时,都是这个顺序,确认瓶颈在上下文冗余,再上重方案。早点把这个顺序定下来,后面能省下很多调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询