索引新鲜度与失效

12 分钟读完

R10
深入解析 · 检索与 RAG

索引新鲜度:真正伤人的,是那条你从未传播出去的删除。

陈旧的索引不会报错,它会给出一个带引用的、自信的答案——而一份语料变陈旧的三种方式,并不是一个问题、一种解法。一段过时的文字是尴尬;一份已被删除却仍在作答的文档是事故,因为有人是有意把它下线的,而你的系统推翻了这个决定。本条目要论证的规则是:让失效由来源的变更流驱动,给删除一条不必等下一次爬取的特权路径,并且记住——你的流水线从一个分块派生出的一切(摘要、记忆条目、图节点、缓存答案)一样都不会继承它。

STEP 1

三种变更,三种不同的失败。

团队把「索引新鲜度」当成一个延迟数字来谈,这掩盖了一件事:让索引出错的这三种事件,爆炸半径完全不同。

  • 更新。文档还在,内容变了。被检索到的分块是关于某个旧版本的真陈述,而引用指向的是一份现已不这么写的活文档。代价:一个用户点进去就能抓到的错误答案。烦人、能自我纠正,而且是三者中多数团队唯一会去测量的那个。
  • 删除。文档没了——被撤回、被取代,或者因为本就不该存在而被移除。你的索引里仍留着那个分块,检索器仍会把它排上来,于是智能体会去引用一份组织刻意收回的文档。此时引用成了加重情节而不是缓解情节:它把权威赋予了一段无人背书的文字,还指向一个会 404 的 URL——而用户正是这样发现你的系统在说谎的。代价:一次正确性事故,有时还是一次法律事故。
  • 权限变更。文档还在,但这个读者不再有权看到它。这并不是换了顶帽子的新鲜度问题——它是一个授权问题,只不过你恰好把它的执行缓存了起来,而这份缓存的陈旧度就是你的撤权延迟。完整处理在按权限检索;从这里唯一要带走的是:它不能和内容共用同一张刷新时刻表。

按「失败如何浮出水面」来排序,优先级就和常识反过来了。更新是吵闹的,所以会被修掉。删除是安静的、貌似合理的,而且在检索时刻与一次正确命中无从区分——向量里没有任何一位在说「这份文档已经不存在了」。一个返回了东西的检索器,行为完全正常;根本没有可供告警的错误路径。

由此掉出来的设计规则:更新可以搭时刻表,删除必须走事件。如果你唯一的失效机制是「我们每晚重爬一次」,那你已经选择了最长把撤回内容继续端上一整天——而且这个选择是隐式的、写在一条 cron 表达式里、没有任何人签过字。

STEP 2

每夜全量重爬是一张账单,不是一份保证。

默认的新鲜度架构是定时全量爬取,而它的成本曲线是所有选项里最差的:价格随语料规模走,它追的那个东西却随变更速率走。这两个数字通常差三个数量级。

# A corpus of 1M documents, 0.5% changing per day

  documents re-read per night      = 1,000,000
  documents that actually changed  =     5,000     # 0.5%
  wasted work                      =       200x

# And the freshness you bought with it:

  mean staleness   = interval / 2 =  12 hours
  worst staleness  = interval     =  24 hours
  deletion window  = interval     =  24 hours      # retracted content still answering

# Halving staleness doubles the bill. The bill is the corpus, not the delta.

这笔算术正是「那就爬勤一点」在语料大到真正重要的那一刻恰好不再是个选项的原因。按内容哈希增量摄取——哈希变了才重新嵌入——修好了账单里嵌入那一半,却修不好爬取那一半:你仍然得取回一百万份文档,才能发现其中 995,000 份没变。在本地文件系统上,这个「发现」几乎免费;在一个有速率限制的 API 上,它就是成本的大头,也是团队默默把间隔拉长的原因——先拉到一天,再拉到一周。

两个诚实的例外。一份大约十万份文档以内、且存放在你自己掌控的存储上的语料,可以频繁到足以让上面这些都无所谓地做全量对账——那就做那件简单的事。而一份确实整体更替的语料,比如每夜一次的分析导出,那是重建,不是失效问题;怎样切换才不会留下「索引一半是新一半是旧」的窗口,见重建索引与嵌入迁移。

