RAG上下文压缩实战:让大模型不再"看花眼"

做RAG的朋友基本都遇到过这个问题:检索出来10段文本,LLM回答的时候完全忽略了最重要的那段。

不是LLM笨,是你喂进去的信息太杂了。

我花了两周时间专攻上下文压缩这个问题,分享一下我的经验。

问题在哪

先说一个典型场景。

用户问:“这个产品的保修期是多久?”

RAG检索出5个片段:

  1. “产品A保修期12个月,B保修期24个月…”(命中)
  2. “产品的日常使用注意事项…”(不相关)
  3. “我们的退换货政策…”(不相关)
  4. “产品B的详细参数…”(不相关)
  5. “联系我们获取更多帮助…”(不相关)

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-48K2.5K
GPT-4-128K128K38K
Claude 3200K60K
本地Qwen2-7B32K10K

给得太少找不到信息,给得太多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的查询改写,可以关注一下。

更多推荐