14.14-上下文拼接

要点

  • 上下文拼接是把检索到的 chunk 组装成 LLM 可以理解的 Prompt
  • 拼接顺序、格式、去重策略直接影响 LLM 的生成质量
  • Token 预算分配:系统 Prompt + 上下文 + 用户问题 + 生成空间——上下文不是越大越好
  • 相关性排序:按 chunk 和查询的相关度排序,最相关的放在最前面
  • 去重和合并:相邻 chunk 可以合并,重复信息需要去重
  • 上下文太长时的降级策略:丢弃低相关度 chunk、压缩历史、截断

1. 拼接的基本结构

RAG 的 Prompt 通常长这样:

你是一个知识库助手。请根据以下参考资料回答用户的问题。
如果参考资料中没有相关信息,请明确说明「根据已有资料无法回答」。

## 参考资料

[1] 来源:退款政策 - 第3章
内容:退款一般在 3-5 个工作日内到账,退回原支付账户。

[2] 来源:退款政策 - 第4章
内容:退款金额超过 500 元需要人工审核,审核时间约 1 个工作日。

[3] 来源:帮助中心
内容:目前不支持部分退款,只能全额退款。

## 用户问题

退款一般多久到账?

## 回答要求

1. 只基于参考资料回答,不要编造
2. 引用来源时标注编号,如 [1]
3. 如果资料不足以回答,明确说明

拼接器要做的事情就是把检索到的 chunk 按格式填进「参考资料」部分。

2. 拼接实现

基础实现

// src/services/rag/context-builder.ts
 
type ContextChunk = {
  id: string
  content: string
  metadata: {
    documentTitle?: string
    sectionTitle?: string
    chunkIndex?: number
    score?: number
  }
}
 
function buildContext(
  chunks: ContextChunk[],
  options: { maxTokens?: number; format?: 'numbered' | 'xml' | 'markdown' } = {}
): string {
  const { maxTokens = 3000, format = 'numbered' } = options
 
  if (chunks.length === 0) {
    return '(未找到相关参考资料)'
  }
 
  const parts: string[] = []
  let currentTokens = 0
 
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i]
    const header = formatHeader(chunk, i + 1, format)
    const part = `${header}\n${chunk.content}`
    const partTokens = estimateTokens(part)
 
    if (currentTokens + partTokens > maxTokens) {
      break  // 超出预算,停止添加
    }
 
    parts.push(part)
    currentTokens += partTokens
  }
 
  return parts.join('\n\n---\n\n')
}
 
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}" source="${source}" section="${section ?? ''}">`
    case 'markdown':
      return section
        ? `### [${index}] ${source} > ${section}`
        : `### [${index}] ${source}`
    default:
      return `[${index}] ${source}`
  }
}

XML 格式(Claude 推荐)

Claude 的文档推荐使用 XML 标签包裹上下文——结构清晰,模型更容易理解边界。

function buildXMLContext(chunks: ContextChunk[], maxTokens = 3000): string {
  const parts: string[] = []
  let currentTokens = 0
 
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i]
    const source = chunk.metadata.documentTitle ?? '未知来源'
 
    const xml = [
      `<reference index="${i + 1}">`,
      `  <source>${source}</source>`,
      chunk.metadata.sectionTitle
        ? `  <section>${chunk.metadata.sectionTitle}</section>`
        : '',
      `  <content>${chunk.content}</content>`,
      '</reference>',
    ]
      .filter(Boolean)
      .join('\n')
 
    const tokens = estimateTokens(xml)
    if (currentTokens + tokens > maxTokens) break
 
    parts.push(xml)
    currentTokens += tokens
  }
 
  return `<references>\n${parts.join('\n')}\n</references>`
}

生成的 Prompt 长这样:

<references>
<reference index="1">
  <source>退款政策</source>
  <section>退款到账时间</section>
  <content>退款一般在 3-5 个工作日内到账,退回原支付账户。</content>
</reference>
<reference index="2">
  <source>退款政策</source>
  <section>大额退款审核</section>
  <content>退款金额超过 500 元需要人工审核,审核时间约 1 个工作日。</content>
</reference>
</references>

3. Token 预算分配

LLM 有上下文长度限制(比如 128K token)。上下文不是越大越好——塞太多信息反而会让模型找不到重点。

Token 预算分配:

总预算: 128K token

┌─────────────────────────────────────────────────────┐
│ 系统 Prompt          │ 约 500-2000 token             │
│ 检索上下文            │ 约 3000-8000 token            │
│ 对话历史             │ 约 2000-5000 token             │
│ 用户当前问题          │ 约 50-500 token               │
│ 生成空间             │ 剩余空间                       │
└─────────────────────────────────────────────────────┘
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
 
  // 上下文不超过 maxContextTokens
  return Math.min(available, maxContextTokens)
}
 
// 使用
const contextBudget = allocateBudget(128000, {
  systemPromptTokens: 1000,
  historyTokens: 3000,
  queryTokens: 200,
  maxContextTokens: 8000,
  reservedForGeneration: 4000,
})
// contextBudget = 8000

实际经验:上下文 3000-8000 token(约 5-10 个 chunk)是大多数 RAG 场景的甜点。更多上下文不一定会提升回答质量,反而可能增加延迟和成本。

4. 相关性排序

检索结果按相关性排序——最相关的 chunk 放在最前面。LLM 对 Prompt 开头和结尾的信息更敏感(「Lost in the Middle」现象)。

