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 在回答中标注信息来源。