篇五:上下文组装——召回 50 条全塞给 LLM?难怪回答越来越差

第五篇:上下文组装

这篇换个结构------从一次真实事故讲起,把上下文组装里的坑一个个拆出来。


事故复盘

某公司内部知识库上线两周后,用户投诉激增:

"问报销政策,AI 给我编了一个不存在的制度。" "问请假流程,它把销售部和研发部的混在一起说了。" "明明搜到了正确答案,但回答里全是废话。"

排查发现:文档解析、切片、Embedding、向量检索、Hybrid Search、Reranker 全都没问题,召回的 Top-10 里确实有正确答案。

问题出在最后一步------上下文组装。

召回的 10 条 chunk 被直接拼接成一个 12K tokens 的 Prompt 塞给 LLM,导致:

  1. 关键信息被淹没:正确答案在第 7 条,LLM 注意力分散,没抓住重点
  2. 信息冲突:不同部门的政策混在一起,LLM 自己"融合"出了一个不存在的版本
  3. 上下文污染:无关内容太多,LLM 开始幻觉

修复方案:加上去重、重排序、窗口截断、来源标注四步处理,投诉量下降 80%。


上下文组装的四个核心问题

问题一:召回太多,塞不下

LLM 的上下文窗口有限(GPT-4o 是 128K,但实际有效注意力远小于此)。召回 50 条 chunk,每条 500 tokens,就是 25K tokens------还没算用户问题和 system prompt,窗口就快满了。

解决方案:分层截断

markdown 复制代码
召回 100 条
    ↓ Reranker 精排
保留 30 条
    ↓ 按相关性截断
保留 10-15 条(约 5K-8K tokens)
    ↓ 给 LLM

经验值:

  • 简单问答 → 5-8 条 chunk
  • 复杂分析 → 10-15 条 chunk
  • 超过 20 条 → 考虑分步问答或摘要压缩

问题二:信息重复,浪费窗口

不同 chunk 可能来自同一文档的不同段落,内容高度重叠。

解决方案:MMR(最大边际相关性)去重

ini 复制代码
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 开头和结尾的内容更敏感("位置偏见")。

解决方案:三明治结构

css 复制代码
[System Prompt]

[用户问题]

[最相关的 chunk 1]
[次相关的 chunk 2]
...
[较不相关的 chunk N]

[再次强调:请基于以上材料回答,不要编造]

或者更激进的策略:把最相关的 2-3 条放在最前面和最末尾,中间放次要内容。

python 复制代码
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 在回答时标注来源:

ini 复制代码
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。


完整组装流程

python 复制代码
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:

css 复制代码
召回 50 条 → 分成 5 组 → 每组让 LLM 摘要成 100 tokens → 500 tokens 摘要 + 原始 Top-3 → 给 LLM

适用场景: 需要大范围扫描文档的复杂问题。 代价: 多一次 LLM 调用,延迟增加 1-2 秒。

技巧二:假设性问题分解

用户问"我们公司的报销政策和竞业限制有什么关联?"------这个问题需要同时检索两个主题。

做法: 先用 LLM 把问题拆成子问题,分别检索,再合并上下文:

ini 复制代码
# 第一步:分解问题
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)

技巧三:元数据过滤前置

在检索前就过滤掉不相关的文档,比检索后再清洗更高效:

ini 复制代码
# 用户问"销售部的报销流程"
# 检索时直接加过滤条件
chunks = retrieve(
    query="报销流程",
    filters={"department": "sales"}  # 只搜销售部文档
)

避坑清单

  1. 召回全塞 → 超过 15 条 chunk 就开始出现信息淹没,务必截断
  2. 不去重 → 重复内容浪费窗口,还会让 LLM 误以为某信息很重要
  3. 不标注来源 → 用户无法验证,信任度大打折扣
  4. 忽略位置偏见 → 重要信息放中间容易被忽略
  5. chunk 之间不加分隔符 → LLM 分不清哪里是哪里,用 --- 或 [文档N] 明确分隔
  6. 不处理冲突信息 → 不同文档说法矛盾时,让 LLM 注明"存在不同说法"
  7. 忘记加"不要编造"指令 → LLM 容易在材料不足时幻觉

总结

上下文组装是 RAG 流水线的最后一公里,前面所有环节的努力都在这一步兑现。召回再准,组装不好也白搭。

一个简单自检: 把你的 Prompt 打印出来,自己读一遍------如果连你都觉得信息杂乱、抓不住重点,LLM 也好不到哪去。

相关推荐
小范的技术工坊2 小时前
工具设定决定Agent上限
大模型·agent
每天都要写算法(努力版)2 小时前
【行业前沿报告】SCoRe:为什么会改答案,还需要专门训练
agent·agent 轨迹
zmsup3 小时前
从运维需求到产品能力:OpsArk Agent 的运维智能平台架构设计思路
运维·系统架构·agent·智能体·运维智能平台
智码看视界3 小时前
开源大模型每日追踪:小米 MiMo-V2.6-Pro,登顶开放权重第一(超过 GLM-5.3 、Kimi K3)
agent·vllm·moe·全模态·小米大模型·mimo-v2.6
Together_CZ3 小时前
UFO : A UI-Focused Agent for Windows OS Interaction——面向Windows操作系统的UI聚焦智能体
agent·ufo·面向windows操作系统·ui聚焦智能体·agentos·ui-focused·os interaction
寻道码路12 小时前
大模型工程化实战(十六):政企国产化避坑——合规/信创名录/私有化运维/国产硬件适配/原厂支持这五道关怎么过
大模型·agent·信创·rag·ai工程化·国产化替代·政企ai落地
张忠琳15 小时前
【hermes-agent】Hermes Agent 自我进化原理之一
ai·agent·hermes
Bug收容所16 小时前
学习LangChain day1
学习·langchain·llm·agent
流浪00117 小时前
大模型技术全景(十一):智能体通信协议 MCP、A2A 与 ANP
llm·agent·通信协议·mcp·a2a·anp