logo

长文档问答:Map-Reduce、Refine 与分层摘要

Published on

长文档问答:Map-Reduce、Refine 与分层摘要

长上下文模型出现以后,很多人会自然地想:既然窗口足够大,把整份合同、整本手册或一年的会议记录直接塞进去,不就可以问了吗?

短文档确实可以这样做。文档一长,问题就会一起出现:输入 Token 和成本迅速上升,模型可能忽略中间内容,多个章节的冲突变得难以处理,答案还很难说明究竟依据了哪一页。上下文窗口变大,解决的是“能不能放进去”,没有解决“模型能不能稳定地找到、理解和引用”。

我做长文档问答时,最先学到的教训是:不要把长文档处理成一个巨大的字符串,而要把它当成一个需要编排的计算任务。不同任务可以使用 Map-Reduce、Refine 或分层摘要;选择哪一种,取决于是否需要全局视角、是否需要保持顺序、能否并行、对细节和引用有多高要求。

一、先定义长文档问题的类型

“总结这份报告”与“合同里关于违约责任的条款是什么”是两类不同任务。前者需要覆盖全文并抽象主题,后者需要精确定位并保留原文证据。

全局总结:所有部分都有贡献,需要合并主题
局部问答:少数片段关键,需要精确定位
跨段推理:多个章节共同决定答案,需要保留关系
时间回顾:按顺序处理事件,需要维护时间线

在选择架构之前,先写清成功标准:答案完整还是精确?是否需要引用页码?能否接受摘要损失?延迟和成本上限是多少?没有这些约束,三种方法都可能“看起来能跑”,却无法比较。

二、Map-Reduce:先分开处理,再合并结果

Map-Reduce 把长文档切成多个片段,每个片段独立交给模型处理,最后再把局部结果交给一个汇总模型。

长文档
 ├─ chunk 1 → 局部摘要 / 局部答案 ┐
 ├─ chunk 2 → 局部摘要 / 局部答案 ├→ 汇总模型 → 最终结果
 ├─ chunk 3 → 局部摘要 / 局部答案 ┘
 └─ chunk N → 局部摘要 / 局部答案

它的最大优点是可以并行。文档拆成 20 段后,Map 阶段可以同时处理,适合批量报告和全局摘要。局部任务之间互不依赖,某个片段失败也可以单独重试。

缺点是信息会在 Map 阶段被压缩。两个事实分别位于不同片段时,局部模型可能都认为自己没有足够上下文,最终 Reduce 阶段只拿到已经丢失细节的摘要。

type ChunkSummary = {
  chunkId: string
  summary: string
  keyFacts: string[]
  sourceRefs: string[]
  confidence: number
}

async function mapReduce(
  chunks: Array<{ id: string; text: string }>,
) {
  const partials: ChunkSummary[] = await Promise.all(
    chunks.map(async (chunk) => ({
      chunkId: chunk.id,
      ...(await summarizeChunk(chunk.text)),
    })),
  )

  const groups = groupByTokenBudget(partials, 6000)
  let current = groups

  while (current.length > 1) {
    current = await Promise.all(
      current.map((group) => reduceSummaries(group)),
    )
  }

  return current[0]
}

Reduce 阶段也可能因为局部摘要总量太大而超出上下文,所以常常需要多轮归并。每轮归并都会损失一些细节,不能只看最终文本是否流畅。

三、Refine:按顺序逐步改进答案

Refine 先用第一段文档生成一个初始答案,随后依次读取下一段,把已有答案和新片段一起交给模型,让它决定是否修正或补充。

chunk 1 → 初始答案
             ↓ + chunk 2
          更新答案
             ↓ + chunk 3
          再次更新
          最终答案

Refine 保留了处理顺序,适合时间线、章节顺序和需要持续累积上下文的任务。它也能在新片段出现冲突时要求模型重新判断,而不是等最后才合并。

type RefineState = {
  answer: string
  sourceRefs: string[]
  processedChunks: number
  conflicts: string[]
}

async function refineDocument(
  chunks: Array<{ id: string; text: string }>,
): Promise<RefineState> {
  let state: RefineState = {
    answer: '',
    sourceRefs: [],
    processedChunks: 0,
    conflicts: [],
  }

  for (const chunk of chunks) {
    state = await refineWithChunk(state, chunk)
    state.processedChunks += 1
  }

  return state
}