STEP 3

订阅来源,并预期它在删除这件事上撒谎。

结构上正确的答案是别再轮询,让来源来告诉你:变更流、webhook、变更令牌、数据库的预写日志。每一个像样的内容系统都有——文档平台提供带游标的变更 API,对象存储会发出创建与删除通知,Git 每次提交给你一份 diff,关系型来源给你逻辑复制。这样成本就随增量走,而这正是你要的。

但有一个坑,毁掉了多到出人意料的一批初版实现:变更流擅长创建与更新,拙于删除。各家产品上的失败形态相当一致,值得一一点名,因为每一种都会在你的索引里留下一个幽灵:

  • 无声消失。这一项只是不再出现在列表里。什么都没发出来,因为从来源的角度看,什么都没发生——只是一行没了。
  • 删除表现为访问错误。下一次抓取返回 403 或 404,而你的摄取 worker 把它归类为瞬时故障,重试三天才放弃。它永远走不到那条会移除分块的代码路径上。
  • 移动看起来像删除,删除看起来像移动。重命名会发出一条删除加一条带新标识符的创建;移到归档空间则什么都不发。如果你的主键是路径,两者都会把索引搞坏。
  • 游标断档。一个过期的变更令牌、一个宕了一小时的 webhook 端点、一个落后太多的复制槽。断档期内的事件没了,也没有任何东西告诉你丢的是哪些。

所以事件流是必要的,但不充分。给它配一次只负责找墓碑的对账扫描:从来源列出标识符,与索引中的标识符做差集,把缺的移除。这比爬取便宜得多——你取的是 ID 不是内容,所以一百万份文档的对账是几次分页列举,而不是一百万个正文——因此它可以做到每小时一次,而爬取每夜一次。事件流与扫描都要以来源分配的稳定标识符为键,绝不能用路径或 URL,否则第一次有人重整目录时,这次扫描就会删掉半份语料。

让摄取 worker 分清「没了」和「失败了」,并且让这个区分是显式的,而不是从状态码里猜出来的。一个健康来源返回的 404 意味着删除;一个对所有东西都返回 404 的来源给出的 404 意味着停下来叫人。把两者混为一谈的流水线,要么永远攒幽灵,要么在一次故障中把索引清空——而这两种失败,都是由用户、在很晚的时候发现的。

STEP 4

先立墓碑,再做压实——并记住那个分块留下了什么。

删除事件到达时,本能是把向量移掉。那是慢路径:在多数存储里,一次删除是一次段重写或一次最终才落地的压实,而「最终」恰恰就是你想关上的那个窗口。改成写一块墓碑——在分块的元数据上加一个 deleted_at 字段——并在查询时按它过滤。这一行会在毫秒级从结果里消失,物理移除则按存储自己的节奏发生,两件事从此解耦。

这也是唯一能让删除可审计的机制。一行凭空消失了,什么都说明不了;一块带时间戳与原因的墓碑,能让你在几个月后回答「我们是从什么时候起不再提供这份内容的」——而这个问题通常来自法务,不是来自工程。

接下来是真正被漏掉的那部分。检索流水线存的并不是文档的一份拷贝,而是一族派生物,而它们一个都不继承这次删除:

  • 摘要与上下文前缀。如果你给每个分块前置一段生成的文档摘要,那段摘要里含有被删的内容,而它活在你并没有删掉的那些分块里。
  • 知识图谱的节点与边。一趟抽取把文档变成了实体与关系。删掉源分块,这些断言仍然立着,而一个图检索器会心安理得地沿着它们走。
  • 智能体记忆。智能体上周读过这份文档,并把一条事实写进了长期记忆。那条记忆没有指回来的指针。这是最难的一个,也是记忆写入路径应当为每一条写入的事实记录来源溯源的原因——没有它,删除在构造上就是无法执行的。
  • 各类缓存。一个语义缓存里存着一对「问题与答案」,其依据正是那份被删的文档;它会继续端出来,比索引还快,而且中间没有检索步骤可供过滤。见语义缓存。
  • 评测集与微调语料。没那么紧急,偶尔却是最要命的那个。

