多轮对话场景下的RAG系统有一个高频翻车点:用户第一轮问“公司的年假政策是怎样的”,第二轮追问“那病假呢”,系统却返回了年假相关文档,甚至直接回答“请问您想了解什么”。这类问题的根源通常不是检索器本身的召回能力,而是对话历史在传递给检索环节时被静默丢弃或截断了。本文围绕“历史压缩”与“上下文重排”两条主线,给出可落地的完整方案。

一、先定位:对话历史到底在哪几个环节丢失
要解决问题,第一步是把丢失点找全。在一个典型的RAG检索链路中,对话历史会经过至少四个关键节点,每个节点都可能成为信息丢失的源头。很多团队只排查了其中一处,修完之后效果依然不理想,就是因为漏掉了其他环节。
第一个节点是历史窗口截断。为了控制token成本,系统通常只保留最近N轮对话。当用户的追问依赖更早轮次的指代对象时(例如第三轮的“它的报销比例是多少”指向第一轮提到的某个险种),截断会直接导致指代悬空,检索query变成一个语义不完整的句子,召回结果自然跑偏。
第二个节点是检索query的构造方式。不少实现直接把原始用户最后一轮发言送进检索器,完全没有融合历史。这样做在独立问题上没问题,但在追问场景下,“那病假呢”这样的query对向量检索来说几乎没有信息量,embedding后的向量落在语义空间的模糊区域,召回质量极差。
第三个节点是长历史挤占上下文。有些系统确实把全部历史塞进了prompt,但没有做优先级管理,导致历史部分占掉了大部分token,真正检索回来的文档内容反而被截断或置于注意力边缘位置,生成模型“看见了但没记住”。
第四个节点相对隐蔽:压缩过程本身造成语义失真。用摘要模型压缩历史时,如果提示词设计不当,摘要会丢掉实体、数字等检索必需的关键槽位信息,压缩后的历史反而比截断更糟。
二、历史压缩的两种实现思路
1. 滑动窗口加增量摘要
这是工程上最常用的方案。核心思想是:保留最近K轮原始对话保证细节完整,更早的轮次则滚动合并为一段摘要,摘要本身也随对话推进不断更新。这样既控制了总长度,又保住了长程信息。
实现时有两个细节需要注意。一是摘要更新的触发时机,建议每当被挤出窗口的轮次出现时就触发一次增量合并,而不是每次请求都全量重摘,前者能把摘要调用的token成本降低一半以上。二是摘要提示词中必须明确要求保留实体、数值和时间信息,否则大模型倾向于输出泛化描述,检索时匹配不上具体文档。下面是一个可参考的Python实现骨架:
class HistoryCompressor:
def __init__(self, llm, keep_rounds=4):
self.llm = llm
self.keep_rounds = keep_rounds # 保留最近几轮原始对话
self.stale_summary = "" # 早期轮次的滚动摘要
def update(self, messages):
# messages 为完整历史,按轮次组织
recent = messages[-self.keep_rounds:]
older = messages[:-self.keep_rounds]
if older:
merged_text = "\n".join(f"{m['role']}: {m['content']}" for m in older)
prompt = (
"请将以下多轮对话压缩为简洁摘要,"
"必须完整保留出现过的实体名称、具体数值、日期,"
"不要使用代词指代:\n" + merged_text
)
self.stale_summary = self.llm.complete(prompt)
return self.stale_summary, recent
这个方案的优点是逻辑简单、可控性强,缺点是摘要质量依赖底层模型,且滚动合并存在误差累积。缓解办法是每隔固定轮数做一次全量重摘,用少量额外成本校正漂移。
2. 关键槽位抽取
另一种思路是把历史结构化,而不是摘要化。预先定义一组业务槽位(如险种名称、时间范围、办理渠道、比较对象等),每轮对话后用抽取模型更新槽位值,检索时将非空槽位拼接进query。这种方式对保险、政务、客服等实体密集领域尤其有效,因为检索匹配本质上依赖的就是实体和属性词。
SLOT_SCHEMA = {
"entity": None, # 核心实体,如"病假"
"time_range": None, # 时间限定
"attribute": None, # 查询属性,如"报销比例"
"compare_target": None
}
def extract_slots(llm, user_utterance, current_slots):
prompt = (
"根据用户发言更新以下槽位,未提及的保持原值,输出JSON:\n"
f"当前槽位: {current_slots}\n用户发言: {user_utterance}"
)
return json.loads(llm.complete(prompt))
槽位方案的优势在于信息零损耗、可审计、可控token极少;局限是泛化性差,换一个业务域就要重新定义schema。实践中常见的做法是两者结合:槽位保证关键实体不丢,摘要补充对话语境。
三、重排策略:让有限的上下文优先承载高价值信息
压缩解决了“历史怎么进上下文”的问题,重排解决的是“进来的东西怎么排序”的问题。生成模型对上下文头尾位置的注意力显著高于中部,这就是常说的lost in the middle现象。如果不对拼接后的上下文做重排,高价值信息很可能被埋在中部,导致模型明明有正确资料却答错。
重排包含两层。第一层是检索结果重排:用交叉编码器对召回的候选文档与融合后query的相关性打分,取top-k进入生成。交叉编码器比向量检索的双塔结构更精细,通常能带来明显的准确率提升。示例流程如下:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base")
def retrieve_with_rerank(query, retriever, top_k=5):
candidates = retriever.search(query, top_n=20) # 粗召回
pairs = [(query, doc.text) for doc in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [doc for doc, _ in ranked[:top_k]]
第二层是prompt内部的位置重排。拼接最终prompt时,建议采用“首尾放置法”:把与当前问题最相关的1到2个文档放在上下文最前面,历史压缩摘要放在其后,次相关文档放中部,把系统指令和当前问题固定在末尾。这个顺序能让最容易影响答案的信息占据注意力最强的位置。
另一个容易被忽略的重排维度是去重与合并。多轮检索中,相邻轮次召回的文档往往高度重叠,如果不做去重,上下文里会反复出现同一段内容,白白浪费token并稀释有效信息密度。可以按文档向量相似度超过0.95即判定为重复,合并时保留得分更高的版本。
四、参数调优与效果验证
方案落地后需要用数据验证,而不是凭感觉调整。建议构建一组多轮追问测试集,每条包含3到5轮依赖历史的对话,标注每轮的期望命中文档,统计检索命中率与端到端答案准确率作为基线,再逐项开启优化观察增量。
参数方面有三个经验值可以参考:保留轮数K取4到6通常够用,再大边际收益很低;摘要最大长度控制在300 token以内,避免摘要本身变成新的负担;重排后的top_k取3到5,超过5个文档后生成质量往往不升反降,因为噪声占比上升了。
实际项目中,压缩加双层重排的组合通常能把多轮追问场景的检索命中率从七成左右提升到九成以上,而单次请求的token成本增加通常不超过两成,性价比非常高。如果业务对延迟敏感,可以把槽位抽取和摘要更新做成异步任务,在用户输入下一句话的间隙完成,让线上链路几乎没有额外等待。
总结一下,对话历史丢失不是单一bug而是链路性问题,需要沿“截断、query构造、上下文挤占、压缩失真”四个环节逐一排查,再用压缩控制长度、用重排控制优先级,两头配合才能真正让多轮RAG对话既连贯又准确。