Refine 的问题也很明显:它通常是串行的,文档越长,延迟越长;早期错误可能被后续步骤继承;模型每次都要重复看到当前答案,输入 Token 会不断累计。如果处理顺序影响答案,必须记录顺序和版本,不能随意并行。

四、分层摘要:先建立地图,再深入细节

分层摘要把文档组织成树:先对段落做摘要,再把段落摘要合成章节摘要,最后合成全局摘要。用户提问时,可以先在高层摘要中定位相关章节,再回到原文或低层摘要获取细节。

原始段落 ─→ 段落摘要 ─→ 章节摘要 ─→ 文档摘要
    ↑          ↑             ↑
  精确证据   局部理解      全局主题

它兼顾了全局导航和局部细节,适合长报告、手册和持续更新的知识库。代价是构建和维护复杂,需要处理树节点版本、父子关系、摘要过期和引用回溯。

type SummaryNode = {
  id: string
  parentId?: string
  level: 'paragraph' | 'section' | 'document'
  text: string
  childIds: string[]
  sourceRefs: string[]
  contentHash: string
  version: string
}

摘要节点不能替代原文。它是导航和压缩层,最终涉及金额、日期、责任和规则的结论,仍然应该回到原文片段核验。

五、三种方法如何选择

维度Map-ReduceRefine分层摘要
处理方式并行局部后汇总串行逐步更新预构建多层表示
适合任务全文总结、批量处理时间线、顺序累积长期知识库、反复问答
延迟Map 可并行,Reduce 多轮随片段数增长查询快,构建成本高
信息风险局部摘要丢跨段关系早期错误被继承摘要层层压缩损失细节
引用能力需保留每个 chunk 来源易跟踪处理顺序需要父子节点回溯
更新方式重新处理受影响片段从变化处继续或重算增量更新受影响分支

不要按“哪个算法更先进”选择,而要按任务约束选择。一个应用也可以组合它们:先用分层摘要做导航,再用向量检索找相关片段,最后用 Refine 或一次性回答处理少量证据。

六、切分是所有长文档方案的地基

切分太大,局部处理仍然超出预算;切分太小,标题、限定条件和上下文关系会丢失。建议优先按文档结构切分:标题、章节、段落、列表和表格,而不是只按固定字符数。

type DocumentChunk = {
  id: string
  text: string
  headingPath: string[]
  page?: number
  startOffset: number
  endOffset: number
  tokenCount: number
}

每个 chunk 带上祖先标题和页码,既帮助模型理解,也便于引用。相邻片段之间可以有少量重叠,但重叠太大,会让 Map 阶段重复计算、Reduce 阶段重复信息。

表格和代码要使用专门策略。表格需要保留表头、单位和行列关系;代码要保持文件、类、函数和行号。把它们一律按句号切分,会让后续问答比原文更不可靠。

七、上下文预算不是只有一个数字

长文档处理需要同时管理输入、输出和中间结果预算:

一次调用预算
= 输入 Token
 + 预留输出 Token
 + 系统提示 Token
 + 引用和元数据 Token

Map 阶段的输出越详细,Reduce 阶段输入越大;摘要越短,细节损失越多。应该根据任务设置压缩比例,并记录每一级摘要的 Token 和覆盖率。

type ContextBudget = {
  maxInputTokens: number
  reservedOutputTokens: number
  maxSourceTokens: number
  maxCitationTokens: number
}

function fitsBudget(
  inputTokens: number,
  budget: ContextBudget,
) {
  return inputTokens + budget.reservedOutputTokens <= budget.maxInputTokens
}

不要把模型宣传的最大上下文当成稳定工作区。最大窗口可能带来更高成本、更长延迟和注意力退化,应用应保留安全余量。

八、引用和证据要从第一步保留

如果局部摘要只返回一段文字,最后很难知道它来自哪一页。每个中间产物都要带来源引用:

type Evidence = {
  sourceId: string
  locator: { page?: number; heading?: string; startOffset?: number }
  quote: string
  relevance: number
}

type AnswerWithEvidence = {
  answer: string
  evidence: Evidence[]
  uncertainty?: string
}

