用 NVIDIA nemotron 3 embed 1b + Qdrant Cloud 做书籍 RAG,问答和检索准确,但全书的整体摘要频繁失败,暴露了 Embedding 在长文本上的局限性。
我从一个简单的方案开始:对书籍进行分块,使用 NVIDIA 的 nemotron 3 embed 1b 模型生成向量嵌入,全部存储在 Qdrant Cloud 中。对于大多数推理任务,这种方式效果很好。询问作者的观点、查找某个想法出现的位置、对比两个章节——余弦相似度都准确地完成了它该做的事。检索速度快,答案准确。我觉得自己已经搞定了。
然后我让它总结整本书,事情开始崩解。
不是偶尔,而是经常。摘要中缺少整个章节,叙事流畅性断裂,重要细节直接消失。为了确保不是我对自己的评估过于随意,我用一本我非常熟悉的书做了测试——《代码整洁之道》作者 Robert C. Martin。我对这本书足够熟悉,能准确发现摘要在哪里骗了我。而它确实一直在骗我。
于是我面对一个令人不安的问题:向量存储对摘要任务真的有帮助吗?
诚实的答案是没有。一旦我开始审视生产级 RAG 工具如何处理摘要,到处都能看到同样的模式。检索得分最高的二三十个随机 chunk,交给 LLM,让它总结。但这不是摘要。这是在用向量空间中最接近的 chunk 回答一个模糊的查询。真正的摘要需要的是全部内容,而不是三十个看起来最相关的片段。
于是我彻底改变了方案。如果摘要不是检索问题,那我就不该继续把它当检索问题来处理。新方案是从 PDF 直接提取原始文本,直接传给 LLM,获取摘要后,将摘要存入普通的、非向量类型的数据库。从此以后,每当 agent 需要总结这本书时,根本不会调用余弦相似度或检索,而是直接拉取预生成的摘要并在上面进行推理。附带的好处是,这也让个性化变得简单:不用给每个用户返回相同的固定摘要,LLM 可以在服务时根据用户偏好重新调整存储摘要的风格。
听起来很干净。实际上在完全相同的地方再次崩解了。
我的第一个假设是撞上了输入 token 限制。这看起来很合理,毕竟一本 500 页的书确实包含大量文本。但现代模型的上下文窗口已经非常大了,所以这个理论没撑多久。我在模型限制范围内输入,它仍然以完全相同的方式丢失细节。
真正的罪魁祸首有一个确切的名字,一个我之前一直没有认真对待直到自己亲历才明白的名字:Lost in the Middle(中间丢失)。长上下文模型在相关信息位于长上下文中间位置时,仍然会表现出明显的性能下降——即使整个输入在技术层面完全处于上下文窗口之内。这不是因为每个模型行为完全相同,也不是说开头和结尾总是处理得很好。而是中间位置最容易出问题,而一本完整的书大部分都是中间部分。
一旦看到这个模式被命名和描述,一切都串联起来了。我的摘要对开头章节是准确的,对结尾章节是准确的,但对中间两百页的内容要么模糊、要么完全错误。这正是你预期会看到的模式——如果模型对其上下文边缘部分注意力强、对中心部分注意力弱的话。
我的第一反应,大概和大多数人的第一反应一样,是用更好的 prompt 来对抗它。我尝试告诉模型要更关注中间部分,不要忽视第五章到第十五章的细节。但都没用。你无法通过 prompt 工程摆脱结构性的注意力偏差。如果解决 Lost in the Middle 的办法是更强的 system prompt,那你已经陷入了和我一样的循环。
真正的修复根本不是更聪明的 prompt,而是一个相当令人谦卑的想法:永远不应该要求 LLM 在单次调用中总结一整本 300 页的书,不管它的上下文窗口声称有多大。相反,你应该递归地分解问题。先总结小块,再总结总结块的总和。这就是 MapReduceDocumentChain 背后的思想,以及它的近亲 TreeSummarize。既然我已经熟悉 LangChain,就朝这个方向走了。
两者并不完全相同,尽管人们倾向于交替使用这两个名字。MapReduce,最简单地说,就是独立总结每个 chunk,然后将所有这些摘要合并到一次最终 pass 中。TreeSummarize 更进一步。当合并后的摘要仍然太长、无法放入单个上下文窗口时,它会递归地对摘要进行分组和再次总结,反复进行,直到剩余内容最终能够放入一次 pass 中。实际上,对于一本 300 到 1000 页的书,你几乎总是需要树版本,因为对数十个 chunk 摘要进行单次扁平 reduce 操作本身都可能变得足够长,从而重新引入你试图摆脱的同一类中间上下文问题。
以下是整个流程的大致样子。

