我们用一个小模型,扔掉了 68% 的 RAG 上下文
我们用一个小模型,扔掉了 68% 的 RAG 上下文
在 kapa.ai,我们每天要处理成千上万个来自 Docker、CircleCI、Reddit 等产品的技术问题。我们的 RAG 系统在检索阶段会捞回大量文档块,但说实话——很多都是噪音。这篇文章讲讲我们怎么用一个小模型,在答案生成之前先把垃圾扔掉。
先说说我们的 RAG 长什么样
我们的检索有好几种形式,有些是 agentic 的,有些是单次检索,但本质上都是同一个模式:
用户提问 → 检索器捞文档块 → LLM 看这些块 → 写出答案检索器从文档库里捞出和问题相关的块,然后生成模型拿这些块来写答案。看起来挺直接的对吧?但这里藏着一个问题——只要你稍微多捞一点,噪音就进来了。
多捞是为了不遗漏关键信息,这没错。但问题在于,那些不相关的块并不会被模型无视——它们会污染上下文,让模型分心,甚至直接导致幻觉。
传统的做法:rerank
业界比较成熟的做法是 rerank。在检索之后、生成之前,加一个 reranker,对捞回来的块重新排序,把最相关的排前面,然后截断只保留 top-k。
Rerank 当然有用,但它有一个根本性的限制:它只能在块级别上做取舍。一个文档块可能几百个 token,里面真正有用的可能就一两句话。rerank 要么全留要么全扔,颗粒度太粗了。
我们的做法:用一个小模型做 pruning
我们的思路不一样。
我们放了一个小模型在检索器和生成器中间。这个小模型很便宜,跑一次几乎不花钱。它的任务很简单:读一遍问题,再读一遍所有捞回来的块,然后告诉生成器——这些句子留下,那些扔掉。
流程变成了这样:
用户提问 → 检索器捞文档块 → 小型 pruner 逐句筛选 → 只保留有用内容 → LLM 写答案关键点:
- 不是块级别的丢弃,是句子级别的。一个块里可能三句话,两句有用一句没用,pruner 会把没用的那句扔掉,而不是把整个块扔掉。
- pruner 模型本身很小很便宜,和生成的 LLM 比起来几乎可以忽略不计。
- pruner 同时看到问题和所有块,所以它能做全局判断——哪些内容真正对回答问题有帮助。
我们用的模型:Provence
我们在生产里用的是 Provence(Pruning and Reranking Of retrieVEd relevaNt ContExts,名字挺长的,反正就是个缩写)。
Provence 相比之前的方法有几个关键改进:
1. 序列标注
它把 pruning 当成一个序列标注任务来做。每个句子进来,模型给它打一个标签:保留 or 丢弃。这听起来简单,但实际效果比之前那些全局打分的方法好得多。
2. 把 pruner 和 reranker 统一了
之前的研究把 pruning 和 reranking 分成两个独立的阶段。Provence 把两者统一到一个模型里——同一个模型既能做句级别的筛选,也能做块级别的重排序。这在实际部署中省了很多事。
3. 训练数据多样性
Provence 的训练数据覆盖了多种领域和上下文长度。这意味着它不会在你的文档表现很好、换个领域就崩掉。我们测下来,它在各种场景下的表现都很稳定。
效果怎么样?
我们在一个标注数据集上测了 recall——这些问题是真实的用户问题,我们人工标注了每个问题到底需要哪些块才能正确回答。
结果:
- 压缩率 68%:平均扔掉 68% 的上下文,留给生成模型的内容不到原来的三分之一。
- Recall 几乎没有下降:丢掉的内容确实都是不相关的。
- 延迟几乎没增加:pruner 模型小,跑一次几毫秒到几十毫秒,和生成的 LLM 调用时间比起来可以忽略。
- 成本下降明显:生成模型看到的内容少了 68%,token 花费也少了 68%。
为了验证不是标注集上的巧合,我们把各种配置在一个随机月份的完整生产流量上做了回放——用的就是每条查询当时实际发送给生成器的那些块。结果和标注集上的表现一致。
生产部署:一些实际经验
说几个在实践中踩过的坑:
1. Pruner 和生成模型的配合
Pruner 的核心目标不是"尽可能多扔",而是在保证 recall 的前提下尽可能多扔。我们调了一个阈值参数来控制 pruning 的激进程度。刚开始我们设得太激进了,把一些模棱两可的内容也扔了,结果发现某些边缘 case 的回答质量下降了。
最后的做法是:设一个保守一点的默认值,同时在 API 里暴露这个参数,让用户根据自己的场景调整。
2. 不同类型的块需要不同的处理
有些文档块是纯文本,有些是表格,有些是代码片段。Provence 在纯文本上表现最好,在表格和代码上稍微差一点——因为这些内容的语义密度很高,几句话之间可能没有明显的"冗余"可以剪。
我们的解决办法是:对不同类型的块使用不同的 pruning 策略。代码块和表格的 pruning 力度小一些,纯文本的力度大一些。
3. 长上下文场景下的收益更大
我们发现,当检索器捞回的块数比较多(比如超过 10 个)时,pruning 的收益是最明显的。因为块数少的时候,每个块可能都有用,但块数一多,重复信息和冗余内容就出现了。事实上我们的数据也印证了这一点——那些检索返回块数最多的查询,pruning 的压缩率最高。
什么时候不该用 pruning?
虽然 pruner 很便宜,但也不是零成本。如果你遇到下面这些情况,可能需要小心:
- 检索已经极度精确:如果你的检索器只返回 1-2 个块,而且几乎不会漏掉关键信息,pruning 的收益很小。
- 你用的生成模型本身就很强:像 Claude Opus 这种级别的模型,对噪音的容忍度其实很高。pruning 能省钱,但对质量的提升可能不明显。
- 延迟敏感度极高:虽然 pruner 很快,但毕竟多了一次模型调用。如果每一毫秒都很关键,可能不值得。
怎么用?
目前在 kapa 的产品里,pruning 是默认开启的。如果你在用我们的 Product Agent SDK,知识库搜索已经自动带了 pruning。如果你在用 Retrieval API 或 MCP 服务器,也可以通过参数开启。
代码大概长这样:
# 开启 pruningretrieval_result=kb.search(query="如何配置 OAuth2?",enable_pruning=True,# 默认就是 Truepruning_threshold=0.35# 控制激进程度,0~1)最后
RAG 发展到今天,单纯在检索层面做优化已经不够了。当模型可以吃下几百万 token 的时候,"喂什么"比"能喂多少"重要得多。Pruning 不是什么魔法,它就是很朴素的思路——让便宜的模型干脏活累活,让贵模型只看到精华。
但就是这个朴素的思路,在生产里给我们省了 68% 的 token,同时没有牺牲回答质量。对我们来说,这已经足够了。
如果你也在做 RAG 系统,可以试试这个方向。不一定非要用 Provence——Voyage 也有类似的能力,或者你自己 finetune 一个小 BERT 做二分类也能跑。关键不是模型,而是不要在生成的这一步浪费钱处理噪音。
本文翻译整理自 kapa.ai 技术博客 “How we prune RAG context”,数据和案例均来自 kapa.ai 生产环境。