做 RAG(检索增强生成)的同学,应该都对这一套流程不陌生:用户问一个问题,我们把问题向量化去库里召回 top_k 条文档切片,然后把这些切片一股脑塞给大模型生成答案。听起来很顺,但长期做知识库问答、长文档分析的人一定踩过同一个坑——检索回来的文本往往又长又乱,真正和问题相关的可能就一两句话,其余全是背景铺垫、举例说明、免责声明。把这些噪音直接丢给大模型,token 成本上去了,回答质量反而下来了,模型很容易被一堆无关信息带偏。Contextual Compression(也就是“上下文压缩”)就是来解决这个问题的。它不改变检索环节,而是专门在“召回之后、生成之前”加一道工序:先判断哪些内容真的和当前问题相关,再把相关的部分压缩成更精简的片段,最后才把压缩后的文本交给大模型。换句话讲,检索器负责“把可能相关的找回来”,压缩器负责“把真正有用的挑出来、说人话”。这篇文章我会从原理、代码到调优,把我实际搭建上下文压缩链路时积累的经验完整梳理一遍,适合正在做知识库问答、客服机器人或长文档分析,且对 token 成本和答案质量有要求的开发者参考。1. 为什么 RAG 链路需要“上下文压缩”1.1 传统 RAG 检索链路的瓶颈常规 RAG 管道的设计思路,是把检索当作一个“尽可能召回相关材料”的过程。召回阶段为了保证不高漏,通常会设置一个比较宽松的相似度阈值,或者直接固定取前 k 条。这个策略在召回阶段是合理的,但问题在于,检索器返回的单元是“文档切片”,而不是“问题的答案”。一个切片是多大?取决于切分策略,短的几百 token,长的上千 token。也就是说,哪怕只有一条切片真正命中了用户的问题,我们传送给生成模型的也可能是一整块长文本。我在某公司的内部知识库项目里就遇到过类似问题:员工问“退款周期是几个工作日”,系统召回的五条切片里有三条都是商品活动介绍,只有一段提到了退款规则。如果直接把全部内容送进模型,实测下来模型有时会把“赠品发放时间”和“退款时间”混在一起回答,因为上下文里干扰信息太多,注意力被分散了。这里涉及一个很核心的问题——大模型处理长上下文时,不是“信息越多越好”。过多的冗余文本会稀释关键信息的注意力权重,尤其是在开源模型或参数量较小的部署场景下,这种稀释效应非常明显。与此同时,每次请求的 token 成本也在线性上涨。如果你在做一个调用量大、业务又要求低延迟的问答系统,这个问题会被放大到不得不解决的地步。1.2 上下文压缩的解决思路上下文压缩的核心思路,可以用一句话概括:把“召回的材料”变成“可用的证据”。它把压缩逻辑拆成两步。第一步是判断,对召回来的每个切片做相关性评估,把那些明显与当前问题无关的内容过滤掉;第二步是精简,对保留下来的相关切片,只抽取其中真正表达核心信息的部分,可能是几句关键句子,也可能是一个简洁的摘要。这里有一个很容易被忽略的设计取舍:压缩器的目标,不只是“减少文本量”,还要“保留证据感”。我在项目里对比过两类方案,一类是让大模型把相关切片改写成一个概括性摘要,另一类是让模型标出哪几句话与问题直接相关并保留原话。前者文本更短,但生成阶段容易丢细节,比如精确的数字、日期、专有名词;后者虽然压缩率没那么高,但答案生成时更有依据,引用原文时也更可信。打个生活化的比方:从图书馆借来一本五百页的书,你要找的其实是关于某个城市房价变化的五页内容。传统 RAG 是把整本书复印给用户,上下文压缩则是先翻目录定位章节,再只复印关键页面,甚至把其中的核心段落用荧光笔标出来。用户拿到手的,是能直接用于决策的材料,而不是一堆等着他自己翻找的信息。2. 压缩器内部是怎么工作的2.1 两类核心子组件:过滤器与提取器我实际用下来的经验是,一套可靠的上下文压缩方案通常由两类子组件组成:相关片段过滤器和文本提取压缩器。两者职责不同,缺一不可。相关片段过滤器,负责从召回的切片中筛掉无用信息。它的实现方式很多,最轻量的是用嵌入模型对“用户问题”和“每个候选切片”分别向量化,计算相似度后把低于阈值的切片丢弃。这类方案的好处是速度快、可解释性强,缺点是无法处理“相关性藏在原文的语境中”的情况。举个例子,用户问“发货怎么变慢了”,问题和一条切片表面没有太多词面重合,但切片内容讲的是物流方仓库爆仓,语义上其实高度相关。这种情况下,仅靠嵌入相似度很容易误杀。文本提取压缩器,则负责从保留的切片中提取或重写唯一对回答问题有用的句子。常见的做法是调用一个大模型,让它阅读切片后提取所有与当前问题相关的句子;也有用问答链做抽取式摘要的,会告诉你“根据这个切片,问题的答案是什么、支持这个答案的原文是什么”。这类方案更精准,但代价是增加了模型调用耗时和成本。我通常会把两者串联起来用:先用嵌入过滤器快速砍掉明显无关的切片,再对剩余切片跑模型提取。这样做的好处是,“粗筛”阶段很便宜,能把召回数量从几十条降到三五条,后续“精提”阶段即使调用大模型,需要处理的文本量也大幅减少,整体链路开销反而可控。2.2 数据流转全过程拆解为了把完整流程讲清楚,我举个例子。假设我们的知识库里存了一批产品操作手册,按每个主题章节做了切分,切片大小大约 200 到 400 token。用户提问:“如何重置设备密码?”第一步,检索器执行向量相似度检索,从知识库中返回 8 个候选切片,其中 3 个讲的是设备登录流程,2 个讲的是固件更新,2 个讲的是售后服务政策,还有 1 个介绍了产品整体参数。第二步,相关片段过滤器登场。把用户问题分别与 8 个切片算相似度,设定阈值为 0.3。此时 4 个切片被过滤掉,保留了固件更新相关的 2 个切片、参数介绍的 1 个切片,以及登录流程相关的部分切片。第三步,文本提取压缩器处理保留下来的切片。它逐个切片阅读内容,并从中抽出“按住侧边按钮十秒”“在设置页选择重置并输入默认密码 1234”这样的关键句子。那些“固件更新日志见官网”之类的背景信息,则被丢弃。第四步,把提取出来的句子拼接成一段干净的文本,传给生成模型。最终进入生成阶段的文本,可能只有原始文本量的三分之一甚至更少,但信息密度大幅提升。我统计过一个内部项目的数据:切片压缩前的平均长度是 380 token,压缩后平均长度降到 120 token 左右,生成模型的答案得分反而提升了 11%,这主要归功于移除了干扰信息。2.3 为什么压缩器不能“一把梭”到底另一个我在实际中反复体会到的细节是,不能把“压缩”做成“二次总结”。如果单纯让大模型“用一句话概括这段内容”,生成模型在理解问题之前也许已经丢失了关键信息。比较典型的是时间线、金额、编号这类数字信息,它们容易在概括中被省略或改写。我踩过一次比较惨的坑:某项目需要回答“上一财年华东区的销售总额”,压缩器对相关切片做了摘要式改写,结果把“去年营收 3.86 亿”改写成了“相比前年有较大增长”。下游回答虽然语句通顺,但关键的金额数字丢了。后来我改用抽取式方案,让压缩器把原文中和问题数字相关的句子原样保留,问题才彻底解决。这个改动还带来一个额外好处:答案生成时如果引用了切片原文,做“检索内容引用标注”会非常自然,因为证据链是完整的。3. 手把手搭建一个可用的上下文压缩链路3.1 核心组件与依赖准备如果你用的是业界常见的开源编排库(后面我统一称它为 LC 库),配套搭建上下文压缩链路会很快。为了不依赖任何闭源服务,我们的目标是搭建一条完整的 RAG 压缩链路,核心组件包括:一个向量数据库,存储文档切片索引一个嵌入模型,用于计算问题与切片的相似度一个轻量分类或评分机制,用于相关性过滤一个大模型实例,既用于片段提取,也用于最终答案生成LC 库环境,用于串联检索器、压缩器、生成链路要提醒的是,嵌入模型的选择直接影响过滤效果。我的建议是优先选择语义理解能力强、且对中文友好的向量模型,而不是只追求速度的模型。因为在上下文压缩场景里,嵌入相似度承担了“粗筛”的重任,筛错了后面再怎么提取都补不回来。3.2 关键实现代码与参数选择下面这段代码可以看作一个最小可运行的上下文压缩链路骨架。它演示的是:先初始化一个基础检索器,再包装一个压缩器,最后让压缩检索器在收到用户问题后返回压缩过的文档列表。from lc_lib.schema import Document from lc_lib.retrievers import ContextualCompressionRetriever from lc_lib.document_compressors import EmbeddingsFilter from lc_lib.document_compressors import LLMChainExtractor # 1. 初始化向量存储与基础检索器 embedder = EmbeddingModel("your-embedding-model-name") vectorstore = VectorStore.load("your-index-path", embedder=embedder) base_retriever = vectorstore.as_retriever(search_kwargs={"k": 8}) # 2. 配置一个基于嵌入相似度的过滤器 emb_filter = EmbeddingsFilter( embeddings=embedder, similarity_threshold=0.3, k=5 ) # 3. 配置一个大模型驱动的片段提取器 extractor_llm = ChatModel(temperature=0) llm_extractor = LLMChainExtractor.from_llm(extractor_llm) # 4. 组装压缩管道 compressor_pipeline = DocumentCompressorPipeline( transformers=[emb_filter, llm_extractor] ) # 5. 用压缩器包装基础检索器 compression_retriever = ContextualCompressionRetriever( base_compressor=compressor_pipeline, base_retriever=base_retriever ) # 6. 实际查询 compressed_docs = compression_retriever.get_relevant_documents( "如何重置设备密码?" )这段代码里有几个参数值得展开讲。similarity_threshold=0.3是嵌入过滤器的保留阈值,含义是“只有当切片与问题的相似度高于 0.3 时才保留”。这是我经过大量实验后比较常用的一个起点值。阈值太高可能误杀语义相关但表述差异大的文档,阈值太低又起不到过滤作用。我在不同业务上测试过,0.25 到 0.4 之间是比较安全的区间,需要结合嵌入模型自身的分数分布来调。k=8是基础检索器的召回数量,k=5则是过滤后希望保留的切片数量。这里有一个小经验:基础检索器的召回量可以适当放宽,因为压缩层会做二次筛选。传统 RAG 为了省 token 不敢召回太多,但在压缩链路上,你可以先把召回量调大到 8 到 10 条,再让压缩器筛选到 3 到 5 条。这样能在不增加生成阶段成本的前提下,降低“相关文档没被召回”的风险。LLMChainExtractor内部的实现,本质是将每个文档切片与大模型提示词组装起来,要求模型抽取与问题最相关的句子。这里要格外注意temperature,务必设置为 0。因为提取工作不允许有随机性,模型一旦“自由发挥”,就可能导致提取结果不稳定。3.3 从一份样例看压缩前后的效果差异为了直观说明压缩链路的收益,我在模拟项目 X 里构造了一个测试样本。知识库中存入某产品的完整使用文档,用户问题是“如何在订单完成后申请开发票?”。未压缩时,直接检索到的 8 条切片拼接起来约 3200 token,其中混杂了产品介绍、退换货规则、会员积分说明等无关内容,只有两条切片真正涉及开票流程。将这些文本全部传给生成模型,最终答案中出现了退换货时限的描述,明显是受到了干扰。使用上下文压缩后,先经嵌入过滤器筛掉 3 条完全不相关的切片,再经提取器保留与开票相关的原句,最终进入生成模型的文本约 900 token。模型给出的答案准确点出了“订单完成后 48 小时内,在订单详情页点击申请开票”这一关键信息,并且没有夹杂任何无关内容。这个对比已经说明问题。上下文压缩不是简单地对文本做“删减”,而是让下游生成模型专注在真正有用的信息上。对于追求高质量输出的 RAG 应用,这一步几乎不能省。4. 调优经验与常见问题排查实录4.1 三个关键参数怎么调上下文压缩链路,最核心的三个可调参数是:召回数量 k、相似度阈值、提取器长度控制。我根据自己的实践,整理了一套比较通用的参考值。召回数量 k:建议控制在 6 到 12。k 太小会漏召回,k 太大则压缩阶段的过滤压力陡增,甚至因为候选切片太多导致过滤分数整体被拉低。如果检索阶段本身效果就不稳定,可以适当调大 k,但不要超过 20,否则即使有压缩层,也会引入过多“看似相关”的干扰。相似度阈值:这是最容易调坏的一个参数。嵌入模型的分数分布在不同业务数据上完全不一样——有的知识库相似度普遍偏高,平均都在 0.6 以上;有的知识库内容风格差异大,0.3 就算很高的相似度。我的建议是在拿到真实查询和切片后,计算一批相似度分数,看一下分布再定阈值。先粗筛,看丢掉的全是无关文档还是误杀了关键文档,再逐步调整。提取器长度控制:我常用的配置是限制每次提取不超过 3 到 5 句话。如果提取内容超过这个长度,说明这个切片本身信息混杂,需要回看切分策略是否需要调整。提取器的目标不是把所有相关句子都找出来,而是只找那些“足以支撑当前答案”的句子。4.2 高频踩坑:压缩过头与上下文丢失上下文压缩最大的坑,是“压过头”。特别是在使用大模型做片段提取时,如果提示词写得不清晰,模型可能会把多个切片的信息合并成一段概括,导致实际生成的文本虽然流畅,却丢失了关键专有名词和数字。我前面提到的“销售总额”案例就是典型。要避免这个问题,我一直坚持一个原则:提取器的输出应当尽量保留原文表达,而不是让模型自由改写。落实到提示词上,我会明确要求“请从原文中逐字摘抄与问题相关的连续句子,禁止改写”。对中文场景还要注意一点,模型摘抄时容易把引号错乱或吞掉句尾数字,调试时可以定期抽查提取结果,看一眼提取后的文本是否保留完整的数字和单位。另一个常见的坑是,压缩后上下文仍然太长。有些开发者以为加了压缩器就万事大吉,结果发现生成阶段的上下文还是动辄几千 token。这通常是因为过滤器的阈值设置得太低,大量内容穿过了第一道粗筛。我的处理方式是两阶段配合:嵌入过滤阶段宁可多杀几条,也要把候选收窄;提取阶段再尽量保留原文,不要贪多求全。4.3 常见问题速查表我在表里整理了日常支持时被问到最多的问题,这些都属于“压缩链路调一调就能解决”的类型。现象可能原因排查方向压缩后答案丢失关键数字提取器做了改写式压缩改用抽取式提取,禁止模型改写原文过滤后上下文还是很长相似度阈值过低查看分数分布,适当调高阈值;或限制提取句子数相关文档被误杀嵌入模型语义理解能力不足更换更强的嵌入模型;调低相似度阈值提取阶段响应太慢切片数量太多或切片过长先在过滤阶段减少切片;限制提取器的输入长度答案比不压缩时还差压缩器破坏原文结构检查提示词,要求逐字摘抄;对照压缩前后文本如果排查完仍然异常,我的一个笨办法是把压缩前后的文档差出来,人工看一遍。压缩链路本身就是一个“输入可解释、输出可退溯”的环节,保留日志、对比原文和压缩结果,是调优最可靠的方式。从长远来看,上下文压缩的价值会越来越大。随着大模型应用逐渐进入企业级场景,不单是 token 成本,生成质量、可解释性,以及对知识库内容更新的适应能力,都要求我们在检索之后、生成之前多一道“克制”的工序。我自己在搭建知识库问答系统的过程中,把这条链路从一个简单的“检索+拼接”演进到“检索+过滤+提取+生成”,每一步都踩了坑,但每一步的收益都是可量化的。哪怕你的项目目前数据量不大,我也建议提前把压缩环节纳入架构设计里。因为它不只影响成本,更决定你的系统能不能在复杂知识场景里保持稳定可靠。
做 RAG(检索增强生成)的同学,应该都对这一套流程不陌生:用户问一个问题,我们把问题向量化去库里召回 top_k 条文档切片,然后把这些切片一股脑塞给大模型生成答案。听起来很顺,但长期做知识库问答、长文档分析的人一定踩过同一个坑——检索回来的文本往往又长又乱,真正和问题相关的可能就一两句话,其余全是背景铺垫、举例说明、免责声明。把这些噪音直接丢给大模型,token 成本上去了,回答质量反而下来了,模型很容易被一堆无关信息带偏。Contextual Compression(也就是“上下文压缩”)就是来解决这个问题的。它不改变检索环节,而是专门在“召回之后、生成之前”加一道工序:先判断哪些内容真的和当前问题相关,再把相关的部分压缩成更精简的片段,最后才把压缩后的文本交给大模型。换句话讲,检索器负责“把可能相关的找回来”,压缩器负责“把真正有用的挑出来、说人话”。这篇文章我会从原理、代码到调优,把我实际搭建上下文压缩链路时积累的经验完整梳理一遍,适合正在做知识库问答、客服机器人或长文档分析,且对 token 成本和答案质量有要求的开发者参考。1. 为什么 RAG 链路需要“上下文压缩”1.1 传统 RAG 检索链路的瓶颈常规 RAG 管道的设计思路,是把检索当作一个“尽可能召回相关材料”的过程。召回阶段为了保证不高漏,通常会设置一个比较宽松的相似度阈值,或者直接固定取前 k 条。这个策略在召回阶段是合理的,但问题在于,检索器返回的单元是“文档切片”,而不是“问题的答案”。一个切片是多大?取决于切分策略,短的几百 token,长的上千 token。也就是说,哪怕只有一条切片真正命中了用户的问题,我们传送给生成模型的也可能是一整块长文本。我在某公司的内部知识库项目里就遇到过类似问题:员工问“退款周期是几个工作日”,系统召回的五条切片里有三条都是商品活动介绍,只有一段提到了退款规则。如果直接把全部内容送进模型,实测下来模型有时会把“赠品发放时间”和“退款时间”混在一起回答,因为上下文里干扰信息太多,注意力被分散了。这里涉及一个很核心的问题——大模型处理长上下文时,不是“信息越多越好”。过多的冗余文本会稀释关键信息的注意力权重,尤其是在开源模型或参数量较小的部署场景下,这种稀释效应非常明显。与此同时,每次请求的 token 成本也在线性上涨。如果你在做一个调用量大、业务又要求低延迟的问答系统,这个问题会被放大到不得不解决的地步。1.2 上下文压缩的解决思路上下文压缩的核心思路,可以用一句话概括:把“召回的材料”变成“可用的证据”。它把压缩逻辑拆成两步。第一步是判断,对召回来的每个切片做相关性评估,把那些明显与当前问题无关的内容过滤掉;第二步是精简,对保留下来的相关切片,只抽取其中真正表达核心信息的部分,可能是几句关键句子,也可能是一个简洁的摘要。这里有一个很容易被忽略的设计取舍:压缩器的目标,不只是“减少文本量”,还要“保留证据感”。我在项目里对比过两类方案,一类是让大模型把相关切片改写成一个概括性摘要,另一类是让模型标出哪几句话与问题直接相关并保留原话。前者文本更短,但生成阶段容易丢细节,比如精确的数字、日期、专有名词;后者虽然压缩率没那么高,但答案生成时更有依据,引用原文时也更可信。打个生活化的比方:从图书馆借来一本五百页的书,你要找的其实是关于某个城市房价变化的五页内容。传统 RAG 是把整本书复印给用户,上下文压缩则是先翻目录定位章节,再只复印关键页面,甚至把其中的核心段落用荧光笔标出来。用户拿到手的,是能直接用于决策的材料,而不是一堆等着他自己翻找的信息。2. 压缩器内部是怎么工作的2.1 两类核心子组件:过滤器与提取器我实际用下来的经验是,一套可靠的上下文压缩方案通常由两类子组件组成:相关片段过滤器和文本提取压缩器。两者职责不同,缺一不可。相关片段过滤器,负责从召回的切片中筛掉无用信息。它的实现方式很多,最轻量的是用嵌入模型对“用户问题”和“每个候选切片”分别向量化,计算相似度后把低于阈值的切片丢弃。这类方案的好处是速度快、可解释性强,缺点是无法处理“相关性藏在原文的语境中”的情况。举个例子,用户问“发货怎么变慢了”,问题和一条切片表面没有太多词面重合,但切片内容讲的是物流方仓库爆仓,语义上其实高度相关。这种情况下,仅靠嵌入相似度很容易误杀。文本提取压缩器,则负责从保留的切片中提取或重写唯一对回答问题有用的句子。常见的做法是调用一个大模型,让它阅读切片后提取所有与当前问题相关的句子;也有用问答链做抽取式摘要的,会告诉你“根据这个切片,问题的答案是什么、支持这个答案的原文是什么”。这类方案更精准,但代价是增加了模型调用耗时和成本。我通常会把两者串联起来用:先用嵌入过滤器快速砍掉明显无关的切片,再对剩余切片跑模型提取。这样做的好处是,“粗筛”阶段很便宜,能把召回数量从几十条降到三五条,后续“精提”阶段即使调用大模型,需要处理的文本量也大幅减少,整体链路开销反而可控。2.2 数据流转全过程拆解为了把完整流程讲清楚,我举个例子。假设我们的知识库里存了一批产品操作手册,按每个主题章节做了切分,切片大小大约 200 到 400 token。用户提问:“如何重置设备密码?”第一步,检索器执行向量相似度检索,从知识库中返回 8 个候选切片,其中 3 个讲的是设备登录流程,2 个讲的是固件更新,2 个讲的是售后服务政策,还有 1 个介绍了产品整体参数。第二步,相关片段过滤器登场。把用户问题分别与 8 个切片算相似度,设定阈值为 0.3。此时 4 个切片被过滤掉,保留了固件更新相关的 2 个切片、参数介绍的 1 个切片,以及登录流程相关的部分切片。第三步,文本提取压缩器处理保留下来的切片。它逐个切片阅读内容,并从中抽出“按住侧边按钮十秒”“在设置页选择重置并输入默认密码 1234”这样的关键句子。那些“固件更新日志见官网”之类的背景信息,则被丢弃。第四步,把提取出来的句子拼接成一段干净的文本,传给生成模型。最终进入生成阶段的文本,可能只有原始文本量的三分之一甚至更少,但信息密度大幅提升。我统计过一个内部项目的数据:切片压缩前的平均长度是 380 token,压缩后平均长度降到 120 token 左右,生成模型的答案得分反而提升了 11%,这主要归功于移除了干扰信息。2.3 为什么压缩器不能“一把梭”到底另一个我在实际中反复体会到的细节是,不能把“压缩”做成“二次总结”。如果单纯让大模型“用一句话概括这段内容”,生成模型在理解问题之前也许已经丢失了关键信息。比较典型的是时间线、金额、编号这类数字信息,它们容易在概括中被省略或改写。我踩过一次比较惨的坑:某项目需要回答“上一财年华东区的销售总额”,压缩器对相关切片做了摘要式改写,结果把“去年营收 3.86 亿”改写成了“相比前年有较大增长”。下游回答虽然语句通顺,但关键的金额数字丢了。后来我改用抽取式方案,让压缩器把原文中和问题数字相关的句子原样保留,问题才彻底解决。这个改动还带来一个额外好处:答案生成时如果引用了切片原文,做“检索内容引用标注”会非常自然,因为证据链是完整的。3. 手把手搭建一个可用的上下文压缩链路3.1 核心组件与依赖准备如果你用的是业界常见的开源编排库(后面我统一称它为 LC 库),配套搭建上下文压缩链路会很快。为了不依赖任何闭源服务,我们的目标是搭建一条完整的 RAG 压缩链路,核心组件包括:一个向量数据库,存储文档切片索引一个嵌入模型,用于计算问题与切片的相似度一个轻量分类或评分机制,用于相关性过滤一个大模型实例,既用于片段提取,也用于最终答案生成LC 库环境,用于串联检索器、压缩器、生成链路要提醒的是,嵌入模型的选择直接影响过滤效果。我的建议是优先选择语义理解能力强、且对中文友好的向量模型,而不是只追求速度的模型。因为在上下文压缩场景里,嵌入相似度承担了“粗筛”的重任,筛错了后面再怎么提取都补不回来。3.2 关键实现代码与参数选择下面这段代码可以看作一个最小可运行的上下文压缩链路骨架。它演示的是:先初始化一个基础检索器,再包装一个压缩器,最后让压缩检索器在收到用户问题后返回压缩过的文档列表。from lc_lib.schema import Document from lc_lib.retrievers import ContextualCompressionRetriever from lc_lib.document_compressors import EmbeddingsFilter from lc_lib.document_compressors import LLMChainExtractor # 1. 初始化向量存储与基础检索器 embedder = EmbeddingModel("your-embedding-model-name") vectorstore = VectorStore.load("your-index-path", embedder=embedder) base_retriever = vectorstore.as_retriever(search_kwargs={"k": 8}) # 2. 配置一个基于嵌入相似度的过滤器 emb_filter = EmbeddingsFilter( embeddings=embedder, similarity_threshold=0.3, k=5 ) # 3. 配置一个大模型驱动的片段提取器 extractor_llm = ChatModel(temperature=0) llm_extractor = LLMChainExtractor.from_llm(extractor_llm) # 4. 组装压缩管道 compressor_pipeline = DocumentCompressorPipeline( transformers=[emb_filter, llm_extractor] ) # 5. 用压缩器包装基础检索器 compression_retriever = ContextualCompressionRetriever( base_compressor=compressor_pipeline, base_retriever=base_retriever ) # 6. 实际查询 compressed_docs = compression_retriever.get_relevant_documents( "如何重置设备密码?" )这段代码里有几个参数值得展开讲。similarity_threshold=0.3是嵌入过滤器的保留阈值,含义是“只有当切片与问题的相似度高于 0.3 时才保留”。这是我经过大量实验后比较常用的一个起点值。阈值太高可能误杀语义相关但表述差异大的文档,阈值太低又起不到过滤作用。我在不同业务上测试过,0.25 到 0.4 之间是比较安全的区间,需要结合嵌入模型自身的分数分布来调。k=8是基础检索器的召回数量,k=5则是过滤后希望保留的切片数量。这里有一个小经验:基础检索器的召回量可以适当放宽,因为压缩层会做二次筛选。传统 RAG 为了省 token 不敢召回太多,但在压缩链路上,你可以先把召回量调大到 8 到 10 条,再让压缩器筛选到 3 到 5 条。这样能在不增加生成阶段成本的前提下,降低“相关文档没被召回”的风险。LLMChainExtractor内部的实现,本质是将每个文档切片与大模型提示词组装起来,要求模型抽取与问题最相关的句子。这里要格外注意temperature,务必设置为 0。因为提取工作不允许有随机性,模型一旦“自由发挥”,就可能导致提取结果不稳定。3.3 从一份样例看压缩前后的效果差异为了直观说明压缩链路的收益,我在模拟项目 X 里构造了一个测试样本。知识库中存入某产品的完整使用文档,用户问题是“如何在订单完成后申请开发票?”。未压缩时,直接检索到的 8 条切片拼接起来约 3200 token,其中混杂了产品介绍、退换货规则、会员积分说明等无关内容,只有两条切片真正涉及开票流程。将这些文本全部传给生成模型,最终答案中出现了退换货时限的描述,明显是受到了干扰。使用上下文压缩后,先经嵌入过滤器筛掉 3 条完全不相关的切片,再经提取器保留与开票相关的原句,最终进入生成模型的文本约 900 token。模型给出的答案准确点出了“订单完成后 48 小时内,在订单详情页点击申请开票”这一关键信息,并且没有夹杂任何无关内容。这个对比已经说明问题。上下文压缩不是简单地对文本做“删减”,而是让下游生成模型专注在真正有用的信息上。对于追求高质量输出的 RAG 应用,这一步几乎不能省。4. 调优经验与常见问题排查实录4.1 三个关键参数怎么调上下文压缩链路,最核心的三个可调参数是:召回数量 k、相似度阈值、提取器长度控制。我根据自己的实践,整理了一套比较通用的参考值。召回数量 k:建议控制在 6 到 12。k 太小会漏召回,k 太大则压缩阶段的过滤压力陡增,甚至因为候选切片太多导致过滤分数整体被拉低。如果检索阶段本身效果就不稳定,可以适当调大 k,但不要超过 20,否则即使有压缩层,也会引入过多“看似相关”的干扰。相似度阈值:这是最容易调坏的一个参数。嵌入模型的分数分布在不同业务数据上完全不一样——有的知识库相似度普遍偏高,平均都在 0.6 以上;有的知识库内容风格差异大,0.3 就算很高的相似度。我的建议是在拿到真实查询和切片后,计算一批相似度分数,看一下分布再定阈值。先粗筛,看丢掉的全是无关文档还是误杀了关键文档,再逐步调整。提取器长度控制:我常用的配置是限制每次提取不超过 3 到 5 句话。如果提取内容超过这个长度,说明这个切片本身信息混杂,需要回看切分策略是否需要调整。提取器的目标不是把所有相关句子都找出来,而是只找那些“足以支撑当前答案”的句子。4.2 高频踩坑:压缩过头与上下文丢失上下文压缩最大的坑,是“压过头”。特别是在使用大模型做片段提取时,如果提示词写得不清晰,模型可能会把多个切片的信息合并成一段概括,导致实际生成的文本虽然流畅,却丢失了关键专有名词和数字。我前面提到的“销售总额”案例就是典型。要避免这个问题,我一直坚持一个原则:提取器的输出应当尽量保留原文表达,而不是让模型自由改写。落实到提示词上,我会明确要求“请从原文中逐字摘抄与问题相关的连续句子,禁止改写”。对中文场景还要注意一点,模型摘抄时容易把引号错乱或吞掉句尾数字,调试时可以定期抽查提取结果,看一眼提取后的文本是否保留完整的数字和单位。另一个常见的坑是,压缩后上下文仍然太长。有些开发者以为加了压缩器就万事大吉,结果发现生成阶段的上下文还是动辄几千 token。这通常是因为过滤器的阈值设置得太低,大量内容穿过了第一道粗筛。我的处理方式是两阶段配合:嵌入过滤阶段宁可多杀几条,也要把候选收窄;提取阶段再尽量保留原文,不要贪多求全。4.3 常见问题速查表我在表里整理了日常支持时被问到最多的问题,这些都属于“压缩链路调一调就能解决”的类型。现象可能原因排查方向压缩后答案丢失关键数字提取器做了改写式压缩改用抽取式提取,禁止模型改写原文过滤后上下文还是很长相似度阈值过低查看分数分布,适当调高阈值;或限制提取句子数相关文档被误杀嵌入模型语义理解能力不足更换更强的嵌入模型;调低相似度阈值提取阶段响应太慢切片数量太多或切片过长先在过滤阶段减少切片;限制提取器的输入长度答案比不压缩时还差压缩器破坏原文结构检查提示词,要求逐字摘抄;对照压缩前后文本如果排查完仍然异常,我的一个笨办法是把压缩前后的文档差出来,人工看一遍。压缩链路本身就是一个“输入可解释、输出可退溯”的环节,保留日志、对比原文和压缩结果,是调优最可靠的方式。从长远来看,上下文压缩的价值会越来越大。随着大模型应用逐渐进入企业级场景,不单是 token 成本,生成质量、可解释性,以及对知识库内容更新的适应能力,都要求我们在检索之后、生成之前多一道“克制”的工序。我自己在搭建知识库问答系统的过程中,把这条链路从一个简单的“检索+拼接”演进到“检索+过滤+提取+生成”,每一步都踩了坑,但每一步的收益都是可量化的。哪怕你的项目目前数据量不大,我也建议提前把压缩环节纳入架构设计里。因为它不只影响成本,更决定你的系统能不能在复杂知识场景里保持稳定可靠。