落到实处:给每一个派生物在创建时就带上一个 source_ids 数组,并把删除做成沿这个索引的扇出,而不是一次单行操作。在收到第一份删除请求之后再补溯源,要比一开始就带着它贵得多——这与对智能体记忆的删除权从治理一侧给出的论证是同一个。

STEP 5

把陈旧度变成一个数字,按来源分,并且有人署名。

在多数系统里,新鲜度无人负责,因为它没被测量;而它没被测量,是因为那个显而易见的指标——「爬虫上次跑是什么时候」——测的是爬虫,不是语料。真正有意义的数字是变更到可检索:从文档在来源处发生变更的那一刻,到其新版本能够被检索到的那一刻之间的实际时间。做法是在摄取时把来源自己的修改时间戳打进分块,再与摄取时间做差。

  • 按来源报,取 p99。把一个 wiki、一个工单系统和一个文档库混在一起算均值,得到的是一个关于它们谁都不是的数字。某个 webhook 过期的来源会停在「以天计」,而均值看上去仍然良好。
  • 删除单独报。「删除到不可检索」是另一条分布,有另一个负责人,也有另一种后果。它才是该摆在为留存政策签字的那个人面前的数字。
  • 对「没有事件」告警,而不只是对错误告警。一条安静下来的变更流,和一份不再变更的语料,看上去一模一样。每个来源都需要一条预期事件速率的下限以及低于它的告警;这是整条流水线上单位收益最高的一个监控。
  • 把时间戳放进检索结果里。把「最后修改时间」与正文一起返回,好让模型看得见,从而说出「截至 9 月 3 日」,而不是用现在时下断言。代价是几个 token;换来的是把一类无声的错误,变成读者可以据以行动的一句保留。
  • 把失效用例加进评测集。一个正确答案已经变过的问题,以及一个来源文档已被删除的问题,都属于评估 RAG里描述的那个持续生长的小评测集。没有它们,每一次新鲜度的回归都会绿着发出去。
STEP 6

按语料各自定陈旧度预算,并允许其中一些宽松。

以上没有一句在主张「到处都要把陈旧度压到最小」。新鲜度要花钱、也要花复杂度,而很多语料并不需要多少。这个决定是一份按语料写下一次的预算,由拥有内容的人来定,而不是由拥有流水线的人。

  • 分钟级,事件驱动。任何人会据以行动、且人可以改动的东西:定价、政策、值班表、权限、事故记录。在这里,一个删除事件必须被立刻兑现,而索引宁可什么都不返回,也不该返回一份已被撤回的东西。
  • 小时级,事件驱动加每夜扫描。产品文档、内部 wiki、客服话术库。最常见的一档,也是 STEP 3 与 STEP 4 的架构所对应的那一档。
  • 天级,定时。研究语料、归档、已发表论文,以及任何「以追加为主、旧版本仍是关于过去的真陈述」的东西。
  • 干脆别索引它。如果一份语料变化的速度超过你能传播的速度,而答案又必须是当下的——订单状态、工单队列、账户余额——那么检索就是错的工具。给智能体一个实时查询工具。这与本地优先检索对源码树得出的结论是同一个:grep 读的是此刻的文件,而没有任何失效策略比「根本不留副本」更好。

最后这一行最值得力争,因为它恰恰是设计评审上没人会提的那个选项。新鲜度工程有很大一部分,存在的意义就是让索引表现得像一次实时查询。如果那个字段很小、结构化、而且本来就有 API 暴露出来,那么诚实的架构就是一次工具调用——索引则留着那些不会每小时变一次的散文。

只做一件事的话:挑出你最重要的那份语料,今天就去测「删除到不可检索」——在来源处删掉一份测试文档,计时看它还会回来多久。多数团队从没跑过这一步,并且会被答案吓一跳,因为这个数字通常是「爬取间隔 + 存储的压实延迟 + 缓存的存活时长」——三段谁都没加过的延迟。然后,在你为「让更新更快」花任何钱之前,先给删除一条自己的事件路径和一块自己的墓碑。相关:按权限检索对应 ACL 那一半,智能体式检索讲如何让智能体自己察觉陈旧并再搜一次,知识截止与时间则是模型自身版本的同一个问题。