RAG上下文压缩实战:让大模型不再“看花眼
RAG上下文压缩实战:让大模型不再"看花眼"
做RAG的朋友基本都遇到过这个问题:检索出来10段文本,LLM回答的时候完全忽略了最重要的那段。
不是LLM笨,是你喂进去的信息太杂了。
我花了两周时间专攻上下文压缩这个问题,分享一下我的经验。
问题在哪
先说一个典型场景。
用户问:“这个产品的保修期是多久?”
RAG检索出5个片段:
- “产品A保修期12个月,B保修期24个月…”(命中)
- “产品的日常使用注意事项…”(不相关)
- “我们的退换货政策…”(不相关)
- “产品B的详细参数…”(不相关)
- “联系我们获取更多帮助…”(不相关)
LLM从5个片段里找正确答案,真的容易"看花眼"。
检索结果的Top-1准确率其实不高。把全部结果都塞进去,LLM反而被干扰。
上下文压缩的思路
核心思路就四个字:去粕取精。
但怎么"取精"有很多种做法,我试了几个方案。
方案一:LLM压缩(效果好但贵)
在把检索结果喂给主模型之前,先让小模型筛一遍。
async def llm_compress(documents: list[str], query: str) -> list[str]:
compress_prompt = f"""
用户的问题是:{query}
以下是检索到的文档片段。请判断每个片段是否与问题相关。
仅保留相关的片段,并去掉冗余内容。
文档列表:
{chr(10).join(f'{i+1}. {doc}' for i, doc in enumerate(documents))}
输出格式:一行一个保留的片段编号,如:1 3 5
"""
result = await small_llm.chat(compress_prompt)
indices = [int(x.strip()) for x in result.split() if x.strip().isdigit()]
return [documents[i-1] for i in indices if 1 <= i <= len(documents)]
效果是真的好,token能省掉40%-60%。
代价是多了一次LLM调用,延迟增加了200-300ms。
方案二:基于Embedding的重排序
不用LLM,直接用交叉编码器(cross-encoder)来做。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def rerank_documents(query: str, documents: list[str], top_k=3):
pairs = [[query, doc] for doc in documents]
scores = reranker.predict(pairs)
scored = list(zip(scores, documents))
scored.sort(key=lambda x: x[0], reverse=True)
return [doc for _, doc in scored[:top_k]]
这个方案比LLM压缩快很多,一次推理几十毫秒搞定。
缺点是精度不如LLM做判断,但日常用完全够。
方案三:选择性上下文(我最推荐的)
这是我最终采用的方案,结合了Reranker + 按需扩展。
def selective_context(query: str, documents: list[str], token_budget=1500):
# 1. 先rerank
reranked = rerank_documents(query, documents, top_k=len(documents))
# 2. 按优先级添加,不超过token预算
result = []
total_tokens = 0
for doc in reranked:
doc_tokens = estimate_tokens(doc)
if total_tokens + doc_tokens <= token_budget:
result.append(doc)
total_tokens += doc_tokens
else:
# 截断最后一个文档以适应预算
remaining = token_budget - total_tokens
if remaining > 50: # 至少保留50个token才有意义
truncated = truncate_text(doc, remaining)
result.append(truncated)
break
return result
这个方案的好处是:高质量片段优先进入上下文,低质量片段直接丢掉。token预算还能控制,不会超过LLM上下文窗口。
另一个维度:句子级别的压缩
文档级别的压缩解决了"该留哪些文档"的问题。但文档内部的冗余也很严重。
比如一段文本:
“我们的产品保修期是12个月。从购买之日起12个月内享受免费保修服务。需要注意的是,保修范围不包括人为损
坏。”
中间那句"12个月内享受免费保修"就是冗余信息。
句子级别的压缩可以解决这个问题。
def sentence_compress(text: str, query: str) -> str:
sentences = split_sentences(text)
# 对每个句子计算与query的相关性
relevant_sentences = []
for sent in sentences:
score = compute_relevance(sent, query)
if score > THRESHOLD:
relevant_sentences.append((score, sent))
relevant_sentences.sort(key=lambda x: x[0], reverse=True)
return "".join(sent for _, sent in relevant_sentences)
这样做,同样的token预算能装下2-3倍的有效信息。
token预算怎么定
这里有个经验值:给检索内容分配的总token数,不要超过模型上下文窗口的30%。
| 模型 | 上下文窗口 | 建议检索内容上限 |
|---|---|---|
| GPT-4 | 8K | 2.5K |
| GPT-4-128K | 128K | 38K |
| Claude 3 | 200K | 60K |
| 本地Qwen2-7B | 32K | 10K |
给得太少找不到信息,给得太多LLM会迷路。
我在生产环境中的实际配置
RAG_CONFIG = {
"top_k_retrieval": 10, # 检索数量
"rerank_top_k": 5, # 重排序后保留的文档数
"token_budget": 2000, # 检索内容总预算
"sentence_compress": True, # 是否启用句子级压缩
"relevance_threshold": 0.35, # 相关性阈值(低于此值丢弃)
"max_doc_length": 500, # 单个文档最大token数
}
最终线上效果:
- 基于Rerank+选择性上下文,准确率从72%提升到88%
- 再加上句子级压缩,准确率到了91%
- token使用量减少了55%,成本直接砍半
写在最后
上下文压缩这个方向,现在还有很大的优化空间。
我目前实验的方向是动态预算分配——对于简单问题只给500token,对于复杂问题给3000token。不需要每次都全量压缩。
话说回来,RAG的根问题还是检索质量。压缩只是补救。检索做得好,压缩需要做的工作就少很多。
下一篇打算讲讲RAG的查询改写,可以关注一下。
更多推荐
所有评论(0)