跳至主要内容
上下文构建:召回之后才刚开始

上下文构建:召回之后才刚开始

有一次排查一个答错的 case,检索结果完全正确:五段材料都在,标准答案就在第三段里。我把这五段原封不动换个顺序重新喂给模型,它答对了。区别只在排序——线上是按相似度从高到低拼的,最相关那段被放在中间。

那次之后我不再把上下文构造当成「把字符串 join 起来」的胶水代码。它其实是在给模型排版一份临时材料:哪些放前面、哪些放后面、每段带什么标签、总共占多少预算,都会直接改变答案。

本系列第 10/15 篇 · 上一篇:重排序:两阶段架构的收益与代价 · 下一篇:RAG 评测:没有评测集就没有优化

位置不是中性的

长上下文模型对首尾信息的利用率通常高于中间,这个现象常被称作 lost in the middle。它不是一个可以精确套用的公式,但足以推翻一个默认做法:把最相关的片段按分数降序排,然后就不管了。降序排列会让第 2、3 名之后的内容挤在中间,而多数回答需要好几段证据一起支撑。

我现在的编排规则很朴素:

  • 最强证据放开头和结尾,把相对次要、用作背景的片段放中间;
  • 按论证顺序而非分数顺序排列,如果材料之间有因果或时间关系,保持这个关系;
  • 相邻片段不要来自同一 doc_id,避免连续几段重复同一段话把注意力吃掉;
  • 最终片段数控制在 5–8 条,超过之后新增片段带来的增益通常抵不过注意力稀释。

一个实用的包装格式:

TEXT9 行
1
2
3
4
5
6
7
8
9
<材料 1>
来源:/handbook/refund.md#第二节(发布于 2025-08,更新于 2026-03)
[正文]
</材料 1>

...

回答要求:仅依据上述材料;每个结论后标注来源编号;材料不足时直接说明无法确认。
用户问题:{query}

三点说明。编号是引用校验的锚点,生成后要检查答案里出现的编号是否真的在送进去的材料里;来源和日期是时效判断的依据;「回答要求」放在材料之后、问题之前或紧邻问题,比放在 system prompt 深处更容易被遵守。

去重、多样性与预算

融合和重排之后,候选里经常混着同一篇文章的多个 chunk、不同分页、以及新旧两个版本。不去重的话,五条上下文里三条是同一段话,等于白丢两个位置。

PYTHON14 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def build_context(candidates, budget_tokens, tokenizer):
seen, picked = set(), []
per_doc = Counter()
for c in candidates:
key = (c.doc_id, normalize_for_dedup(c.text[:120]))
if key in seen or per_doc[c.doc_id] >= 2:
continue
if is_superseded(c, candidates): # 同 doc 的旧 revision 直接跳过
continue
c.wrapped = wrap(c, idx=len(picked) + 1)
if used + count(c.wrapped) > budget_tokens:
continue
picked.append(c); seen.add(key); per_doc[c.doc_id] += 1
return reorder(picked), used

is_superseded 处理的是第 4 篇那个新旧结论打架的问题:同一 doc_id 有更高 revision 的片段在场时,旧片段不进上下文。有些旧文是唯一来源,不能一刀切按日期丢弃,所以我的做法是降权 + 显式标注,而不是删除——在包装里写「发布于 2021 年,可能已过时」,把判断交给模型,同时让它知道该谨慎。

预算要按角色切分,而不是只给上下文留一个总数:

TEXT1 行
1
总窗口 = 系统指令 + 工具定义 + 历史对话 + 检索材料 + 用户问题 + 输出预留

输出预留最容易被忘。上下文塞满到只剩几百 token 的空间,模型会被迫给出很简短甚至截断的答案。我会显式给输出留出空间(生成类任务留得更多),并在日志里记录每部分的实际 token 占比。这个占比表也是排查「最近答案变短了」的第一站。

压缩:省下来的预算值不值

上下文压缩有三类做法,风险递增:

抽取式:只保留与查询相关的句子,用规则或轻量模型打分。可控、便宜、不引入新事实,但容易把转折和限定条件一起删掉——「通常情况下可以,但月结账户除外」被截成「通常情况下可以」,结论就反了。我的规则是限定句、否定句、条件句整句保留,不做句内截断。

摘要式:用小模型把长片段压成几句。省得多,但摘要会丢细节、也可能引入原文没有的概括。适合背景性材料,不适合承载精确数字或条款的证据段。

查询相关的 token 级压缩(LLMLingua 一类思路):用小模型估算每个 token 对当前查询的信息量,删掉低价值的。压缩比高,但输出往往不再是通顺自然语言,可读性差,而且对数字、专有名词误删的代价很大。

我实际上线时只做两件事:父文档回溯时按章节裁剪而不是整篇塞入(第 5 篇),以及对明显超长的背景材料做抽取式压缩。证据段一律不压缩。判断标准很直接:如果删掉之后答案可能变,就别删。压缩省下的 token 带来的收益是延迟和成本,风险是忠实度,这两者不该在同一笔交易里被默认接受。

拒答与时效冲突,都不是相似度阈值能解决的

调相似度阈值来决定「要不要答」,我试过,基本无效:语料里总有一段相似度不低但完全不相干的内容,模型拿到它就能凑出一段像模像样的回答。更稳的是两阶段判断:先让模型只看候选材料的标题与摘要列表,判断有没有能回答该问题的材料;没有就直接拒答,有才进入片段构建与生成。这个改动在我自己的小评测集上比换嵌入模型明显得多。

时效冲突则要靠元数据显式化。包装里带 published_at、updated_at 和 revision,并在指令里写明「材料之间存在冲突时,优先采用更新时间更晚的,并说明存在分歧」。别指望模型从正文里自己推断哪份更新。

后置校验:最后一道便宜的防线

生成完成到返回用户之间,还有一段纯代码能做的事:

PYTHON10 行
1
2
3
4
5
6
7
8
9
10
def post_check(answer, picked):
cited = set(re.findall(r"\[材料\s*(\d+)\]", answer))
valid = {str(c.idx) for c in picked}
if cited - valid: # 引用了没送进去的编号
return Regenerate("citation_hallucination")
if cited and not overlaps(answer, concat(picked)):
return Regenerate("unsupported")
if hits_sensitive(answer):
return Redact()
return Ok()

引用编号校验很便宜,却能拦住最刺眼的一类幻觉。更严格的做法是逐句核对答案中的数字、日期、专有名词是否能在材料里找到支撑,代价是要再做一次模型调用,我只在合规要求高的场景开。

另外一个常被跳过的是记录实际送进模型的 prompt。没有这份快照,第 4 篇的故障归因表就填不出来:你不知道模型到底看了什么,只能猜。我把 context_snapshot 和每个片段的 chunk_id、分数、位置一起写进 trace,出问题时能一键复现。

优劣与边界

把上下文构造当成独立一层,收益是可控:位置编排、去重、预算、引用、拒答、校验都有明确的落点,改任何一处都能单独评测;trace 也让线上问题可复现。代价是多一层代码和一套包装格式,而且包装格式与模型强相关——换模型时指令位置、编号写法往往要重新验证,第 14 篇之外的模型迁移成本里这一块常被低估。

不适用的时候也明确:单片段、短问答、答案几乎不依赖多段证据的场景,做精心的编排收益很小;反过来,多跳推理、需要跨文档比对、或材料之间本身有冲突的场景,这一层几乎是决定性的。

到这里,从语料到答案的完整 RAG 链路都写完了。但「我觉得变好了」不算数——切分、嵌入、融合、重排、编排五层都能调,没有评测就没法知道该动哪一层。下一篇讲怎么把这条链路变成可测量的系统。你现在能不能打印出某次回答的完整 context_snapshot,包括每个片段的位置和 token 数?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#RAG #上下文 #工程

评论