汇总时不能只合并文本,还要合并证据。最终回答中的引用必须来自实际处理过的来源,不能让模型根据摘要内容随意生成页码或 URL。

冲突信息要显式保留。旧版本说“30 天”,新版本说“15 天”,系统应标记冲突并按时间和来源规则处理,必要时向用户说明,而不是让最后一次看到的文本静默覆盖前面事实。

九、Map-Reduce 的并行并不等于无限并发

并行 Map 可以降低墙钟时间,但会同时消耗模型 API 配额、GPU、网络和日志存储。需要使用有界并发和失败重试:

type ConcurrencyPolicy = {
  maxInFlight: number
  maxRetries: number
  timeoutMs: number
  backoffMs: number
}

某个 chunk 失败时,重试同一 chunk,不要重跑整个文档。多个片段使用相同 Prompt 和模型版本,结果才容易比较;模型或策略版本变化后,旧摘要要标记版本,不能和新摘要混合归并。

十、Refine 要防止答案漂移

Refine 每一步都可能改写已有答案。模型为了吸收新片段,可能删除原本正确的细节,或者把不相关内容逐步带入。可以让它显式输出保留、增加、删除和冲突项:

type RefineDelta = {
  kept: string[]
  added: string[]
  removed: string[]
  conflicts: string[]
  evidence: Evidence[]
}

每几步做一次独立校验,或者与上一版本做差异比较。高风险答案不要让一次串行循环直接覆盖旧结果,保留检查点,发现质量下降时能够回退。

十一、摘要质量怎么评测

摘要评测不能只看语言流畅。至少关注:

  • 事实保真:摘要中的主张是否能被原文支持。
  • 覆盖率:关键事实是否被保留。
  • 去重率:多层摘要是否重复表达同一内容。
  • 引用准确率:来源定位是否真实。
  • 冲突保留:不同版本是否被正确区分。
  • 长度与成本:压缩是否值得。
type SummaryReport = {
  factuality: number
  keyFactCoverage: number
  citationAccuracy: number
  conflictRecall: number
  compressionRatio: number
  costPerDocument: number
}

可以用人工标注的关键事实集做覆盖评测,用独立模型或规则做辅助判断,但高风险文档仍需要人工抽检。摘要系统一旦把关键限定词丢掉,最后答案可能会变成相反含义。

十二、长文档问答不等于长上下文问答

上下文窗口大时,可以减少切分和检索的工作,但不代表所有文档都应该一次性输入。选择架构时还要看文档是否反复查询、更新频率、引用要求、延迟预算和知识权限。

一次性长上下文适合低频、短期、需要全局阅读的任务;分层摘要适合反复查询的稳定资料;RAG 适合局部证据和持续更新;Map-Reduce 适合批处理全局总结;Refine 适合顺序累积和冲突修正。

低频一次性任务 → 长上下文 / 简单分块
高频固定文档 → 分层摘要 + 检索
持续更新知识 → 增量索引 + 局部重算
全文报告生成 → Map-Reduce
时间线 / 连续修订 → Refine

十三、我的总结:先建立地图,再回答问题

Map-Reduce、Refine 和分层摘要不是三种互相竞争的魔法,而是三种不同的长文档编排方式。Map-Reduce 擅长把独立工作并行化,Refine 擅长沿顺序累积和修正,分层摘要擅长建立从全局到局部的导航地图。

我现在处理长文档,会先判断问题需要全局覆盖、精确定位、跨段关系还是时间顺序;然后根据成本、延迟、更新和引用要求选择方案。无论使用哪种方法,都把原文来源、版本、页码和中间产物保留下来,给摘要设置预算和质量门禁。

最重要的一点是:摘要不是事实本身,上下文窗口也不是理解能力。压缩会丢信息,串行会产生漂移,并行会增加资源压力,长输入会让模型忽略关键位置。只有让中间结果可以追溯、最终答案可以验证,长文档问答才不会变成“模型读过很多字,所以应该知道答案”的幻觉。

真正成熟的系统,不是把更多文字塞进模型,而是知道哪些信息先压缩、哪些关系必须保留、哪些证据需要回到原文确认。把长文档变成一张可导航、可检索、可核验的地图,模型才能在有限预算里回答得更完整,也更诚实。

🤪 您也可以编辑此页: