Skip to content

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

关键差异来自三个优化点:

  1. 去重 + 合并去掉了 3 个重复 chunk 和 2 对相邻 chunk,输入从 12 个降到 6 个,Token 减少 29%
  2. 相关性排序 + 预算控制把最相关的 chunk 放在最前面,低相关的直接丢弃,模型注意力集中在关键信息上
  3. XML 格式让模型清楚区分每个来源,来源标注正确率从 60% 提到 95%

上下文拼接不是「塞得越多越好」,而是在有限预算内做信息排列优化。选对格式、排好顺序、去掉噪声,6 个 chunk 的效果胜过 12 个。

下一步是把拼接好的上下文交给 LLM,让它在回答中标注信息来源——用户可以追溯每一条引用的出处。这是 15 的内容。

基于 MIT 协议开源