RAG系统的检索阶段通常取回30到50个片段,但真正能支撑答案的往往只有两三条。其余片段可能是主题相近的旧版本说明、重复的API文档,或者只包含关键词却缺少关键条件的段落。这些冗余内容一旦被拼进prompt,会带来两类问题:一是token消耗成倍增加,二是模型容易受到噪声干扰,生成看似相关但实际错误的回答。控制冗余不能只靠提高检索阈值,因为阈值过高会漏掉关键证据。更可靠的做法是把召回、压缩、重排序分开处理。

上下文压缩解决的是检索结果中噪声过多的问题,重排序解决的是相关片段排不到前面的问题。二者作用于不同环节,但目标一致:让进入大模型生成阶段的上下文更短、更精准。接下来先分析检索冗余的产生原因,再分别拆解两种技术的实现方式,最后给出一个可以落地的组合流程。
一、检索冗余到底是怎么产生的
RAG的召回阶段通常使用向量检索。文档被切分成固定大小的片段,通过嵌入模型转换成向量,查询时计算问题向量与片段向量的余弦相似度,取相似度最高的前k个片段。这个机制天然会引入冗余,因为向量相似度衡量的是整段文本的语义接近程度,而不是句子级别的证据匹配。一个片段只要整体语义与问题相关,即使里面大部分句子与当前问题无关,也可能被排到前面。
另一个原因是固定分块策略带来的上下文碎片化。比如一份技术文档按500个token切分,某个关键参数的定义被切到了两个片段里,而另一个片段只包含了一个小标题和大量背景介绍。检索时三个片段可能都因为包含部分关键词而被召回,但真正有用的信息只集中在其中一段的两句话里。再加上不同片段之间常常存在重复内容,例如同一份文档的不同版本、同一个FAQ的多个镜像页面,检索结果就变得更加臃肿。
此外,向量检索本身对否定条件、时间约束、数字范围等细节不敏感。比如用户问的是2023年之后发布的版本,但检索回来的片段可能包含大量2022年的旧版说明。这些片段在语义上确实与版本发布相关,却无法回答用户的具体问题。即使调整嵌入模型或增加元数据过滤,也很难完全消除这类噪声。因此,在召回之后增加压缩与重排序环节,比单纯优化召回算法更实际。
二、上下文压缩的三种可落地方案
上下文压缩的目标是在不丢失关键证据的前提下,减少送入生成模型的文本量。第一种方案是相似度去重。检索回来的片段经常出现高度重复,可以直接用嵌入模型计算片段之间的相似度,低于阈值的保留,高于阈值的只保留第一个。下面是一个基于sentence-transformers的去重函数。
def compress_by_similarity(docs, threshold=0.85):
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
embeddings = model.encode(docs, convert_to_tensor=True)
keep = []
for i, doc in enumerate(docs):
duplicate = False
for j in keep:
score = util.cos_sim(embeddings[i], embeddings[j]).item()
if score > threshold:
duplicate = True
break
if not duplicate:
keep.append(i)
return [docs[i] for i in keep]
这个方案实现简单,计算开销也小,但它只能去掉重复段落,无法处理内容不同但与问题无关的片段。为了更细粒度地控制噪声,可以采用第二种方案:句子级证据提取。先对每个检索片段按句子切分,再用一个小型自然语言推理模型判断每个句子与问题的关联度,只保留关联度超过阈值的句子,最后把保留的句子重新拼接成上下文。这种方式可以大幅压缩文本量,尤其适合原文中存在大量铺垫、说明、示例场景的内容。
第三种方案是直接让大模型做压缩。把检索到的片段合并后,用指令让模型提取与问题直接相关的信息,并删除背景噪声、重复解释和不必要的修饰。例如可以使用类似下面的prompt:
prompt = (
"请从以下内容中提取与问题直接相关的句子,"
"去除重复描述和背景噪声,只保留能够支撑回答的证据。\n"
f"问题:{query}\n\n内容:\n{context}"
)
compressed = llm.complete(prompt)
LLM压缩的效果通常最好,因为它能理解语义,不仅会删除整句,还会合并表达相近的句子。但它的缺点是增加了一次模型调用,延迟和成本都更高。实际项目中可以把三种方案组合使用:先用相似度去重降低总量,再用句子级提取过滤明显无关的句子,最后在文本仍然很长时才调用LLM做精细压缩。这样在效果和开销之间取得平衡。
三、重排序如何弥补向量检索的不足
向量检索为了速度,通常采用双编码器结构,也就是查询和文档分别编码成向量,再计算相似度。这种方式无法捕捉查询与文档之间的细粒度交互,容易把只包含部分关键词的片段排到前面。重排序阶段改用交叉编码器,把查询和文档拼接起来一起送入Transformer,让模型在注意力机制中充分比较查询词与文档词的关系,从而给出更精确的相关性分数。
from sentence_transformers import CrossEncoder
model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
pairs = [[query, doc] for doc in docs]
scores = model.predict(pairs)
ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
top_docs = [doc for doc, _ in ranked[:5]]
交叉编码器的精度明显高于双编码器,但计算开销也更大。因为查询与每个文档都要重新拼接并过一遍模型,无法像双编码器那样预先缓存文档向量。所以实践中通常采用粗排加精排的两阶段方式:第一阶段用向量检索召回50个候选,第二阶段用交叉编码器对这些候选重排序,取前5到10个送入生成模型。这样既控制了精排成本,又能保证进入生成环节的片段质量。
除了交叉编码器,还可以使用大模型做重排序。一种做法是逐对打分,让模型输出查询与文档的相关性等级;另一种做法是列表重排,把多个文档一并交给模型,让它直接给出排序结果。LLM重排的优势在于能理解复杂指令和隐含条件,例如时间范围、排除项、用户身份偏好等,但调用成本较高,通常只适合对交叉编码器排序后的少量候选做最终筛选。选择哪种重排方案,取决于业务对延迟、成本和准确率的要求。
四、压缩与重排序的组合流程和调参建议
压缩和重排序并不冲突,把它们串起来可以形成一条更稳健的RAG后处理链路。建议的顺序是:先做相似度去重,再做句子级压缩,然后用交叉编码器重排序,最后截断top-k交给大模型生成。压缩放在重排序之前,可以减少交叉编码器需要处理的文档数量,降低精排延迟;重排序放在压缩之后,可以进一步把最相关的片段排到前面,避免截断时误删关键内容。下面是一个组合流程的简化实现。
def rag_pipeline(query, vector_store, k=30):
raw_docs = vector_store.search(query, top_k=k)
dedup_docs = compress_by_similarity(raw_docs)
extract_docs = sentence_level_filter(query, dedup_docs)
top_docs = rerank_with_cross_encoder(query, extract_docs)[:5]
context = "\n\n".join(top_docs)
answer = llm.generate(query, context)
return answer
调参时需要重点关注三个变量:初始召回数量、压缩去重阈值、最终保留片段数。初始召回数量可以设置得比最终需要的片段数大几倍,比如最终需要5条,初始召回可以设为30到50条。压缩去重阈值一般设置在0.85到0.95之间,过高会漏掉相似度略低但仍属重复的片段,过低则可能误删不同但语义相近的内容。最终保留片段数建议根据生成模型的上下文窗口和业务问题的复杂程度调整,一般控制在3到8条比较合适。
如果发现压缩后仍然存在大量无关内容,可以优先检查句子级过滤模型的训练数据是否与业务领域匹配。通用自然语言推理模型在医疗、法律、金融等垂直领域的效果可能下降,此时可以换用领域微调过的模型,或者在LLM压缩指令中补充领域术语解释。如果重排序后靠前的片段仍然不相关,可以尝试换用更强大的交叉编码器,或者在交叉编码器之后再加一级LLM重排,用更高的成本换取更稳定的排序质量。
最后要注意,压缩和重排序并不是越激进越好。过度压缩会把一些看似无关但实际是推理前提的句子删掉,比如问题背景、术语定义、前置条件等。过度重排序则可能因为排序模型偏好某类表达而忽视其他有效证据。更好的做法是把压缩后的上下文和原始检索结果做一次对比评估,记录答案正确率、引用准确率和平均token消耗,再根据评估结果逐步调整策略。只有把召回、压缩、重排序三个环节作为一个整体来优化,RAG系统才能在答案质量和资源消耗之间找到稳定平衡。