在每个步骤的实际摘要工作中,我使用了 NVIDIA 的 nemotron 3.5 lightning 30b a3b 模型。模型选择在这里确实重要,因为不同模型受 Lost in the Middle 影响程度并不相同——有些模型处理长上下文比其他的更优雅。但 map reduce 重构从根本上解决了这个问题,无论底层使用哪个模型,因为它在结构上规避了这个问题,而不是依赖于模型的原始长上下文能力。
所以实际的流程是这样的,简单地说。
首先,书被分割成小的、可管理的 chunk,小到每个 chunk 都能舒适地放在模型的「良好注意力」区域内。这里不存在上下文中间的退化问题,因为每个 chunk 基本上就是那次特定调用的全部上下文。
第二步是 map 步骤。每个 chunk 独立地、并行地发送给 LLM,配以简单的指令,比如"总结这个部分"。因为每次调用只需要在一个小 chunk 上进行推理,所以不存在任何会丢失内容的「中间」。模型从头到尾清晰地看到整个 chunk。
第三步是 reduce 步骤。所有这些独立的 chunk 摘要被收集并合并。如果合并后的摘要仍然太长、无法放入单个上下文窗口,流程会递归进行——摘要被分组并再次总结,这个过程重复直到最终所有内容能够放入一次 pass 中。这基本上就是 TreeSummarize 形式化的内容——你实际上是在构建一棵树,叶子是 chunk 级摘要,之上的每一层进一步压缩,直到最终落在顶部的唯一一个根摘要。
最后是综合步骤。最后一次 reduce pass 接收现在小得多的中间摘要集合,产生整本书的最终、连贯的摘要。
我喜欢这个方案的地方在于,"中间丢失"问题悄悄地消失了。没有单次 LLM 调用需要同时在上下文中持有整本书的中间部分。每次调用只处理一小段、得到充分注意的文本。代价是更多的 LLM 调用、更高的成本和更多的延迟,与单次总结相比,但你换来了正确性——对于一本 500 到 1000 页的书,正确性就是整个练习的全部意义。
我没有在这里运行正式的基准测试——这是个人项目,不是论文。但我做了从一开始就一直在做的事:逐章对照 Clean Code 检查输出,因为我足够了解那本书,能发现缺失的内容。
单次总结——无论直接来自 LLM 还是来自 top k 检索到的 chunk——一致地跳过同类内容。涵盖格式、边界和错误处理的章节,也就是稳稳坐在书中间的那些章节,要么单薄、要么完全缺失,而关于命名和函数的开头章节以及关于类和系统的结尾章节每次都清晰通过。MapReduce 版本是第一个真正浮出每个章节内容的版本,包括那些埋在中间的章节。它并非完美——当你压缩两次时一些细微差别仍然会被扁平化,但它不再跳过整个章节了,而早期的方案每次都会跳过。
这一点比其他任何东西都更让我相信:这从一开始就不是一个 prompt 问题。
回过头看,有几点现在很突出,但在开始时并没有。检索和摘要是真正不同的问题。向量相似度搜索是为「找到相关内容」构建的,不是为「压缩全部内容」构建的。仅仅因为向量数据库已经在你的技术栈里就调用它,并不总是正确的选择。更大的上下文窗口也无法从 Lost in the Middle 中拯救你,因为它是一种注意力行为,不是容量限制。你无法通过 prompt 绕过模型读取长文本时的结构性限制。如果你不断伸手去找的修复办法是更强的指令,那你可能在一个完全错误的层面上解决问题。
最大的教训不是 RAG 不好,不是向量数据库没必要,也不是大上下文窗口没用。上下文大小和有用的推理容量不是一回事。一个模型能接受 500 页不意味着它能同样好地推理全部 500 页。有时候给模型更多信息最好的方式是每次给它更少的信息,让它分阶段构建完整图景,而不是一次全部给。