《RAG 工程实战(三):全量更新 VS 增量更新,到底该怎么选?》

📅 发布时间:2026/10/21 5:55:16
《RAG 工程实战(三):全量更新 VS 增量更新,到底该怎么选?》
目录0. 一个很常见的知识库更新问题1. 全量更新简单但可能存在重复计算2. 增量更新难点不只是算一个 Hash2.1 如何判断一个 Chunk 有没有变化2.2 增量更新的核心实现3. 真正麻烦的是只改一个字可能让大量 Chunk 失效3.1 一个相对实用的优化优先按章节切分4. 增量更新一定更划算吗5. 实际项目中我更倾向于怎么选最后全量更新还是增量更新看似只是重新计算多少个向量的问题真正落地时却涉及 Chunk 差异检测、切分稳定性、索引一致性以及成本取舍。0. 一个很常见的知识库更新问题在企业 RAG 知识库中Wiki、Markdown 和在线文档通常会被持续编辑。比如一篇技术文档已经完成了 Chunking 和 Embedding所有向量也已经存入数据库。某天开发者只是修改了其中一个接口参数。这时系统应该怎么更新索引最直接的方法是重新读取整篇文档、重新切块、重新生成所有 Embedding最后替换旧向量。这种方案简单直接也确实是我接触过的知识库项目中采用过的实现思路。但仔细想想如果一篇 Wiki 有 100 个 Chunk只修改了一小段内容其余 99 个 Chunk 可能完全没变为什么还要重新计算于是就引出了两种不同的更新策略全量更新Full Rebuild和增量更新Incremental Update。但增量一定比全量更好吗研究下来我觉得事情没有那么简单。1. 全量更新简单但可能存在重复计算首先需要明确这里讨论的全量更新并不是每次重建整个知识库。我们讨论的是页面级全量更新只处理发生变化的 Wiki但重新生成该页面全部 Chunk 的向量。典型流程如下Wiki 内容发生修改 ↓ 读取最新页面内容 ↓ 重新 Chunking ↓ 所有 Chunk 重新 Embedding ↓ 替换该页面的旧向量索引在实际工程中这个更新任务通常放到后台异步执行避免用户保存文档时一直等待 Embedding 完成。这种方案最大的优势是实现简单、维护成本相对较低。因为每次都以当前文档为准重新构建索引不需要考虑新旧 Chunk 如何对应也不需要维护额外的向量复用关系。如果一篇 Wiki 只有几个 Chunk重新计算的成本可能完全可以接受。但它的缺点也很明显文档越长、编辑越频繁重复 Embedding 的开销就可能越大。假设一个页面有 100 个 Chunk只修改了其中一段。全量更新仍需要对 100 个 Chunk 重新生成向量。那么能不能只重新计算真正发生变化的内容2. 增量更新难点不只是算一个 Hash我们先考虑一个比较容易落地的增量方案重新 Chunking → 新旧 Chunk Hash 对比 → 复用未变化的 Embedding。为什么仍然需要重新 Chunking因为文档修改后Chunk 的数量、内容和边界都可能变化。不能简单认为用户修改了第三段就只需要更新第三个向量。假设旧版本有五个 ChunkA、B、C、D、E修改后变成A、B、C、D、E那么理想情况下A、B、D、E内容不变复用已有 Embedding。C内容变化重新计算 Embedding。如果删除了某个 Chunk新索引中也必须删除对应的旧知识。这样就能够避免大量不必要的向量计算。2.1 如何判断一个 Chunk 有没有变化一种常见思路是使用内容哈希。但有个细节需要注意不要简单使用 Chunk 的序号作为身份也不要只根据原始正文判断 Embedding 能否复用。因为 Chunk 的位置可能改变而实际送入 Embedding 模型的文本也可能包含标题、章节上下文等信息。更合理的做法是生成一个embedding_key// 简化伪代码 const embeddingKey sha256(JSON.stringify({ text: embeddingInput, model: modelId, preprocessVersion, }));其中embeddingInput是实际送入模型的文本。modelId标识 Embedding 模型。preprocessVersion标识文本预处理配置。如果新旧 Chunk 的embedding_key一致就可以尝试复用原向量。对于重复出现的相同文本也可以共享 Embedding 计算结果但必须保留各自的 Chunk 记录与来源信息。2.2 增量更新的核心实现整体逻辑可以简化为// 设计伪代码省略数据库与异常处理 const chunks splitWiki(latestContent); const oldVectors await loadVectorCache(pageId); const nextIndex []; for (const chunk of chunks) { const input buildEmbeddingInput(chunk); const key createEmbeddingKey(input, modelConfig); let vector oldVectors.get(key); if (!vector) { vector await embed(input); oldVectors.set(key, vector); } nextIndex.push({ content: chunk.text, embeddingKey: key, embedding: vector, }); } await commitNewIndex(pageId, nextIndex);需要说明的是这段代码展示的是增量计算的核心思想不是完整的生产级实现。真正落地时需要在提交阶段校验文档版本原子替换可见索引并确保旧版本中已删除的 Chunk 不再参与检索。这里我比较推荐一种折中实现只增量计算 Embedding但最终仍然以完整的新页面索引进行原子替换。这样优先解决最有可能产生额外成本的重复 Embedding同时避免第一版就引入复杂的数据库行级增删改逻辑。3. 真正麻烦的是只改一个字可能让大量 Chunk 失效这是我觉得增量更新最值得深入研究的地方。假设我们使用最简单的固定长度切分每四个字符生成一个 Chunk。旧版本ABCD | EFGH | IJKL现在用户在开头插入一个字符 XXABC | DEFG | HIJK | L看起来只增加了一个字符但所有 Chunk 的内容都改变了。这时即使使用 SHA-256也找不到能够直接复用的旧 Chunk。问题不在 Hash而在 Chunking 的边界不稳定。实际 RAG 的切分通常还包含 Chunk Size、Overlap 和分隔符规则一次局部修改可能影响相邻甚至后续多个 Chunk。那应该怎么办3.1 一个相对实用的优化优先按章节切分对于结构化 Wiki我更倾向于先按照标题划分章节再在每个章节内部进行 Chunking。假设文档包含第一章环境配置 ├── Chunk A └── Chunk B 第二章接口说明 ├── Chunk C └── Chunk D现在用户只修改第一章。如果两个章节独立切块那么即使第一章内部的 Chunk 边界发生变化第二章的 C、D 仍有机会保持不变。它们的 Hash 可以继续匹配旧向量。这种思路也有成熟工具可以参考比如 LangChain 的MarkdownHeaderTextSplitter可以先根据 Markdown 标题拆分再配合其他切分器控制 Chunk 大小。不过需要强调章节优先切分只能尽量限制变化范围不能保证只修改一个段落就只改变一个 Chunk。如果某一章非常长在章首插入内容该章节内部仍然可能发生边界偏移。另外切分粒度不能只为了提高向量复用率而无限缩小否则可能损失检索需要的上下文。所以最终还需要在检索质量与更新成本之间做权衡。4. 增量更新一定更划算吗到这里其实还存在一个非常现实的问题增量方案虽然减少了 Embedding但也增加了额外的计算与维护工作。它需要查询旧索引、计算 Hash、比较差异、处理删除并保证最终索引的一致性。我们可以用一个简化的成本模型理解全量成本 全部 Embedding 成本 索引重建成本 增量成本 Diff 成本 变化部分的 Embedding 成本 向量复用与索引维护成本两种策略都需要读取文档和重新 Chunking因此在这个简化比较中先不重复计算这部分共同成本。增量的净收益可以近似理解为节省的 Embedding 与写入成本 − 额外的差异检测和管理开销。而且这个收益不只取决于一次更新。假设有两种场景对比维度短篇 Wiki长篇 WikiChunk 总数8500修改后需重算的 Chunk25可复用比例75%99%初步倾向全量更新增量更新以上为假设场景不是实际测试结果。对于短篇 Wiki即使重新计算全部向量成本也可能很低。但对于拥有大量 Chunk、长期频繁进行局部修改的文档增量复用就可能更加值得。反过来如果某次修改导致大多数 Chunk 的边界发生变化那么增量方案的优势也可能大幅下降。因此我认为真正应该关注的不是文档修改了多少字而是重新切块后有多少 Embedding 结果能够安全复用复用这些结果又能省下多少实际成本这就是全量与增量之间的收益边界。这里还存在一个长期成本问题。即使某次增量更新能够节省大量向量计算如果文档一个月才修改一次为它引入复杂的维护机制也未必值得。所以选择更新策略时还需要考虑编辑频率、开发成本、系统维护复杂度和实际延迟要求。5. 实际项目中我更倾向于怎么选如果让我设计一套在线知识库我不会一开始就追求所有文档都采用 Chunk 级增量更新。更合理的思路是场景更倾向的策略短文档、低频编辑页面级全量更新长文档、频繁局部修改Chunk 级增量更新文档内容大范围重写页面级全量更新Embedding 模型或向量化配置发生变化重建受影响的向量内容没有变化直接跳过更新可复用率较高、模型调用成本较大优先考虑增量当然这些都是设计上的倾向而不是放之四海皆准的规则。如果业务已经通过 BullMQ Redis 异步处理知识库更新那么全量与增量也不需要使用两套完全不同的队列系统。Worker 读取最新页面内容后可以根据文档规模、模型配置和可复用率决定执行哪种策略。至于索引一致性两种方案都应保证新索引准备好之后再生效更新失败时不破坏旧索引过期任务不能覆盖较新的文档版本。这些是更新系统的共同要求而不是增量方案独有的问题。最后研究全量与增量更新之后我觉得最重要的收获并不是掌握了一个 Chunk Hash 算法。而是认识到RAG 知识库更新策略本质上是在计算成本、检索质量与工程复杂度之间寻找平衡。全量更新并不落后它用更简单的实现换取了可维护性。增量更新也不是万能优化它的实际收益高度依赖 Chunking 的稳定性、向量可复用率和文档编辑特征。对于不断变化的在线知识库我更倾向于先建立可靠的页面级全量更新机制再结合真实的编辑规模与计算成本决定是否引入 Chunk Hash 和 Embedding 复用。毕竟工程中真正好的方案不一定是计算量最少的而是最适合当前场景、也最值得维护的。延伸阅读LangChain RecordManager内容哈希、来源关联与索引记录管理。LangChain MarkdownHeaderTextSplitter按 Markdown 标题组织文本切分。BullMQ 官方文档知识库异步更新任务的实现参考。