设计一个超长文档的总结和问答服务
一句话回答
整体是一条异步流水线:文件直传对象存储,提交任务后进入队列,Worker 分阶段处理:解析(文本型 PDF 直接提取,扫描件走 OCR,表格保留结构)→ 按章节结构切块 → 两条支路并行:一条建立检索索引支持问答,一条做 Map-Reduce 分层总结(分块摘要 → 章节摘要 → 全文摘要)。每个阶段、每个分块的结果都持久化,失败时只重试失败的部分,可以断点续跑;按文件哈希缓存结果;进度实时推送,先给目录和已完成的章节摘要,索引建好就能开始问答,不必等全文总结完成。
详细解析
第一步:澄清需求和估算
- 文档:类型(合同、财报、书籍、论文)、格式(PDF、Word、扫描件)、最大页数
- 功能:全文摘要、章节摘要、基于文档的问答(答案带页码引用),是否需要跨文档分析
- 时效:用户能接受等几分钟,但希望尽快看到部分结果
- 隐私:企业内部文档能不能发给云端模型
估算(全部是假设值,实际以测量为准):300 页的文档,假设每页约 500 token(取决于排版、语言和分词器),全文约 15 万 token,切成约 80 块:
- Map:80 次调用,输入共约 15 万 token 再加上每次调用的指令开销;假设每块输出 300 token,共约 2.4 万 token
- Reduce:生成章节摘要和全文摘要,再调用十几次,输入是上一层的摘要
- 时间:假设并发 10 个、每次调用 15 秒,Map 要 8 轮,两分钟左右;扫描件的 OCR 可能比模型调用还慢
- 费用:各阶段的 token 数乘以所用模型的单价
第二步:整体架构
前端 ──① 申请上传凭证──► API 服务(返回预签名 URL)
├──② 直传文件────────────────────────────► 对象存储
├──③ 提交任务(文件 ID)──► API 服务 ──► 任务表 + 消息队列
│
│ Worker(按阶段拆分,各自扩缩容,每个阶段可以单独重试)
│ 解析:文本提取 / OCR / 表格识别 → 章节树(标题层级、页码)
│ 切块:按章节切,过长再按 token 切,每块带章节路径和页码
│ ├─► Embedding → 向量库 + 全文索引(问答用)
│ └─► Map:分块摘要(限并发)→ Reduce:章节摘要 → 全文摘要
│ │
└◄──④ 进度推送(SSE 或轮询)◄── 任务状态、各阶段的结果
第三步:核心模块和数据模型
上传:前端拿预签名 URL 直传对象存储,不经过业务服务器;大文件分片上传、断点续传,见大文件上传。上传完成后校验真实的文件类型(看文件头而不是扩展名)、大小和页数上限,计算文件哈希,用于去重和缓存。
解析:
- 文本型 PDF 直接提取文字和版面信息;某一页几乎提取不到文字,就判定为扫描页,走 OCR
- 表格要识别出行列结构,转成 Markdown 表格,不能压成一行文字
- 去掉页眉页脚,保留标题层级和页码,后面的切块和引用都依赖它们
- 版式复杂的页面可以交给多模态模型解析,效果好但成本高,只用在少数页面上
- 可以选开源工具(例如 MinerU、Docling)或云厂商的文档解析服务
数据模型:
documents:文件哈希、存储路径、页数、状态、解析结果的路径jobs:文档 ID、当前阶段(parsing / indexing / summarizing / done / failed)、进度、错误信息、心跳时间chunks:文档 ID、序号、章节路径、页码范围、文本chunk_summaries:文档 ID、分块序号、版本(模型 + Prompt)、摘要;三者组成唯一键,是断点续跑的依据
分层总结:Map 阶段每块单独总结,Prompt 里带上全文目录和本块的章节路径,让模型知道这一块在整体中的位置;Reduce 阶段先把同一章的分块摘要合并成章节摘要,再合并成全文摘要,输入太长就分组递归合并。每条摘要保留页码范围,结论能追溯到原文。Refine、层级摘要等方法的对比见文档太长的处理方法。
问答:同一批分块建向量索引和全文索引,用混合检索回答细节问题,答案标注页码,点击可以跳到原文位置。"这份报告的主要结论是什么"这类全局问题检索不到具体片段,直接用章节摘要和全文摘要回答,也可以把各层摘要一起放进索引。
第四步:关键难点
失败重试和断点续跑:
- 任务按阶段、按分块拆成幂等的子任务,结果按(文档、分块、版本)持久化,重跑时跳过已完成的部分
- 消息处理完成后才确认,Worker 中途崩溃,消息会重新投递给其他 Worker
- 429、5xx、超时按指数退避重试,超过次数进入死信队列;个别分块始终失败(比如被内容审核拦截)就标记后跳过,在结果里注明哪些页没有覆盖
- 用户取消任务时,停止派发新的子任务
并发和限流:Map 阶段很容易瞬间打满上游的 TPM。按单个文档、租户、全局三级限制并发,避免一个用户的几千页文档让其他人的小文档排队很久。
缓存:用"文件哈希 + 流水线版本 + 模型 + Prompt 版本"作为缓存的 key,同一份文件不重复解析和总结;模型或 Prompt 升级后版本号变化,旧缓存自然失效。不同用户上传同一份文件时,只有公开文件才能共享结果,私有文档按租户隔离。
体验:
- 解析完成就展示目录,用户可以先浏览原文
- 章节摘要按完成的顺序陆续显示,不等全文摘要
- 索引建好就开放问答,这时总结可能还在进行
- 按阶段展示进度(如"总结中 45/80")和预计剩余时间,完成后发通知
第五步:扩展与优化
- 成本:Map 阶段用便宜的小模型,Reduce 和问答用能力更强的模型;对时效要求不高的任务,可以用供应商的批处理接口
- 多文档:比如对比几年的财报,在单文档的结果上再做一层跨文档的 Reduce
- 质量评估:根据原文出一批问题,看只读摘要能不能答对,以此衡量摘要的覆盖度
代码示例
Map 阶段:限制并发,按分块持久化结果,重跑时跳过已完成的块:
import pLimit from 'p-limit'
const limit = pLimit(10) // 单个文档的并发上限,全局还有一层限流
async function mapChunks(doc: Doc, chunks: Chunk[], version: string) {
return Promise.all(
chunks.map((chunk) =>
limit(async () => {
const done = await db.chunkSummaries.find(doc.id, chunk.index, version)
if (done) return done // 断点续跑:已完成的块直接复用
const summary = await withRetry(() =>
llm.chat({ messages: mapPrompt(doc.outline, chunk) }), // 带上目录和章节路径
)
const saved = await db.chunkSummaries.save(doc.id, chunk.index, version, {
summary,
pages: chunk.pages, // 保留页码,结论可以追溯到原文
})
await reportProgress(doc.id, 'summarizing')
return saved
}),
),
)
}
面试官可能追问
模型的上下文窗口越来越长,为什么不直接把整份文档放进去?
文档能放进窗口时,直接用长上下文确实更简单,对全局的理解也更好。但有几个限制:超过窗口的文档还是要拆;每次问答都重新发送整份文档,成本和延迟都高(对同一文档多次提问时,提示缓存能缓解);长上下文中间部分的信息容易被忽略;分块方案还能给出精确的页码引用。实践中常把两者结合:中等长度的文档直接放进上下文,超长文档走分块的流水线。
Map-Reduce 总结会丢掉什么?怎么改进?
会丢掉跨块的联系(前文的定义、后文的结论)和全局结构,还可能重复或前后矛盾。改进方法:按章节而不是按固定长度切块;Map 时带上目录和章节路径;Reduce 按章节分组,保持文档原有的结构;要求保留关键数字和结论并标明页码;最后用"根据原文出题、只看摘要作答"的方式评估覆盖度。
任务处理到一半,Worker 进程崩溃了,怎么办?
消息在处理完成后才确认,崩溃后消息会在超时后重新投递给其他 Worker。因为每个分块的结果都已经持久化,新的 Worker 只会处理还没完成的块。任务表里记录每个阶段的心跳时间,长时间没有更新的任务由定时任务重新派发。
扫描件识别质量很差,怎么办?
先判断质量:OCR 一般会给出置信度,低于阈值的页面标记出来。对这些页面换更强的识别方式(比如用多模态模型直接读图),仍然不行就在结果里明确告诉用户"第几页识别质量差,相关内容可能不准确",而不是让模型基于错乱的文字编出一段摘要。
易错点
- 在上传接口里同步做解析和总结,大文件必然超时
- 按固定字数切块,把表格和段落从中间切断,还丢了页码,引用无法回溯
- 只有任务级别的重试,一个分块失败就让整份文档从头再来
- 把所有分块一次性并发发给模型,触发上游限流,所有任务一起失败
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。