有一次排查一个答错的 case,检索结果完全正确:五段材料都在,标准答案就在第三段里。我把这五段原封不动换个顺序重新喂给模型,它答对了。区别只在排序——线上是按相似度从高到低拼的,最相关那段被放在中间。
那次之后我不再把上下文构造当成「把字符串 join 起来」的胶水代码。它其实是在给模型排版一份临时材料:哪些放前面、哪些放后面、每段带什么标签、总共占多少预算,都会直接改变答案。
本系列第 10/15 篇 · 上一篇:重排序:两阶段架构的收益与代价 · 下一篇:RAG 评测:没有评测集就没有优化
位置不是中性的
长上下文模型对首尾信息的利用率通常高于中间,这个现象常被称作 lost in the middle。它不是一个可以精确套用的公式,但足以推翻一个默认做法:把最相关的片段按分数降序排,然后就不管了。降序排列会让第 2、3 名之后的内容挤在中间,而多数回答需要好几段证据一起支撑。
我现在的编排规则很朴素:
- 最强证据放开头和结尾,把相对次要、用作背景的片段放中间;
- 按论证顺序而非分数顺序排列,如果材料之间有因果或时间关系,保持这个关系;
- 相邻片段不要来自同一
doc_id,避免连续几段重复同一段话把注意力吃掉; - 最终片段数控制在 5–8 条,超过之后新增片段带来的增益通常抵不过注意力稀释。
一个实用的包装格式:
1 | <材料 1> |
三点说明。编号是引用校验的锚点,生成后要检查答案里出现的编号是否真的在送进去的材料里;来源和日期是时效判断的依据;「回答要求」放在材料之后、问题之前或紧邻问题,比放在 system prompt 深处更容易被遵守。
去重、多样性与预算
融合和重排之后,候选里经常混着同一篇文章的多个 chunk、不同分页、以及新旧两个版本。不去重的话,五条上下文里三条是同一段话,等于白丢两个位置。
1 | def build_context(candidates, budget_tokens, tokenizer): |
is_superseded 处理的是第 4 篇那个新旧结论打架的问题:同一 doc_id 有更高 revision 的片段在场时,旧片段不进上下文。有些旧文是唯一来源,不能一刀切按日期丢弃,所以我的做法是降权 + 显式标注,而不是删除——在包装里写「发布于 2021 年,可能已过时」,把判断交给模型,同时让它知道该谨慎。
预算要按角色切分,而不是只给上下文留一个总数:
1 | 总窗口 = 系统指令 + 工具定义 + 历史对话 + 检索材料 + 用户问题 + 输出预留 |
输出预留最容易被忘。上下文塞满到只剩几百 token 的空间,模型会被迫给出很简短甚至截断的答案。我会显式给输出留出空间(生成类任务留得更多),并在日志里记录每部分的实际 token 占比。这个占比表也是排查「最近答案变短了」的第一站。
压缩:省下来的预算值不值
上下文压缩有三类做法,风险递增:
抽取式:只保留与查询相关的句子,用规则或轻量模型打分。可控、便宜、不引入新事实,但容易把转折和限定条件一起删掉——「通常情况下可以,但月结账户除外」被截成「通常情况下可以」,结论就反了。我的规则是限定句、否定句、条件句整句保留,不做句内截断。
摘要式:用小模型把长片段压成几句。省得多,但摘要会丢细节、也可能引入原文没有的概括。适合背景性材料,不适合承载精确数字或条款的证据段。
查询相关的 token 级压缩(LLMLingua 一类思路):用小模型估算每个 token 对当前查询的信息量,删掉低价值的。压缩比高,但输出往往不再是通顺自然语言,可读性差,而且对数字、专有名词误删的代价很大。
我实际上线时只做两件事:父文档回溯时按章节裁剪而不是整篇塞入(第 5 篇),以及对明显超长的背景材料做抽取式压缩。证据段一律不压缩。判断标准很直接:如果删掉之后答案可能变,就别删。压缩省下的 token 带来的收益是延迟和成本,风险是忠实度,这两者不该在同一笔交易里被默认接受。
拒答与时效冲突,都不是相似度阈值能解决的
调相似度阈值来决定「要不要答」,我试过,基本无效:语料里总有一段相似度不低但完全不相干的内容,模型拿到它就能凑出一段像模像样的回答。更稳的是两阶段判断:先让模型只看候选材料的标题与摘要列表,判断有没有能回答该问题的材料;没有就直接拒答,有才进入片段构建与生成。这个改动在我自己的小评测集上比换嵌入模型明显得多。
时效冲突则要靠元数据显式化。包装里带 published_at、updated_at 和 revision,并在指令里写明「材料之间存在冲突时,优先采用更新时间更晚的,并说明存在分歧」。别指望模型从正文里自己推断哪份更新。
后置校验:最后一道便宜的防线
生成完成到返回用户之间,还有一段纯代码能做的事:
1 | def post_check(answer, picked): |
引用编号校验很便宜,却能拦住最刺眼的一类幻觉。更严格的做法是逐句核对答案中的数字、日期、专有名词是否能在材料里找到支撑,代价是要再做一次模型调用,我只在合规要求高的场景开。
另外一个常被跳过的是记录实际送进模型的 prompt。没有这份快照,第 4 篇的故障归因表就填不出来:你不知道模型到底看了什么,只能猜。我把 context_snapshot 和每个片段的 chunk_id、分数、位置一起写进 trace,出问题时能一键复现。
优劣与边界
把上下文构造当成独立一层,收益是可控:位置编排、去重、预算、引用、拒答、校验都有明确的落点,改任何一处都能单独评测;trace 也让线上问题可复现。代价是多一层代码和一套包装格式,而且包装格式与模型强相关——换模型时指令位置、编号写法往往要重新验证,第 14 篇之外的模型迁移成本里这一块常被低估。
不适用的时候也明确:单片段、短问答、答案几乎不依赖多段证据的场景,做精心的编排收益很小;反过来,多跳推理、需要跨文档比对、或材料之间本身有冲突的场景,这一层几乎是决定性的。
到这里,从语料到答案的完整 RAG 链路都写完了。但「我觉得变好了」不算数——切分、嵌入、融合、重排、编排五层都能调,没有评测就没法知道该动哪一层。下一篇讲怎么把这条链路变成可测量的系统。你现在能不能打印出某次回答的完整 context_snapshot,包括每个片段的位置和 token 数?
评论