知识库改了,检索还在返回旧答案:RAG 索引的生命周期
退货政策从 7 天改成了 15 天,文档改了,也重新导入了。可问答助手有时回答 15 天,有时回答 7 天。另一份早就删掉的旧 FAQ,它也还能引用。
检索增强生成(RAG)的演示一般只有导入和查询两步:把文档切块、嵌入、写进向量库,然后检索。上线之后,文档会被修改、删除、改权限,嵌入模型也会升级。导入脚本如果只会「追加」,索引就会慢慢变成一份和源文档对不上的副本。索引不是一次性产物,它是源文档的一个派生视图,需要像缓存一样回答:键是什么,源头变了怎么失效,谁能看到哪一部分。
本文用一个确定性的假嵌入(字符二元组哈希到 64 维)和内存索引,验证修改、删除、换模型、权限和答案缓存五种情况。不调用任何模型。实验见文末。
一、修改:按内容哈希做键,旧块不会被替换
一个常见的去重做法是用块内容的哈希做主键:同样的内容只存一份,重复导入不会重复写入。
policy.md 从 7 天改成 15 天后重新导入,只按内容哈希做键:索引里 3 块;查「退货期限是多少天」得到 policy.md:退货期限为签收后 15 天 / policy.md:退货期限为签收后 7 天修改后的段落哈希变了,于是作为一个新块写入;旧段落的哈希没有任何东西去删除它。两个版本都在索引里,相似度又几乎一样,谁排在前面取决于细微的差别。开头那个时而 15 天、时而 7 天的回答就是这么来的。
内容哈希回答的是「这段内容存过没有」,而更新需要的是「这个来源现在产生哪些块」。所以要记录来源到块的映射:
def upsert_source(self, source, text, group="all"):
new_ids = set()
for i, c in enumerate(chunks(text)):
cid = f"{source}#{i}:{sha256(c)[:8]}"
new_ids.add(cid)
self.rows[cid] = {...}
for old in self.by_source.get(source, set()) - new_ids:
del self.rows[old] # 这个来源不再产生的块
self.by_source[source] = new_ids按来源记录块,重建时删掉不再产生的块:索引里 2 块;查「退货期限是多少天」得到 policy.md:退货期限为签收后 15 天 / policy.md:运费由买家承担二、删除:增量导入看不到「没有了」
old-faq.md 从仓库删除之后,朴素索引只做增量导入:查「退货期限是多少天」仍得到 old-faq.md:旧版:退货期限为签收后 3 天 / ……
全量同步:删除前 old-faq.md:旧版:退货期限为签收后 3 天 / ……;删除后 policy.md:退货期限为签收后 15 天 / policy.md:运费由买家承担增量导入只处理「这次看到的文件」,一个被删除的文件根本不会出现在这次的输入里。发现删除只有两种办法:拿索引里的来源清单与源头的当前清单对比(全量同步),或者从源头接收删除事件。前者简单可靠,后者及时但要处理事件丢失,通常两者结合:事件驱动的增量更新,加上定期的全量对账。
这和数据导入里的问题是同一类:导入的目标是让副本与源头一致,而不仅仅是把新数据写进去。
三、换嵌入模型:向量不能混用
同一模型下问题与答案的相似度 0.33;问题换成新模型嵌入、文档还是旧模型的向量 0.11
每条向量记录嵌入模型,查询时检查:索引里有 ['emb-v1'] 生成的向量,查询用的是 emb-v2:拒绝混用
维度不同的模型直接混用:维度不一致:64 与 48不同嵌入模型的向量空间没有对应关系,维度相同时也一样:计算能进行,结果没有意义。维度不同时会直接报错,这反而是好事;维度相同时,检索质量会悄悄变差,很难发现。相似度的数值不能跨模型比较讲的也是这件事。
做法是每条向量记录生成它的模型和版本,查询时检查。升级模型时建一个新索引,全量重新嵌入,验证检索质量之后再切换读流量,而不是在原索引上边写边换。
四、权限:先过滤,再排序
员工(只属于 all)查「退货专员的待遇」:policy.md:退货期限为签收后 15 天 / policy.md:运费由买家承担;人事(all、hr)查:salary.md:退货专员的薪资档位为 B3权限必须在检索阶段生效:只在用户可见的块里排序取前 k 个。如果先取前 k 个再过滤,一方面结果可能不足 k 个(先检索后过滤返回不足),另一方面,不可见的内容可能已经进入了后续环节,例如被送进模型做摘要,或者被写进了日志。权限信息要随块一起写入索引,并在权限变化时更新,这又回到了第一节的问题:块要能按来源找到。
五、答案缓存:键里要有权限范围
答案缓存只用问题文本做键,人事先问过一次,员工再问:salary.md:退货专员的薪资档位为 B3
缓存键加上权限范围:员工再问:policy.md:退货期限为签收后 15 天为了省钱和降低延迟,常会在问答前加一层缓存。如果键只有问题文本,人事看到的答案就会原样返回给员工,检索阶段的权限过滤完全被绕过。缓存键必须包含决定答案的全部输入:问题、权限范围、索引版本(文档更新后旧答案也该失效)。这和 HTTP 缓存的 Vary 是同一个道理。
六、检查清单
| 变化 | 索引要能回答 | 做法 |
|---|---|---|
| 文档修改 | 这个来源现在产生哪些块 | 记录来源 → 块;重建来源时删掉不再产生的块 |
| 文档删除 | 源头还有哪些来源 | 全量对账,或删除事件加定期对账 |
| 换嵌入模型 | 这条向量由哪个模型生成 | 每条记录模型版本,查询时检查;新建索引后切换 |
| 权限 | 谁能看到这个块 | 权限随块写入,检索时先过滤再排序 |
| 答案缓存 | 答案依赖哪些输入 | 键包含问题、权限范围与索引版本 |
配套实验
- codesphere-labs/ai/rag-index-lifecycle:Python 3.14.8,确定性的假嵌入与内存索引:修改后的旧块、删除后的残留、混用嵌入模型、权限过滤、答案缓存的键(验证记录)
参考资料