RAG 的上下文压缩——Token 有限,怎么只喂给模型最有用的那些内容
RAG 的上下文压缩——Token 有限,怎么只喂给模型最有用的那些内容
我承认,我之前对这个问题的判断是错的。
最开始做 RAG 的时候,我的思路是:检索 Top-10,全部塞给 LLM,让模型自己判断哪些有用。我觉得 LLM 聪明嘛,给它更多信息总比给少了强。
然后我看账单。
一个月下来,GPT-4 的 Token 消耗比预期多了 3 倍。我把每次请求的 token 日志捞出来分析,发现一个触目惊心的规律:平均每次请求喂给 LLM 的 context,有效内容占比不到 40%。剩下 60% 是和问题完全不相关的文档块,在那里浪费 Token,贡献噪音,还拖慢速度。
更让我难受的是,这 60% 的噪音不只是在浪费钱,它还会降低答案质量。LLM 在长 context 里容易被无关内容干扰,产生"注意力稀释",有用信息反而被压制了。
这才让我认真对待上下文压缩这个问题。
噪音从哪里来
先说清楚为什么检索出来的文档里会有这么多无关内容。
向量检索本质上是相似度排序,不是相关性判断。这两个概念差别很大:
- 相似度:向量空间里的距离,越近越相似
- 相关性:对于回答这个特定问题,是否有实质性的帮助
举个例子,用户问"员工离职流程"。文档库里有一篇关于"员工入职流程"的文档,向量相似度可能很高——因为都在说"员工流程",语义空间接近——但对回答"离职流程"这个具体问题,这篇文档基本没用。
向量检索把它排进了 Top-10,但它就是噪音。
另一个来源是文档分块带来的问题。我们通常按固定大小(比如 512 token)分块,一个大文档被切成十几块,每块都有类似的元信息、开头结尾重复内容。即使是"相关"文档的多个块被检索出来,其中可能有 6 块是核心内容,4 块是重复的元数据、目录、页眉页脚之类的东西。
上下文压缩要解决的,就是在把内容送给 LLM 之前,过滤掉这些噪音。
三种压缩手段
1. LLM 过滤器(LLM-based Filter)
最直接的方法:再调用一次 LLM,让它判断每个文档块和问题的相关性。
@Component
@Slf4j
public class LlmContextFilter {
private final ChatClient chatClient;
private static final String RELEVANCE_CHECK_PROMPT = """
判断以下文档片段是否包含回答用户问题所需的信息。
用户问题:{question}
文档片段:
{context}
请只回答 YES 或 NO:
- YES:文档片段包含与问题直接相关的信息
- NO:文档片段与问题无关或只有边缘相关
""";
public LlmContextFilter(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public boolean isRelevant(String question, String documentContent) {
try {
String response = chatClient.prompt()
.user(u -> u.text(RELEVANCE_CHECK_PROMPT)
.param("question", question)
.param("context", documentContent))
.call()
.content()
.trim()
.toUpperCase();
return response.startsWith("YES");
} catch (Exception e) {
log.warn("LLM filter error, keeping document as fallback", e);
return true; // 出错时保守处理,保留文档
}
}
public List<Document> filter(String question, List<Document> documents) {
long startTime = System.currentTimeMillis();
List<Document> filtered = documents.parallelStream()
.filter(doc -> isRelevant(question, doc.getContent()))
.collect(Collectors.toList());
long elapsed = System.currentTimeMillis() - startTime;
log.info("LLM filter: {} -> {} documents in {}ms",
documents.size(), filtered.size(), elapsed);
return filtered;
}
}这个方法效果很好,但成本很高。每个文档块都要调一次 LLM,如果 Top-10 的话就是 10 次额外调用。在对延迟要求高的场景里基本不可用。
我们实际上用这个方法做了离线评估,但生产环境换成了下面的方案。
2. Rerank 重排序(Cross-Encoder Reranking)
Rerank 是我们目前生产主力方案,性价比最高。
原理是用一个专门的 Cross-Encoder 模型(不是 Bi-Encoder Embedding 模型)来计算问题和每个文档块的匹配分数,这个分数比向量相似度更精准,因为它是把问题和文档块拼在一起联合编码,而不是分别编码再算距离。
Cohere Rerank 是目前最常用的商业化方案,效果很好,有 API 可以直接调用。
@Service
@Slf4j
public class CohereRerankService {
private final RestTemplate restTemplate;
@Value("${cohere.api.key}")
private String apiKey;
@Value("${cohere.rerank.model:rerank-english-v3.0}")
private String rerankModel;
private static final String COHERE_RERANK_URL = "https://api.cohere.ai/v1/rerank";
public CohereRerankService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public List<Document> rerank(String query, List<Document> documents, int topN) {
if (documents.isEmpty()) return documents;
// 构建请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", rerankModel);
requestBody.put("query", query);
requestBody.put("top_n", Math.min(topN, documents.size()));
requestBody.put("documents", documents.stream()
.map(Document::getContent)
.collect(Collectors.toList()));
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(
COHERE_RERANK_URL, entity, Map.class
);
List<Map<String, Object>> results = (List<Map<String, Object>>) response.get("results");
return results.stream()
.map(result -> {
int index = ((Number) result.get("index")).intValue();
double score = ((Number) result.get("relevance_score")).doubleValue();
Document doc = documents.get(index);
// 把 rerank 分数写入 metadata,方便后续调试
Map<String, Object> metadata = new HashMap<>(doc.getMetadata());
metadata.put("rerank_score", score);
return new Document(doc.getId(), doc.getContent(), metadata);
})
.collect(Collectors.toList());
} catch (Exception e) {
log.error("Cohere rerank failed, falling back to original order", e);
return documents.subList(0, Math.min(topN, documents.size()));
}
}
}Cohere Rerank 的限制是只支持英文和部分语言,中文效果一般。如果你做的是中文场景,可以考虑用 BGE Reranker(bge-reranker-v2-m3),可以本地部署。
@Service
@Slf4j
public class BgeRerankService {
// BGE Reranker 本地部署,通过 HTTP 接口调用
@Value("${bge.reranker.url:http://localhost:8080/rerank}")
private String bgeRerankUrl;
private final RestTemplate restTemplate;
public BgeRerankService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public List<RankedDocument> rerank(String query, List<Document> documents) {
List<Map<String, String>> pairs = documents.stream()
.map(doc -> Map.of("query", query, "passage", doc.getContent()))
.collect(Collectors.toList());
Map<String, Object> request = Map.of("pairs", pairs);
try {
Map<String, Object> response = restTemplate.postForObject(
bgeRerankUrl, request, Map.class
);
List<Double> scores = (List<Double>) response.get("scores");
List<RankedDocument> ranked = new ArrayList<>();
for (int i = 0; i < documents.size(); i++) {
ranked.add(new RankedDocument(documents.get(i), scores.get(i)));
}
return ranked.stream()
.sorted(Comparator.comparingDouble(RankedDocument::score).reversed())
.collect(Collectors.toList());
} catch (Exception e) {
log.error("BGE rerank failed", e);
return documents.stream()
.map(doc -> new RankedDocument(doc, 0.0))
.collect(Collectors.toList());
}
}
public record RankedDocument(Document document, double score) {}
}3. 上下文蒸馏(Context Distillation)
这个更激进:不只是过滤文档块,而是让 LLM 从多个文档块中提取出和问题最相关的片段,生成一个压缩的摘要。
@Component
@Slf4j
public class ContextDistiller {
private final ChatClient chatClient;
private static final String DISTILL_PROMPT = """
你是一个信息提取助手。
用户问题:{question}
以下是从知识库中检索到的多个文档片段:
{contexts}
请从这些片段中提取出与回答用户问题最直接相关的信息,去掉冗余内容。
要求:
1. 只保留对回答问题有实质帮助的内容
2. 保持原文语言和专业性
3. 如果片段之间有重复内容,合并处理
4. 输出压缩后的核心信息,长度控制在原始内容的30%-50%
""";
public String distill(String question, List<Document> documents) {
String combinedContext = IntStream.range(0, documents.size())
.mapToObj(i -> String.format("[片段%d]\n%s", i + 1, documents.get(i).getContent()))
.collect(Collectors.joining("\n\n"));
try {
return chatClient.prompt()
.user(u -> u.text(DISTILL_PROMPT)
.param("question", question)
.param("contexts", combinedContext))
.call()
.content();
} catch (Exception e) {
log.error("Context distillation failed, returning original", e);
return combinedContext;
}
}
}蒸馏效果最好,但需要额外一次 LLM 调用,而且存在"蒸馏过度"的风险——LLM 可能把一些它认为不重要但实际有用的信息删掉了。这个问题没有完美解法,只能在业务中反复测试。
完整的上下文压缩流程
把三种方法串起来——生产可用的压缩管道
实际我用的是一个分级的压缩策略:
@Service
@Slf4j
public class ContextCompressionPipeline {
private final CohereRerankService rerankService;
private final ContextDistiller distiller;
private final TikTokenCounter tokenCounter;
// 最大 context token 数(给答案生成留空间)
private static final int MAX_CONTEXT_TOKENS = 4000;
// Rerank 后只保留 top-N
private static final int RERANK_TOP_N = 5;
// 低于这个 rerank 分数的文档直接丢弃
private static final double MIN_RERANK_SCORE = 0.3;
public ContextCompressionPipeline(CohereRerankService rerankService,
ContextDistiller distiller,
TikTokenCounter tokenCounter) {
this.rerankService = rerankService;
this.distiller = distiller;
this.tokenCounter = tokenCounter;
}
public String compress(String question, List<Document> rawDocuments) {
log.info("Starting compression: {} documents", rawDocuments.size());
// Step 1: Rerank
List<Document> reranked = rerankService.rerank(question, rawDocuments, RERANK_TOP_N);
// Step 2: 过滤低分文档
List<Document> filtered = reranked.stream()
.filter(doc -> {
Object score = doc.getMetadata().get("rerank_score");
if (score instanceof Number) {
return ((Number) score).doubleValue() >= MIN_RERANK_SCORE;
}
return true;
})
.collect(Collectors.toList());
log.info("After rerank+filter: {} documents", filtered.size());
if (filtered.isEmpty()) {
log.warn("All documents filtered out, using top reranked document");
filtered = reranked.subList(0, Math.min(1, reranked.size()));
}
// Step 3: 计算 Token 数,判断是否需要蒸馏
String combined = filtered.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
int tokenCount = tokenCounter.count(combined);
log.info("Combined context tokens: {}", tokenCount);
if (tokenCount <= MAX_CONTEXT_TOKENS) {
return combined;
}
// Step 4: 需要蒸馏
log.info("Token count {} exceeds limit {}, distilling...", tokenCount, MAX_CONTEXT_TOKENS);
String distilled = distiller.distill(question, filtered);
int distilledTokens = tokenCounter.count(distilled);
log.info("After distillation: {} tokens (reduction: {}%)",
distilledTokens,
100 - (distilledTokens * 100 / tokenCount));
return distilled;
}
}Token 计数器,用 tiktoken4j 或者近似估算:
@Component
public class TikTokenCounter {
// 近似计算:中文每个字约 1.5-2 个 token,英文每个词约 1-1.3 个 token
// 粗略估算,避免引入 tiktoken 依赖
public int count(String text) {
if (text == null || text.isEmpty()) return 0;
// 简单估算:中文字符 * 2,其他字符 / 4(近似英文词)
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
int otherChars = text.length() - (int) chineseChars;
return (int) (chineseChars * 2 + otherChars / 4.0);
}
}实测效果
这是我们在生产环境跑了两个月的数据,基准是不做任何压缩,Top-10 全量送入:
| 方案 | 平均 Context Token | 答案准确率 | API 成本/千次 |
|---|---|---|---|
| 无压缩(Top-10) | 6,842 | 71.3% | ¥128 |
| 仅 Rerank Top-5 | 3,421 | 74.8% | ¥64 |
| Rerank + 过滤 | 2,156 | 76.2% | ¥41 |
| Rerank + 过滤 + 蒸馏(按需) | 1,987 | 77.9% | ¥38 |
最终方案平均 Context Token 从 6842 降到了 1987,减少了 71%,准确率反而提升了 6.6 个百分点,API 成本降到了原来的 30%。
这个数据当时让我很震惊。噪音真的在拖累准确率,不是帮忙。
不同场景的压缩策略选择
简单问答场景(延迟敏感): 只做 Rerank,用本地 BGE Reranker。延迟增加不超过 100ms,效果足够。
复杂分析场景(准确率优先): Rerank + 蒸馏全套走。可以接受 2-3 秒延迟,换来更高质量的答案。
长文档处理场景: 蒸馏特别有用,尤其是当相关信息分散在一个长文档的不同位置时,蒸馏能把散点聚合起来。
实时对话场景: 只做 Rerank + 硬截断(超 token 就截掉尾部),不做蒸馏,因为蒸馏的 LLM 调用延迟太高。
一个坑要特别说
Rerank 分数阈值不能乱设。我们最开始设的 MIN_RERANK_SCORE 是 0.5,结果发现有些问题的最佳答案文档分数只有 0.35,被过滤掉了,导致回答质量极差。
后来我分析了一批 "Rerank 分数分布",发现对于我们的业务场景,0.3 是个更合理的阈值。但这个值高度依赖你的数据,没有放之四海而皆准的值。
一定要用你的真实业务数据来标定这个阈值,不要照搬别人的。
还有一个问题:当所有文档分数都低时,说明这次检索本身就没召回到相关内容,这时候要避免把全部文档过滤完,导致 LLM 无从回答。所以代码里有 "filtered 为空时 fallback 到 top-1" 的逻辑,这个 fallback 很重要。
总结
上下文压缩在 RAG 系统里是个容易被忽视但收益很高的优化点。很多团队把精力都放在检索质量上,没意识到即使检索对了,噪音文档也在悄悄吃掉 Token 预算、拉低答案质量。
我现在做任何 RAG 项目,上下文压缩都是标配,不是可选项。
