跳到主要内容

按摄入日期分片的向量索引

· 阅读需 11 分钟
Tian Pan
Software Engineer

在按时间分区的向量索引中,隐藏着一种特定类型的召回率谎言,而构建离线评估的人通常是最后才发现它的人。仪表盘显示 recall@10 为 0.94。检索器在 94% 的情况下都能提供正确的片段。产品团队正基于这个数字发布更多以检索为基础的功能。接着,客服工单接踵而至:“助手引用的指南与答案不符”、“助手链接到了上周版本的政策”、“助手找不到我两个月前上传的文档”。这些工单都不与 0.94 这个数字冲突。它们证明了 0.94 衡量的是错误的东西。

这种机制很简单,也很容易被忽视。向量索引按摄入日期进行分片,因为这是保持高写入吞吐量、停用旧数据以及将热工作集保留在快速内存中的最简单方法。离线测试集每晚从生产日志中生成,这意味着查询是从最新分片恰好持有的同一个近期窗口中提取的。召回率是根据存在于一两个分片深处的基准真相(ground truth)来衡量的。检索器在这些查询上表现出色,因为在生产环境中,路由层会将这些查询保留在同一个分片内。

接着,真实用户提出了需要跨分片查询的问题。路由器进行分发(fan out),top-k 在分区之间进行合并,各分区在合并前返回各自本地的 top-k。从教科书的角度来看,这种合并是正确的——来自每个分片的最高分向量被组合在一起。但是,最新分片中的最高分向量几乎总是比旧分片中的次优向量匹配度更高,因为最新分片包含用户实际寻找的文档的最新重写版本。那个“正确”的片段,即两个月前本可以回答该问题的片段,却留在了一个被合并步骤降低了优先级的的分区中,无法被找到。

为什么离线数据依然表现良好

向量搜索不像关系型数据那样可以进行干净的分区。根据定义,最近邻查询是全局性的——正确答案可以存在于嵌入空间的任何位置——因此任何分片方案都是对典型查询需要哪些邻居的一次博弈。基于质心的分片在几何上进行博弈:在嵌入空间中相近的向量落在同一个分片上,路由将每个查询发送到少数几个可能的聚类中。这个博弈是有效的,因为查询分布的拓扑结构和数据分布的拓扑结构往往是重叠的。而按摄入日期分片则采取了完全不同的博弈。它假设文档的编写时间可以预测人们询问该文档的时间。这种假设对于新闻类数据是成立的,对于产品目录部分成立,但对于文档、政策和参考内容则是错误的——而这些恰恰是大多数 RAG 系统所基于的语料库。

离线评估无法检测到这一点,因为评估集的采样具有与架构本身相同的这种时间偏见。如果你的按摄入日期分片方案在起作用,那么最新的分片也就是持有能够回答今天查询的最新嵌入版本文档的分片。你的日志采样管道从昨天的流量中抓取查询,通过全量语料库扫描计算基准真相最近邻,然后评估生产环境的检索器。在离线模式下,由于没有延迟压力,检索器无论如何都会在所有分片上进行分发。基准测试变成了一个衡量答案出现在最新切片中的频率的指标——而由于样本的时间偏见,答案几乎总是出现在那里。Recall@10 看起来像是检索器的一个属性。实际上,它只是你的评估恰好询问的数据的一个属性。

会丢失信息的跨分片合并

问题出在分布式 top-k 的计算方式上。每个分片独立产生其得分最高的 k 个向量,路由器收集(分片数量 × k)个候选向量,合并步骤按分数挑选出全局 top-k。这是在大规模环境下唯一可行的方法——你无法承受将每个向量都传回中心节点进行重新排序的代价——但在数学上,这与 ANN(近似最近邻)无异:它是一种近似,只是具有不同的失败模式。

标准的 ANN 失败是单个分片内部错误的邻居胜出。而跨分片失败则更为隐蔽:正确的邻居永远无法从它所在的分片中脱颖而出,因为它在本地 top-k 环节就被过滤掉了。想象一个查询,其真正的最近邻位于一个查询密度较低的六个月前的分片中。正确文档的余弦相似度为 0.81。而包含另一个文档的最新更新版本的最新分片,返回了一个相似度为 0.83 的片段。正确答案在本地被压制了,因为旧分片包含许多原始文档的近乎重复项——在嵌入空间中,“近乎重复”足以让正确的文档无法进入本地 top-k。合并步骤永远看不到 0.81 这个候选对象。而在针对从同一近期分片提取的查询分布进行评估的仪表盘中,也永远看不到这种失败。

这就是为什么“增加 k 值”看起来像是一个显而易见的解决办法,却几乎从未起效。增加每个分片的 k 值会使延迟线性增加,成本呈平方级增长;而召回率的提升却是次线性的,因为旧分片的本地分布仍然被同一个近乎重复的集群所占据。你通过分片节省下来的延迟预算,正是你为了弥补这一缺陷而不得不花费的预算。

