大多数检索增强生成(RAG)教程都停在同一个地方:加载文档、切块、向量化、存进向量数据库,然后开始提问。做演示够用,撑不起一个真正被业务依赖的系统——因为真实文档每天都在变。

打开网易新闻 查看精彩图片

政策会修订,产品页会更新,合同会被替换,旧版本不该再被引用。索引不跟着变,助手就会理直气壮地拿昨天的事实回答今天的问题。按作者的说法,过期或重复的切片造成的错误答案,比语言模型本身还多。

团队常见的应急做法是每晚全量重建索引。语料小的时候没问题,一旦语料变大、向量化账单上涨,夜间任务中途失败,生产环境里就留下一个建了一半的索引。作者给出的替代方案是一条增量管道:只重新向量化发生变化的内容,删除已消失的内容,并记录每个切片的来源。

核心是一张切片登记表

整条设计的关键是一张很小的表:为每个切片存一个稳定ID和一份内容哈希。每次运行重新切块、比对哈希,只处理不一样的部分。

第一步是按结构切块,并给切片稳定ID。按固定字符数切块有两个毛病:会把句子和从句拦腰截断;只要有人改了文档开头,切片边界就整体位移,编辑点之后的每个切片都显得是“新的”,整篇文档被迫重新向量化。

按标题切块能同时解决这两个问题。一个章节本身就是自然的语义单元,某个章节被改动,不影响其他章节。

代码里用了两个哈希,各管一件事:chunk_id 标识切片住在哪里(文档、章节、章节内位置),content_hash 判断它的文本有没有变。

给切片补上文档上下文

单独一句“退款在14天内处理”是有歧义的:哪个产品?哪个地区?哪个版本?如果每个切片都带上一段短头部,写明文档标题和章节路径,检索效果会明显改善。Anthropic 把这类思路的进阶版本称为上下文检索(contextual retrieval),而一个朴素的头部就是便宜好用的起点。

要点在于:向量化的是加上下文后的文本,哈希的却是原始文本。这样重命名文档不会强制全量重嵌入,除非你本来就想这么做。

第三步是与登记表比对。如果用 pgvector,登记表可以和向量放在同一个 Postgres 库里,更新保持原子性。一张表就够,字段包括 chunk_id、doc_id、content_hash、source_url、body、embedding 和 updated_at,并在 doc_id 上建索引。

对每篇文档,读出已有哈希,把新切片分成三类:

  • 需要写入或更新的:哈希对不上,或登记表里没有
  • 需要删除的:登记表里有、新切片里没有
  • 未变化的:哈希一致,直接跳过

只向量化变化的部分,一个事务内完成

同步函数先切块,再查该文档已有的 chunk_id 与 content_hash,做差异比对,然后只对需要写入的切片调用向量模型。示例用的是 all-MiniLM-L6-v2,输出384维。

写入用 INSERT ... ON CONFLICT DO UPDATE,删除用一条 DELETE 批量处理。关键在于写入和删除跑在同一个事务里:中途失败,文档保留的是上一个完整版本,而不是新旧切片混在一起。

第五步处理被删除的文档。上面的差异比对只管到章节级别,整篇文档消失还需要额外一道检查:把源系统里的文档ID列表和表里的ID比对,删掉已经不存在的。跳过这一步,是旧政策反复出现在答案里的最常见原因。

实际运行中的变化

在典型的业务语料上,任何一天真正变化的切片只占很小一部分,所以多数运行只向量化很少的内容。每晚全量重建不管变没变都要重嵌一遍,成本随语料规模增长;增量运行的成本随变化量增长,而变化量通常只是很小的一截。

另外两点收益和成本同样重要:

  • 可追溯:每个答案都能引用 source_url 和 updated_at,用户能核对来源、看到它有多新
  • 可调试:答案出错时,能看清检索到的是哪个切片版本、最后一次更新在什么时候

几个需要留意的坑

  • 更换向量模型会让所有向量失效。把模型名存进表里,切换时全量重建到新表,再整体替换
  • 改动切块逻辑会改变 chunk_id,按更换模型同样的方式处理
  • 超长章节仍需要尺寸上限,也就是前面按段落拆分的那段逻辑,否则检索质量会下降
  • 单独测试检索:维护一小批真实问题和已知的源章节,每次改动管道后确认正确的切片仍出现在靠前的结果里

RAG 系统的新鲜度上限就是索引的新鲜度。按结构切块,给每个切片稳定ID和内容哈希,只重嵌变化的部分,删掉消失的部分。代码只有几十行,却能消掉一整类“自信地答错”。