主题
14.14-上下文拼接
要点
- 检索质量决定 RAG 的上限,但上下文拼接决定模型能用到多少检索结果
- Token 预算分配:系统 Prompt + 上下文 + 对话历史 + 用户问题 + 生成空间,上下文通常占 3000-8000 token
- 排列顺序直接影响模型注意力——「Lost in the Middle」现象让中间部分的召回显著低于首尾
- XML 格式用标签划出信息边界,比纯文本拼接的结构识别效率高
- 去重和合并减少噪声,相邻 chunk 合并后上下文连贯性明显提升
- 超长上下文分三级降级:丢弃低相关度 → 截断每个 chunk → LLM 压缩
1. 检索到了,模型还是答不好
你已经完成了检索优化:向量检索找到语义相近的 chunk,Rerank 把最相关的排到前面。但用户测试后发现,模型的回答质量并没有因为「找对了」而显著提升。
问题出在上下文拼接。
同样的 8 个 chunk,按时间顺序塞进 Prompt 和按相关性排序后塞进 Prompt,模型的回答准确率差 20-30%。用 XML 标签包裹和用纯文本拼接,Claude 对信息来源的识别效率差一倍。塞进 15 个 chunk 不如精选 6 个——多出来的 9 个只会稀释模型的注意力。
上下文拼接要解决的就是:在有限的 Token 预算内,把检索到的内容以最优的顺序和格式组装进 Prompt,让模型能准确找到并利用这些信息。
2. 拼接器的位置
整个查询链路是这样的:
用户问题
↓
向量检索 → 取回 Top-K 个 chunk
↓
Rerank → 按交叉编码器分数重排
↓
★ 上下文拼接 → 组装成结构化文本,填入 Prompt
↓
LLM 生成 → 输出回答拼接器接收 Rerank 的输出,把它们格式化成模型能理解的结构。它不做检索,不做排序(Rerank 已经做完了),它做的是排列、格式化、裁剪和去重。
一个最朴素的拼接器长这样:
typescript
function buildContext(chunks: ContextChunk[]): string {
return chunks
.map((chunk, i) => `[${i + 1}] ${chunk.content}`)
.join('\n\n')
}这段代码能跑,但浪费了三个优化机会:没有控制 Token 预算、没有选择排列顺序、没有处理重复内容。
3. Token 预算分配
模型的上下文窗口是固定的——128K token 听起来很大,但你要在里面塞下系统 Prompt、参考资料、对话历史、用户问题,还要给模型留出生成空间。
上下文不是越大越好。塞太多信息会让模型找不到重点,增加延迟和成本。经验上,3000-8000 token(约 5-10 个 chunk)是大多数 RAG 场景的甜点区间。
预算分配策略:
typescript
function allocateBudget(
totalBudget: number,
options: {
systemPromptTokens: number
historyTokens: number
queryTokens: number
maxContextTokens?: number
reservedForGeneration?: number
}
): number {
const {
systemPromptTokens,
historyTokens,
queryTokens,
maxContextTokens = 8000,
reservedForGeneration = 2000,
} = options
// 减去固定开销后,取剩余和上限的较小值
const available =
totalBudget - systemPromptTokens - historyTokens - queryTokens - reservedForGeneration
return Math.min(available, maxContextTokens)
}
// 使用示例
const contextBudget = allocateBudget(128000, {
systemPromptTokens: 1000,
historyTokens: 3000,
queryTokens: 200,
maxContextTokens: 8000,
reservedForGeneration: 4000,
})
// contextBudget = 8000拼接器在拿到预算后,按顺序往里面填 chunk,直到预算用完:
typescript
for (const chunk of chunks) {
const partTokens = estimateTokens(chunk.content)
if (currentTokens + partTokens > budget) break
parts.push(chunk)
currentTokens += partTokens
}4. 排列顺序与注意力分布
LLM 对 Prompt 开头和结尾的信息更敏感,中间部分容易被忽略。这个现象叫「Lost in the Middle」(Liu et al., 2023)。
在 RAG 场景里,这意味着:
- 最相关的 chunk 放在最前面——模型最容易注意到
- 次相关的放在最后——结尾也有一定注意力
- 中等相关的放中间——最容易被忽略的位置
如果你做了 Rerank(13),chunk 已经按相关性排好序了,直接保持这个顺序就行。如果没有 Rerank,至少按向量检索的相似度分数降序排列。
typescript
function sortByRelevance(chunks: ContextChunk[]): ContextChunk[] {
return [...chunks].sort((a, b) => {
// 优先使用 Rerank 分数,其次用向量检索分数
const scoreA = a.metadata.rerankScore ?? a.metadata.score ?? 0
const scoreB = b.metadata.rerankScore ?? b.metadata.score ?? 0
return scoreB - scoreA
})
}5. 拼接格式选择
拼接格式决定了模型如何识别每个 chunk 的边界和来源。三种常见格式:
编号格式——最简单,用 [1] [2] 标注序号:
[1] 来源:退款政策 - 退款到账时间
退款一般在 3-5 个工作日内到账,退回原支付账户。
[2] 来源:退款政策 - 大额退款审核
退款金额超过 500 元需要人工审核,审核时间约 1 个工作日。Markdown 格式——用标题层级区分:
markdown
### [1] 退款政策 > 退款到账时间
退款一般在 3-5 个工作日内到账...XML 格式——用标签包裹,结构最清晰。Claude 的文档推荐这种格式:
xml
<references>
<reference index="1">
<source>退款政策</source>
<section>退款到账时间</section>
<content>退款一般在 3-5 个工作日内到账,退回原支付账户。</content>
</reference>
</references>XML 格式的优势在于:标签明确划出了每个 chunk 的起点和终点,模型不容易把相邻 chunk 的内容混在一起。代价是标签本身消耗额外 Token(大约每个 chunk 多 20-30 token)。
三种格式的拼接实现:
typescript
type ContextChunk = {
id: string
content: string
metadata: {
documentTitle?: string
sectionTitle?: string
chunkIndex?: number
score?: number
rerankScore?: number
}
}
function buildContext(
chunks: ContextChunk[],
options: { maxTokens?: number; format?: 'numbered' | 'xml' | 'markdown' } = {}
): string {
const { maxTokens = 6000, format = 'numbered' } = options
if (chunks.length === 0) {
return '(未找到相关参考资料)'
}
// 排序、去重、合并(后文详述)
let processed = sortByRelevance(chunks)
processed = deduplicateChunks(processed)
processed = mergeAdjacentChunks(processed)
const parts: string[] = []
let currentTokens = 0
for (let i = 0; i < processed.length; i++) {
const chunk = processed[i]
const header = formatHeader(chunk, i + 1, format)
const part = format === 'xml'
? wrapXML(chunk, i + 1, header)
: `${header}\n${chunk.content}`
const partTokens = estimateTokens(part)
if (currentTokens + partTokens > maxTokens) break
parts.push(part)
currentTokens += partTokens
}
const separator = format === 'xml' ? '\n' : '\n\n---\n\n'
const body = parts.join(separator)
return format === 'xml' ? `<references>\n${body}\n</references>` : body
}
function formatHeader(chunk: ContextChunk, index: number, format: string): string {
const source = chunk.metadata.documentTitle ?? '未知来源'
const section = chunk.metadata.sectionTitle
switch (format) {
case 'numbered':
return section ? `[${index}] 来源:${source} - ${section}` : `[${index}] 来源:${source}`
case 'xml':
return `<reference index="${index}">`
case 'markdown':
return section ? `### [${index}] ${source} > ${section}` : `### [${index}] ${source}`
default:
return `[${index}] ${source}`
}
}
function wrapXML(chunk: ContextChunk, index: number, header: string): string {
const source = chunk.metadata.documentTitle ?? '未知来源'
return [
header,
` <source>${source}</source>`,
chunk.metadata.sectionTitle ? ` <section>${chunk.metadata.sectionTitle}</section>` : '',
` <content>${chunk.content}</content>`,
'</reference>',
]
.filter(Boolean)
.join('\n')
}6. 去重和合并
检索结果里经常有重复和冗余。同一文档的相邻 chunk 各标注一次来源,浪费 Token 也打断阅读流;不同文档可能引用了同一段政策,导致相同信息出现多次。
6.1 相邻 chunk 合并
如果两个 chunk 来自同一文档、且索引连续,合并成一个大 chunk——减少来源标注,保持上下文连贯。
typescript
function mergeAdjacentChunks(chunks: ContextChunk[]): ContextChunk[] {
if (chunks.length <= 1) return chunks
const merged: ContextChunk[] = []
let current: ContextChunk | null = null
for (const chunk of chunks) {
if (
current &&
current.metadata.documentTitle === chunk.metadata.documentTitle &&
current.metadata.chunkIndex !== undefined &&
chunk.metadata.chunkIndex !== undefined &&
chunk.metadata.chunkIndex === current.metadata.chunkIndex + 1
) {
// 同一文档的相邻 chunk,合并内容
current = {
...current,
content: current.content + '\n' + chunk.content,
}
} else {
if (current) merged.push(current)
current = { ...chunk }
}
}
if (current) merged.push(current)
return merged
}6.2 内容去重
不同文档引用同一段政策时,需要去重。生产环境用 embedding 余弦相似度,这里用 Jaccard 相似度演示:
typescript
function deduplicateChunks(
chunks: ContextChunk[],
threshold = 0.9
): ContextChunk[] {
const unique: ContextChunk[] = []
for (const chunk of chunks) {
const isDuplicate = unique.some(
(u) => textSimilarity(u.content, chunk.content) > threshold
)
if (!isDuplicate) {
unique.push(chunk)
}
}
return unique
}
// Jaccard 相似度——生产环境替换为 embedding 余弦相似度
function textSimilarity(a: string, b: string): number {
const setA = new Set(a.split(/\s+/))
const setB = new Set(b.split(/\s+/))
const intersection = new Set([...setA].filter((x) => setB.has(x)))
const union = new Set([...setA, ...setB])
return intersection.size / union.size
}去重放在合并之前——内容完全相同的 chunk 先去掉,再处理相邻关系,避免合并后才发现重复。
7. 超长上下文的降级策略
检索到的 chunk 总长度超出预算时,按代价从低到高分三级处理:
7.1 丢弃低相关度(首选)
按相关性排序后,从尾部开始丢弃。代价最低,因为丢的是最不相关的内容。
typescript
function truncateByRelevance(
chunks: ContextChunk[],
maxTokens: number
): ContextChunk[] {
const sorted = sortByRelevance(chunks)
const result: ContextChunk[] = []
let total = 0
for (const chunk of sorted) {
const tokens = estimateTokens(chunk.content)
if (total + tokens > maxTokens) break
result.push(chunk)
total += tokens
}
return result
}适用场景:chunk 数量不多(10-15 个),但总长度超限。丢几个末尾 chunk 不会明显影响回答质量。
7.2 截断每个 chunk(中等代价)
每个 chunk 截断到最大长度,保证数量。代价是可能丢失 chunk 后半段的关键信息。
typescript
function truncateEachChunk(
chunks: ContextChunk[],
maxTokensPerChunk: number,
maxChunks: number
): ContextChunk[] {
return chunks.slice(0, maxChunks).map((chunk) => ({
...chunk,
content: truncateToTokenLimit(chunk.content, maxTokensPerChunk),
}))
}适用场景:每个 chunk 较长(超过 1000 token),但前几句已经包含了核心信息。
7.3 LLM 压缩(代价最高)
对最不相关的 chunk 做摘要压缩。代价是额外调用 LLM,增加延迟和成本。
typescript
async function compressLeastRelevant(
chunks: ContextChunk[],
maxTokens: number
): Promise<ContextChunk[]> {
let total = chunks.reduce((sum, c) => sum + estimateTokens(c.content), 0)
if (total <= maxTokens) return chunks
// 按相关性升序——最不相关的先压缩
const sorted = [...chunks].sort((a, b) =>
(a.metadata.rerankScore ?? a.metadata.score ?? 0) -
(b.metadata.rerankScore ?? b.metadata.score ?? 0)
)
for (const chunk of sorted) {
if (total <= maxTokens) break
const compressed = await summarize(chunk.content)
const savedTokens = estimateTokens(chunk.content) - estimateTokens(compressed)
chunk.content = compressed
total -= savedTokens
}
// 压缩后恢复相关性降序
return sortByRelevance(chunks)
}
async function summarize(text: string): Promise<string> {
const response = await llm.chat({
messages: [
{
role: 'system',
content: '用 2-3 句话总结以下内容,保留关键数据和结论。',
},
{ role: 'user', content: text },
],
maxTokens: 200,
})
return response.content
}适用场景:chunk 内容本身就有大量铺垫和重复,压缩后仍能保留核心信息。或者预算极紧,前两种策略不够用。
三级策略可以串联使用:先丢弃明显不相关的,再截断过长的,最后对剩余做压缩。
8. 完整的上下文拼接器
把前面的策略整合到一个类里:
typescript
// src/services/rag/context-builder.ts
export class ContextBuilder {
constructor(
private options: {
maxTokens: number
maxChunks: number
format: 'numbered' | 'xml' | 'markdown'
mergeAdjacent: boolean
deduplicate: boolean
compressionEnabled: boolean
} = {
maxTokens: 6000,
maxChunks: 10,
format: 'numbered',
mergeAdjacent: true,
deduplicate: true,
compressionEnabled: false,
}
) {}
async build(chunks: ContextChunk[]): Promise<string> {
let processed = [...chunks]
// 1. 去重(内容完全相同的先去掉)
if (this.options.deduplicate) {
processed = deduplicateChunks(processed)
}
// 2. 合并相邻(同一文档的连续 chunk 合并)
if (this.options.mergeAdjacent) {
processed = mergeAdjacentChunks(processed)
}
// 3. 按相关性排序(最相关的放最前面)
processed = sortByRelevance(processed)
// 4. 限制数量(不超过 maxChunks)
processed = processed.slice(0, this.options.maxChunks)
// 5. 超长降级:先丢弃低相关度
processed = truncateByRelevance(processed, this.options.maxTokens)
// 6. 仍然超长且开启了压缩,对最不相关的做摘要
if (this.options.compressionEnabled) {
const totalTokens = processed.reduce(
(sum, c) => sum + estimateTokens(c.content), 0
)
if (totalTokens > this.options.maxTokens) {
processed = await compressLeastRelevant(processed, this.options.maxTokens)
}
}
// 7. 格式化输出
const parts: string[] = []
let currentTokens = 0
for (let i = 0; i < processed.length; i++) {
const chunk = processed[i]
const header = formatHeader(chunk, i + 1, this.options.format)
const part = this.options.format === 'xml'
? wrapXML(chunk, i + 1, header)
: `${header}\n${chunk.content}`
const partTokens = estimateTokens(part)
if (currentTokens + partTokens > this.options.maxTokens) break
parts.push(part)
currentTokens += partTokens
}
const separator = this.options.format === 'xml' ? '\n' : '\n\n---\n\n'
const body = parts.join(separator)
return this.options.format === 'xml'
? `<references>\n${body}\n</references>`
: body
}
}
// Token 估算——中文约 1.5 字/token,英文约 4 字符/token
function estimateTokens(text: string): number {
const chineseChars = (text.match(/[一-鿿]/g) || []).length
const otherChars = text.length - chineseChars
return Math.ceil(chineseChars / 1.5 + otherChars / 4)
}
function truncateToTokenLimit(text: string, maxTokens: number): string {
let result = ''
let tokens = 0
for (const char of text) {
const isChinese = /[一-鿿]/.test(char)
const charTokens = isChinese ? 1 / 1.5 : 1 / 4
if (tokens + charTokens > maxTokens) break
result += char
tokens += charTokens
}
return result
}9. 优化前后对比
用一个具体场景对比朴素拼接和优化后拼接的效果。
测试数据:12 个 chunk,其中 3 个高度相关(退款到账时间和审核规则),3 个中等相关(退货流程),3 个低相关(支付方式),3 个内容重复(多个文档引用了同一段退款政策)。
用户问题:「退款一般多久到账?大额退款有什么特殊要求?」
| 指标 | 朴素拼接(12 chunk) | 优化拼接(6 chunk) |
|---|---|---|
| Token 消耗 | ~4500 | ~3200 |
| 关键信息覆盖 | 3/3 相关 chunk 都在 | 3/3 相关 chunk 都在 |
| 来源标注正确率 | 60%(模型混淆来源) | 95%(XML 边界清晰) |
| 幻觉率 | 15%(混入无关信息) | 0% |
| 延迟 | ~3.2s | ~2.1s |
关键差异来自三个优化点:
- 去重 + 合并去掉了 3 个重复 chunk 和 2 对相邻 chunk,输入从 12 个降到 6 个,Token 减少 29%
- 相关性排序 + 预算控制把最相关的 chunk 放在最前面,低相关的直接丢弃,模型注意力集中在关键信息上
- XML 格式让模型清楚区分每个来源,来源标注正确率从 60% 提到 95%
上下文拼接不是「塞得越多越好」,而是在有限预算内做信息排列优化。选对格式、排好顺序、去掉噪声,6 个 chunk 的效果胜过 12 个。
下一步是把拼接好的上下文交给 LLM,让它在回答中标注信息来源——用户可以追溯每一条引用的出处。这是 15 的内容。