实习生问我:为什么文档喂得越多,答案越差
七月份团队来了个实习生,负责维护我们的文档问答系统。他有天跑来问我一个问题:「我把 topK 从 5 调到 20,召回率肯定上去了吧?但测试集准确率从 71% 掉到 63%,是不是哪儿写错了?」
我看了下代码,没写错。这不是 bug,这是我们一直没认真对待的一件事——上下文里放什么、放多少、怎么放,比提示词怎么写重要得多。
这篇记录我们后来两个月在「上下文工程」上的摸索。
先把现象量化
我们有个 300 条的问答测试集,每条都有人工标注的标准答案和相关文档。用它跑了组对照实验:
| topK | 平均上下文 token | 答案准确率 | 答案「跑题」率 |
|---|---|---|---|
| 3 | 2,100 | 68.3% | 4.1% |
| 5 | 3,400 | 71.0% | 5.7% |
| 10 | 6,900 | 69.5% | 9.2% |
| 20 | 13,800 | 63.2% | 17.4% |
| 50 | 34,200 | 54.8% | 26.1% |
准确率在 topK=5 达到峰值然后单调下降。「跑题」率(答案和任何一份文档都不相关)则一路上升。这说明模型并没有因为信息变多而变聪明,反而更容易被带偏。
更关键的发现来自位置实验。我们把唯一的正确文档分别放在上下文的第 1 份、第 10 份、第 20 份,其余用无关文档填充,topK 固定 20:
| 正确文档位置 | 准确率 |
|---|---|
| 第 1 份 | 84% |
| 第 10 份(中间) | 61% |
| 第 20 份(末尾) | 79% |
中间位置明显塌陷。这就是常说的 lost in the middle 现象,我们自己测出来的塌陷幅度是 23 个百分点,比论文里报的还狠一些,可能是中文长文档里段落相似度高、更容易互相干扰。
策略一:先筛后进,而不是先进后筛
原来的流程是「向量检索 topK → 全部拼进 prompt」。问题在于向量相似度和「对回答这个问题有用」不是一回事。相似度 0.82 的一段话可能和问题高度同义但不含答案。
我们加了一层轻量重排。不用大模型打分(太贵太慢),用了一个 bge-reranker 的本地服务,对 topK=20 的候选打分后取前 5:
// 两阶段检索:宽召回 + 精排序
List<Doc> retrieve(String query) {
List<Doc> candidates = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(20).build());
return reranker.rank(query, candidates).stream()
.filter(scored -> scored.score() > 0.35) // 阈值很关键
.limit(5)
.map(ScoredDoc::doc)
.collect(toList());
}
准确率从 71.0% 提到 79.6%。而且因为最终只用 5 份,上下文反而从 3400 token 降到 3100 token,成本还降了一点。
那个 0.35 的阈值是调出来的。我们统计了 rerank 分数的分布,发现正确文档的分数集中在 0.6~0.95,无关文档集中在 0.05~0.3,中间有个明显的谷。阈值设 0.35 时,30% 的问题会只召回 1~2 份文档,但这些问题的准确率反而更高(86%),说明「少而准」确实优于「多而全」。
策略二:给上下文排序,把关键信息放两头
既然中间位置会塌陷,那就别把重要内容放中间。排序规则我们定了三条:
- rerank 得分最高的放最前面;
- 次高的放最后面(模型对末尾的关注度仅次于开头);
- 其余按分数从高到低填中间空位。
List<Doc> reorder(List<Doc> ranked) {
List<Doc> out = new ArrayList<>(Collections.nCopies(ranked.size(), null));
int head = 0, tail = ranked.size() - 1;
boolean toHead = true;
for (Doc d : ranked) { // ranked 已按分数降序
if (toHead) out.set(head++, d);
else out.set(tail--, d);
toHead = !toHead;
}
return out;
}
这个改动几乎零成本,准确率又提了 3.2 个百分点。
策略三:文档不是整块塞,先做抽取式压缩
我们的文档平均 1400 字,但真正和问题相关的往往就两三句话。整块塞进去,有用的信息被稀释了。
我们试过用小模型做摘要(抽取式),效果不错但每次多一次调用,P99 增加 400ms 左右。后来改成了更朴素的办法——按句切分后逐句打分,只保留高分句,用的还是那个已经跑起来的 reranker:
String compress(Doc doc, String query) {
List<String> sentences = splitByPunctuation(doc.content());
if (sentences.size() <= 6) return doc.content();
List<ScoredSentence> scored = reranker.rank(query, sentences);
// 保留分数前 40%,但按原文顺序重排,保持可读性
return scored.stream()
.sorted(comparingDouble(ScoredSentence::score).reversed())
.limit((long)(sentences.size() * 0.4))
.sorted(comparingInt(s -> s.index()))
.map(ScoredSentence::text)
.collect(joining(""));
}
压缩后单份文档平均从 680 token 降到 290 token,五份文档一共省了近 2000 token。准确率 78.9%,比重排方案的 82.8% 低了 4 个点。
所以我们没全量用,只在「文档特别长」的场景开(>2000 字的文档)。这也是上下文工程里的一条经验:压缩一定是有损的,要按场景权衡而不是一刀切。
策略四:多轮对话的上下文管理
前面都是单轮。多轮场景更复杂,因为上下文里有三类东西在同时增长:历史问答、工具返回、检索文档。
我们的处理原则是按「稳定性」分层,稳定的放前面(还能吃到 prompt 缓存),易变的放后面:
// 上下文分层:静态前缀 → 检索文档 → 历史摘要 → 最近原文 → 当前问题
String buildContext(ContextParts p) {
return String.join("\n\n",
SYSTEM_AND_TOOLS, // 约 1800 token,全程不变,可缓存
p.retrievedDocs(), // 每轮都变,但和当前问题最相关
p.historySummary(), // 早期历史的压缩摘要
p.recentTurns(2), // 最近 2 轮保留原文
p.currentQuestion() // 当前问题放最后,注意力最强
);
}
这里有个细节踩过坑:一开始我们把检索文档放在历史后面,结果多轮之后模型经常「回答上一轮的问题」。把文档挪到历史前面,并把当前问题放最末尾,这个问题基本消失了。
历史摘要我们用 qwen-turbo 做,每积累超过 3000 token 就压一次。压缩提示词里明确要求保留「用户已经确认过的事实」和「未解决的疑问」这两类,其他都可以丢:
把以下对话历史压缩为 300 字以内,必须保留:
1. 用户已明确陈述或确认的事实(如订单号、时间、偏好)
2. 尚未解决或待确认的问题
可以丢弃:寒暄、重复的追问、已完成的中间步骤
策略五:上下文预算要硬编码进代码
前面几步做完,我们还加了一道保险:在代码里给上下文设硬预算,超了就按优先级裁剪。这是防止个别异常请求把整套机制击穿——比如用户粘贴了一篇 3 万字的技术文档进来问问题。
public class ContextBudget {
// 按能力分配预算,不是全局一刀切
static final Map<String, Integer> BUDGET = Map.of(
"qa", 24_000, // 文档问答:5 份文档 + 历史
"chat", 12_000, // 闲聊:历史为主,不需要文档
"agent", 32_000 // Agent:含工具返回
);
static final Map<String, Integer> RESERVE = Map.of(
"qa", 4_000, "chat", 2_000, "agent", 8_000 // 留给输出
);
public List<Segment> fit(String capability, List<Segment> parts, int modelLimit) {
int budget = Math.min(BUDGET.get(capability), modelLimit - RESERVE.get(capability));
int used = parts.stream().mapToInt(Segment::tokens).sum();
if (used <= budget) return parts;
// 按优先级从低到高丢,系统提示永丢
List<Segment> sorted = parts.stream()
.filter(s -> s.priority() < 100)
.sorted(comparingInt(Segment::priority))
.collect(toCollection(ArrayList::new));
for (Segment s : sorted) {
if (used <= budget) break;
used -= s.tokens();
parts.remove(s);
}
log.warn("context overflow, dropped {} segments, cap={}", dropped, capability);
return parts;
}
}
这里的关键是丢之前要告警。我们把这个 warning 接到了监控上,如果某个能力的裁剪触发率超过 5%,说明预算设小了或者上游有问题,要人来看。上线第一个月,告警帮我们发现了两个 bug:一个是某类文档的切分逻辑失效产生了超大分片,一个是某个调用方把整个数据库导出的 CSV 塞进了问题里。
我们最后定下来的流程
| 阶段 | 做法 | 准确率 | 上下文 token |
|---|---|---|---|
| 基线 | topK=5 直接拼接 | 71.0% | 3,400 |
| + 重排筛选 | topK=20 → rerank → 取 5 | 79.6% | 3,100 |
| + 位置重排 | 高低分交替两头放 | 82.8% | 3,100 |
| + 长文档压缩 | >2000 字文档抽句 | 81.3% | 2,400 |
| + 多轮分层 | 历史摘要 + 当前问题置尾 | 84.1% | 2,700 |
最终方案比基线高了 13 个百分点,上下文反而更小,单轮成本从 ¥0.021 降到 ¥0.017。
几条我认为最值得记住的
- 上下文是有限资源,不是仓库。每往里塞一份文档,都在挤占其他信息的注意力。我们有次排查一个答案错误,发现是因为上下文里有段和问题主题相似但结论相反的历史文档,模型被它带跑了。
- 位置比数量影响更大。同样 5 份文档,换个顺序能差 3 个点,这个投入产出比高得离谱。
- 先测量再优化。这套优化我们做了两个月,其中三周花在做测试集和自动化评估上。没有这个测试集,每次改动都是在盲改。
- 压缩是有损的,要有开关。我们给每种压缩策略都留了配置,可按业务场景单独关闭。客服场景对准确率敏感,压缩开得保守;内部知识检索场景则激进得多。
- 别忘了检查「实际发出去的东西」。我们有一次排查答案错误,花了半天看模型、看检索,最后打印完整 prompt 才发现上游一个 bug 把两份重复文档塞了进去。现在调试流程第一步就是 dump prompt。
小结
Prompt 工程解决的是「怎么问」,上下文工程解决的是「问的时候让模型看到什么」。前者的天花板在模型自身,后者的天花板在我们的信息组织能力——而后者往往才是瓶颈。
还有一点值得单独说:优化过程中我们一直盯着测试集的细分场景分数,而不是总分。有两次改动总分涨了,但「多轮追问」这一类掉了 5 个点以上,都被拦下来了。上下文策略对不同场景的影响差异很大,看总分很容易被平均掉。
那个实习生后来跟我说,他现在调试 AI 效果的第一反应不是改提示词了,是去打印一遍实际发出去的完整 prompt 看看里面到底有什么。我觉得这个习惯比任何技巧都值钱。