能够真正检测到这一点的诊断方法

你需要的信号不是聚合的 recall@10。它是以真实邻居所在的分片(shard)为条件的召回率。要获得这个数字,你需要一个评估集,其 ground-truth 邻居必须刻意分布在各个分片中,且比例要符合生产环境的实际情况 —— 而不是符合你的日志采样器比例。

一个可行的构建方案:

  • 根据文档时间(age)而非流量进行分层构建查询集。如果生产环境中 30% 的查询需要的是 90 天之前的答案,那么你的评估集至少应该有 30% 来自该桶。
  • 通过全语料库的暴力扫描计算 ground truth。记录每个 top-1 落在哪个分片中。
  • 在相同的查询上运行生产环境的检索器。测量基于 ground-truth 分片的条件召回率。如果对于答案在最新分片中的查询,recall@10 为 0.97,而对于答案在 90 天以上分片中的查询,recall@10 为 0.62,那么聚合数字就掩盖了 35 个百分点的差距。

同样的构建方式可以告诉你这个差距是随着时间缩小还是扩大。如果自最初的分片决策以来,你的语料库已经陈化了六个月,那么曾经在最新分片中占比 80% 的群体现在可能只有 50%,即便没有任何参数改变,你的聚合召回率也会悄无声息地发生偏移(drift)。

第二个值得运行的诊断:在每个分片上独立地重放相同的查询,捕获每个分片的局部 top-k,并询问如果重新包含那些在局部被砍掉的候选结果,合并步骤产生的全局 top-k 是否会改变。答案发生变化的查询比例,就是你欠合并步骤的“召回债”。在状态良好的索引中,这个比例低于 2%。但在一个为文档查询提供服务、且按摄入日期分片的索引中,这个比例可能达到 15% 到 25%。

看到问题后该怎么做

第一反应通常是重新分片(re-shard)。有时这是正确的决定:如果语料库和查询分布都具有稳定的时间局部性(例如新闻搜索产品或最近动态推荐系统),那么按摄入日期分片可以经受住这种分析并保留下来。对于大多数基于缓慢变动语料库的 RAG 系统,基于质心(centroid-based)或混合分片是更好的选择。重新分片的成本是一次性且巨大的;而沿用错误方案的成本则是无声且持续的。

第二反应是扩大扇出(fan out)—— 更多的 probe、更大的分片 k 值、每次查询搜索更多的分片。这有时是正确的过渡步骤,特别是当重新分片受阻于你无法控制的数据摄入管道时。诀窍是让扇出对查询具有自适应性,而不是对所有查询都一视同仁。如果你可以廉价地估计查询可能需要的文档时间 —— 使用查询嵌入上的小型辅助分类器、基于规则的日期提取器或按租户的先验信息 —— 你可以只针对需要的查询深入检索旧分片。大多数查询支付原始的延迟成本;而长尾查询则自负盈亏。

第三种做法是在评估层。即使索引保持不变,评估也应该改变。聚合 recall@10 应与按 ground-truth 分片、按文档时间、按查询时间分层的 recall@10 一起报告。当条件数值偏差超过几个点时,仪表盘应拒绝只显示一个单一数字。分片索引上的单一召回率数字几乎总是在撒谎;这种自律是为了迫使评估暴露架构选择隐藏的东西。

更广泛的模式

这种失效模式是更普遍现象的一个例子:任何时候当数据布局和评估数据具有共同的偏差时,评估就变成了对这种偏差的测量。从生产流量中采样查询是自然的默认做法,但生产流量是由你正在评估的系统所塑造的 —— 这意味着评估继承了系统的盲点。未被检索的数据不会产生被采样的查询。未被探测的分片不会对测试集做出贡献。在最旧和最新数据之间制造差距的语料库偏移,也正是悄悄地让测试集权重向“已经有效的部分”倾斜的同一种偏移。

修复方法是结构性的,而非指标性的。针对“答案应该来自哪里”的模型来构建评估群体,而不是针对“查询碰巧在哪里”。根据你怀疑不均匀的架构维度进行分层 —— 分片、租户、时间桶、嵌入版本、语言、文档类型。当各层平衡时,聚合指标才是真实有效的。当它们不平衡时,聚合指标只是架构讲给自己的一个故事。

检索团队的工作就是拒绝让这个故事保持连贯。召回率不是 0.94。召回率在你采样器偏好的切片上是 0.97,在产生支持工单的切片上是 0.62。任何混淆这两个数字的人,都会不断地被同一类 bug 所震惊。

References:Let's stay in touch and Follow me for more thoughts and updates