function sortByRelevance(chunks: ContextChunk[]): ContextChunk[] {
  return [...chunks].sort((a, b) => {
    // 优先按 Rerank 分数排序
    if (a.metadata.score !== undefined && b.metadata.score !== undefined) {
      return b.metadata.score - a.metadata.score
    }
    // 否则按检索分数排序
    return (b.metadata.score ?? 0) - (a.metadata.score ?? 0)
  })
}

如果做了 Rerank,用 Rerank 分数排序。如果没有,用向量检索的相似度分数排序。

5. 去重和合并

相邻 chunk 合并

如果检索到同一文档的相邻 chunk,可以合并成一个大 chunk——减少重复的来源标注,保持上下文连贯。

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
}

内容去重

不同文档可能包含相似的内容(比如多个文档都引用了同一段政策)。

function deduplicateChunks(chunks: ContextChunk[], threshold = 0.9): ContextChunk[] {
  const unique: ContextChunk[] = []
 
  for (const chunk of chunks) {
    const isDuplicate = unique.some((u) => {
      const similarity = textSimilarity(u.content, chunk.content)
      return similarity > threshold
    })
 
    if (!isDuplicate) {
      unique.push(chunk)
    }
  }
 
  return unique
}
 
// 简单的文本相似度——生产环境用 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  // Jaccard 相似度
}

6. 上下文超长时的降级策略

当检索到的 chunk 总长度超出预算时,需要有降级策略。

策略一:丢弃低相关度

按相关性排序后,从后面开始丢弃,直到 fit 进预算。

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

每个 chunk 截断到最大长度,保证数量。

function truncateEachChunk(
  chunks: ContextChunk[],
  maxTokensPerChunk: number,
  maxChunks: number
): ContextChunk[] {
  return chunks.slice(0, maxChunks).map((chunk) => ({
    ...chunk,
    content: truncateToTokenLimit(chunk.content, maxTokensPerChunk),
  }))
}

策略三:渐进式压缩

对长 chunk 做摘要压缩。

async function compressWithContext(
  chunks: ContextChunk[],
  maxTokens: number
): Promise<ContextChunk[]> {
  let total = chunks.reduce((sum, c) => sum + estimateTokens(c.content), 0)
 
  if (total <= maxTokens) return chunks
 
  // 按相关性排序,最不相关的先压缩
  const sorted = sortByRelevance(chunks).reverse()
 
  for (const chunk of sorted) {
    if (total <= maxTokens) break
 
    // 压缩这个 chunk
    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> {
  // 用 LLM 做摘要
  const response = await llm.chat({
    messages: [
      {
        role: 'system',
        content: '用 2-3 句话总结以下内容,保留关键信息。',
      },
      { role: 'user', content: text },
    ],
    maxTokens: 200,
  })
  return response.content
}

7. 完整的上下文拼接器

// src/services/rag/context-builder.ts
 
export class ContextBuilder {
  constructor(
    private options: {
      maxTokens: number
      maxChunks: number
      format: 'numbered' | 'xml' | 'markdown'
      mergeAdjacent: boolean
      deduplicate: boolean
    } = {
      maxTokens: 6000,
      maxChunks: 10,
      format: 'numbered',
      mergeAdjacent: true,
      deduplicate: true,
    }
  ) {}
 
  build(chunks: ContextChunk[]): string {
    let processed = [...chunks]
 
    // 1. 去重
    if (this.options.deduplicate) {
      processed = deduplicateChunks(processed)
    }
 
    // 2. 合并相邻
    if (this.options.mergeAdjacent) {
      processed = mergeAdjacentChunks(processed)
    }
 
    // 3. 按相关性排序
    processed = sortByRelevance(processed)
 
    // 4. 限制数量
    processed = processed.slice(0, this.options.maxChunks)
 
    // 5. 按 token 预算截断
    const parts: string[] = []
    let currentTokens = 0
 
    for (let i = 0; i < processed.length; i++) {
      const chunk = processed[i]
      const header = this.formatHeader(chunk, i + 1)
      const part = `${header}\n${chunk.content}`
      const partTokens = estimateTokens(part)
 
      if (currentTokens + partTokens > this.options.maxTokens) break
 
      parts.push(part)
      currentTokens += partTokens
    }
 
    return parts.join('\n\n---\n\n')
  }
 
  private formatHeader(chunk: ContextChunk, index: number): string {
    const source = chunk.metadata.documentTitle ?? '未知来源'
    const section = chunk.metadata.sectionTitle
 
    switch (this.options.format) {
      case 'xml':
        return `<reference index="${index}" source="${source}">`
      case 'markdown':
        return section ? `### [${index}] ${source} > ${section}` : `### [${index}] ${source}`
      default:
        return section ? `[${index}] 来源:${source} - ${section}` : `[${index}] 来源:${source}`
    }
  }
}

总结

回顾这一节的要点:

  • 上下文拼接把检索到的 chunk 组装成 LLM 可以理解的 Prompt
  • 拼接格式:numbered、XML、Markdown——XML 对 Claude 最友好
  • Token 预算分配:上下文通常 3000-8000 token(5-10 个 chunk)
  • 相关性排序:最相关的放在最前面,避免「Lost in the Middle」
  • 去重和合并:相邻 chunk 合并保持连贯,重复内容去重
  • 超长降级策略:丢弃低相关度 → 截断每个 chunk → 渐进式压缩
  • 上下文不是越大越好——太多信息反而让模型找不到重点

下一篇讲引用来源返回——让 LLM 在回答中标注信息来源。