第五篇:上下文组装
这篇换个结构——从一次真实事故讲起,把上下文组装里的坑一个个拆出来。
事故复盘
某公司内部知识库上线两周后,用户投诉激增:
"问报销政策,AI 给我编了一个不存在的制度。" "问请假流程,它把销售部和研发部的混在一起说了。" "明明搜到了正确答案,但回答里全是废话。"
排查发现:文档解析、切片、Embedding、向量检索、Hybrid Search、Reranker 全都没问题,召回的 Top-10 里确实有正确答案。
问题出在最后一步——上下文组装。
召回的 10 条 chunk 被直接拼接成一个 12K tokens 的 Prompt 塞给 LLM,导致:
- 关键信息被淹没:正确答案在第 7 条,LLM 注意力分散,没抓住重点
- 信息冲突:不同部门的政策混在一起,LLM 自己"融合"出了一个不存在的版本
- 上下文污染:无关内容太多,LLM 开始幻觉
修复方案:加上去重、重排序、窗口截断、来源标注四步处理,投诉量下降 80%。
上下文组装的四个核心问题
问题一:召回太多,塞不下
LLM 的上下文窗口有限(GPT-4o 是 128K,但实际有效注意力远小于此)。召回 50 条 chunk,每条 500 tokens,就是 25K tokens——还没算用户问题和 system prompt,窗口就快满了。
解决方案:分层截断
召回 100 条
↓ Reranker 精排
保留 30 条
↓ 按相关性截断
保留 10-15 条(约 5K-8K tokens)
↓ 给 LLM
经验值:
- 简单问答 → 5-8 条 chunk
- 复杂分析 → 10-15 条 chunk
- 超过 20 条 → 考虑分步问答或摘要压缩
问题二:信息重复,浪费窗口
不同 chunk 可能来自同一文档的不同段落,内容高度重叠。
解决方案:MMR(最大边际相关性)去重
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
def mmr_select(chunks, query_vector, top_k=10, lambda_param=0.7):
"""
MMR: 兼顾相关性和多样性
lambda_param: 1.0 = 只追求相关性, 0.0 = 只追求多样性
"""
chunk_vectors = np.array([c["vector"] for c in chunks])
selected = []
remaining = list(range(len(chunks)))
# 第一步:选最相关的
sims = cosine_similarity([query_vector], chunk_vectors)[0]
first = np.argmax(sims)
selected.append(first)
remaining.remove(first)
# 后续步骤:选与已选内容差异大、且与 query 相关的
while len(selected) < top_k and remaining:
best_score = -np.inf
best_idx = None
for idx in remaining:
# 与 query 的相关性
relevance = sims[idx]
# 与已选内容的最大相似度(越高越冗余)
max_sim_to_selected = max(
cosine_similarity([chunk_vectors[idx]], [chunk_vectors[s]])[0][0]
for s in selected
)
# MMR 分数
score = lambda_param * relevance - (1 - lambda_param) * max_sim_to_selected
if score > best_score:
best_score = score
best_idx = idx
selected.append(best_idx)
remaining.remove(best_idx)
return [chunks[i] for i in selected]
效果: 10 条 chunk 里去掉 3-4 条重复内容,窗口利用率提升 30%+。
问题三:顺序混乱,LLM 抓不住重点
把 chunk 按检索分数降序排列?不一定最优。LLM 对 Prompt 开头和结尾的内容更敏感("位置偏见")。
解决方案:三明治结构
[System Prompt]
[用户问题]
[最相关的 chunk 1]
[次相关的 chunk 2]
...
[较不相关的 chunk N]
[再次强调:请基于以上材料回答,不要编造]
或者更激进的策略:把最相关的 2-3 条放在最前面和最末尾,中间放次要内容。
def reorder_chunks(chunks):
"""三明治排序:最重要的放首尾"""
if len(chunks) <= 3:
return chunks
# 按相关性排序
sorted_chunks = sorted(chunks, key=lambda c: c["score"], reverse=True)
# 首尾放最重要的,中间放次要的
result = [sorted_chunks[0]] # 最重要放开头
result.extend(sorted_chunks[2:]) # 中间放次要的
result.append(sorted_chunks[1]) # 次重要放结尾
return result
问题四:来源不明,用户无法验证
用户看到回答后想查证,但不知道信息来自哪份文档。
解决方案:引用标注
在 Prompt 里给每条 chunk 加编号,让 LLM 在回答时标注来源:
def build_prompt(query, chunks):
# 给 chunk 编号
chunk_texts = []
for i, chunk in enumerate(chunks, 1):
chunk_texts.append(
f"[文档{i}] {chunk['title']} - {chunk['text']}\n"
)
context = "\n".join(chunk_texts)
prompt = f"""你是一个企业内部知识库助手。请严格基于以下材料回答问题,不要编造。
如果材料中没有足够信息,请说"根据现有材料无法回答"。
回答时请在句末标注引用来源,如 [文档3]。
【材料】
{context}
【问题】
{query}
【回答】"""
return prompt
LLM 输出示例:
员工年度报销上限为 5000 元 [文档2],但销售部门因业务需要可申请额外额度 [文档5]。
完整组装流程
def assemble_context(query, raw_chunks, top_k=10):
"""
上下文组装完整流程
"""
# 1. 去重(基于向量相似度)
deduped = mmr_select(raw_chunks, query_vector, top_k=top_k * 2)
# 2. 截断(单条 chunk 太长则截断)
truncated = []
for chunk in deduped:
if len(chunk["text"]) > 500:
chunk["text"] = chunk["text"][:500] + "..."
truncated.append(chunk)
# 3. 重排序(三明治结构)
reordered = reorder_chunks(truncated)
# 4. 构建 Prompt
prompt = build_prompt(query, reordered)
return prompt
进阶技巧
技巧一:摘要压缩
如果召回的 chunk 太多,可以先让 LLM 对每组 chunk 做摘要,再把摘要组装进 Prompt:
召回 50 条 → 分成 5 组 → 每组让 LLM 摘要成 100 tokens → 500 tokens 摘要 + 原始 Top-3 → 给 LLM
适用场景: 需要大范围扫描文档的复杂问题。 代价: 多一次 LLM 调用,延迟增加 1-2 秒。
技巧二:假设性问题分解
用户问"我们公司的报销政策和竞业限制有什么关联?"——这个问题需要同时检索两个主题。
做法: 先用 LLM 把问题拆成子问题,分别检索,再合并上下文:
# 第一步:分解问题
sub_queries = llm_generate(
f"将以下问题分解为多个独立的检索子问题:\n{query}"
)
# 输出:["公司报销政策是什么", "公司竞业限制规定是什么"]
# 第二步:分别检索
all_chunks = []
for sub_q in sub_queries:
chunks = retrieve(sub_q)
all_chunks.extend(chunks)
# 第三步:去重 + 组装
context = assemble_context(query, all_chunks)
技巧三:元数据过滤前置
在检索前就过滤掉不相关的文档,比检索后再清洗更高效:
# 用户问"销售部的报销流程"
# 检索时直接加过滤条件
chunks = retrieve(
query="报销流程",
filters={"department": "sales"} # 只搜销售部文档
)
避坑清单
- 召回全塞 → 超过 15 条 chunk 就开始出现信息淹没,务必截断
- 不去重 → 重复内容浪费窗口,还会让 LLM 误以为某信息很重要
- 不标注来源 → 用户无法验证,信任度大打折扣
- 忽略位置偏见 → 重要信息放中间容易被忽略
- chunk 之间不加分隔符 → LLM 分不清哪里是哪里,用
---或[文档N]明确分隔 - 不处理冲突信息 → 不同文档说法矛盾时,让 LLM 注明"存在不同说法"
- 忘记加"不要编造"指令 → LLM 容易在材料不足时幻觉
总结
上下文组装是 RAG 流水线的最后一公里,前面所有环节的努力都在这一步兑现。召回再准,组装不好也白搭。
一个简单自检: 把你的 Prompt 打印出来,自己读一遍——如果连你都觉得信息杂乱、抓不住重点,LLM 也好不到哪去。