Codex版
这篇论文的方法可以概括为一条完整主线:
带有效期的向量事件流
↓
按时间顺序处理插入 / 失效事件
↓
维护"当前有效"的 HNSW 式邻近图
↓
为每个节点保存主邻居 + 备份邻居
↓
把每次邻居变化记录为历史版本
↓
用历史邻居树 HNT 压缩这些版本
↓
收到带查询时间 ts 的向量查询
↓
动态还原 ts 时刻的邻居关系
↓
在该历史图快照上执行 HNSW 搜索
↓
返回当时有效的 k 个近似最近邻
下面按照"数据进入系统 → 索引更新 → 数据失效 → 历史压缩 → 查询执行"的顺序展开。
1. 数据与查询进入系统
每个向量 \(u_i\) 除了高维特征,还带有两个时间属性:
\ u_i=\[\\text{vector},u_i.s,u_i.e) \\
其中:
- \(u_i.s\):向量开始有效的时间;
- \(u_i.e\):向量失效的时间;
- 写入时通常只知道 \(u_i.s\),并不知道未来何时失效;
- 查询由 \((q,t_s,k)\) 构成,即在时间 \(t_s\) 的有效数据中,寻找与查询向量 \(q\) 最相似的 k 个向量。
论文将其定义为:
The timestamp approximate nearest neighbor search TANNS(D, ts, q, k) returns a subset of k valid vectors that are approximately closest to q.
例如,查询 2024 年 6 月的知识时,只允许检索在该时刻有效的文档向量,2025 年新增的信息不能参与搜索。
2. 系统按时间顺序接收更新事件
系统把数据变化组织为时间事件流:
t1:插入向量 u1
t2:插入向量 u2
t3:向量 u1 失效
t4:插入向量 u3
......
论文为简化分析,假定每个离散时间戳只发生一次插入或失效。实际系统可以把同一批更新拆成多个逻辑时间戳。
整个索引不为每个时间戳重新构建一份 HNSW,而是维护一张持续演化的统一图,即时间戳图(Timestamp Graph)。
3. 新向量插入时间戳图
假设新向量 \(o\) 在时间 t 开始有效。
3.1 搜索候选邻居
首先在当前有效图上执行一次类似 HNSW 的搜索,找出 \(M'\) 个候选邻居:
\ cand=\\text{Search}(TG,o,M') \\
论文默认:
- \(M=16\):正式邻居数量;
- \(M'=200\):构图时的候选邻居数量。
3.2 从候选中选择 2M 个邻居
传统 HNSW 只选择 M 个邻居,而论文选择最多 2M 个:
\ TG\[o,Bo=\text{Select-Nbrs}(o,cand,2M) \]
其中:
- 最近、结构质量较好的 M 个进入主邻居列表 \(TGo\);
- 剩余 M 个进入备份邻居列表 \(Bo\)。
通俗来说,主邻居是当前图中的正式连接,备份邻居是"替补席"。以后主邻居失效时,可以直接从替补席补位,不必立即重新搜索整个图。
3.3 建立反向连接
对于 \(o\) 的每个主邻居 u,系统检查是否应当把 o 加入 u 的邻居列表:
- 如果 o 比 u 当前最差的邻居更合适,而且没有被其他邻居支配:
- 把 u 原来最差的主邻居移入 \(Bu\);
- 把 o 加入 \(TGu\);
- 记录 u 在时间 t 的新邻居版本。
- 如果 o 不足以成为主邻居:
- 把 o 放入 \(Bu\);
- 若备份数量超过 M,删除最远的备份。
因此,一次插入只局部影响 o 及其附近大约 M 个节点,而不需要重建整张图。
4. 向量失效时维护图连通性
假设向量 o 在时间 t 失效。
如果直接删除 o,周围节点的邻居数量会逐渐减少,严重时会使图断开,导致 HNSW 搜索无法到达正确区域。因此系统对所有与 o 相连的节点 u 逐一处理。
情况一:o 只是 u 的备份邻居
直接从 \(Bu\) 删除 o,不改变图结构。
情况二:o 是 u 的主邻居
首先从 \(TGu\) 删除 o。
如果 \(Bu\) 不为空:
\ cand=TG\[u\cup Bu \]
然后重新执行邻居选择,从这些局部候选中选出新的 M 个主邻居,其余继续作为备份。
如果 \(Bu\) 已经为空,才执行一次代价更高的全图搜索:
\ cand=\\text{Search}(TG,u,M') \\
再重新选择 M 个主邻居和 M 个备份邻居。
最后,把 u 在时间 t 的新邻居列表记录下来。
o 自身如何处理
当 o 失效时:
- 清空当前使用的 \(Bo\);
- o 不再参与当前时间之后的搜索;
- 但不能删除 o 的历史邻居记录,因为过去时间戳的查询仍可能访问它。
这就是系统能够执行"历史时间旅行查询"的基础。
5. 形成历史邻居版本
随着插入和失效不断发生,一个节点 o 的邻居可能这样变化:
| 时间 | 主邻居 |
|---|---|
| \(t_1\) | \(u_3,u_4\) |
| \(t_2\) | \(u_1,u_3\) |
| \(t_3\) | \(u_1,u_3\) |
| \(t_4\) | \(u_1,u_2\) |
| \(t_5\) | \(u_2,u_6\) |
| \(t_6\) | \(u_1,u_6\) |
| \(t_7\) | 无,o 已失效 |
未压缩的时间戳图会保存这些历史版本。查询 \(t_4\) 时,通过二分查找找到 \(t_4\) 对应的邻居表 \(\{u_1,u_2\}\)。
问题在于:相邻版本高度重复。例如 \(u_1\) 连续出现在多个版本中,如果每个版本都保存完整邻居列表,仍会消耗较多空间。
6. 用历史邻居树 HNT 压缩版本
论文进一步为每个节点维护一棵历史邻居树(Historic Neighbor Tree)。
它由两部分组成:
当前邻居列表 Lnow
+
历史邻居树 HNT
- \(L_{now}\):保存当前邻居;
- HNT:保存已经离开当前邻居表的历史邻居及其有效区间。
6.1 邻居发生变化时
系统收到节点 o 在时间 t 的最新邻居表 \(L_t\),比较:
\ L_{now}\\setminus L_t \\
这些点刚刚从当前邻居表中被移除,需要进入 HNT。
6.2 新历史邻居如何进入 HNT
对于第一次进入历史树的点 p:
- 找到从根节点到最新叶节点的 active path;
- 沿路径寻找 p 能够覆盖的最高节点;
- 将 p 存入该节点;
- 如果最新叶节点中的点超过阈值 \(\mu\),就进行节点分裂;
- 论文默认 \(\mu=8\)。
HNT 采用自底向上的扩展方式,以保证树高最多为:
\ O(\\log n) \\
6.3 邻居退出后又重新出现
如果 p 以前已经进入 HNT,后来又重新成为当前邻居,再次退出时,不重复保存新副本,而是将原记录沿祖先路径向上移动到能够覆盖更多时间范围的位置。
论文证明,移动后的节点一定是原节点的祖先。
6.4 一个点只保存一次
HNT 最关键的不变式是:
a point valid in multiple nodes is stored only in the highest node where it is valid.
也就是说,即使某个邻居出现在十几个历史版本里,它也尽量只保存一份,通过有效时间区间判断它属于哪些版本。
因此,空间从时间戳图的:
\ O(M\^2N) \\
进一步压缩为:
\ O(MN) \\
这与只保存单个时间快照的 HNSW 空间复杂度相同。
7. 收到 TANNS 查询
现在系统收到查询:
\ TANNS(D,t_s,q,k) \\
例如:
查询向量:q
目标时间:t5
返回数量:k=10
系统不需要生成完整的 \(t_5\) 数据快照,而是在图搜索过程中按需还原局部邻居。
8. 从入口点开始执行 HNSW 式搜索
搜索维护两个集合:
pool:下一步可能访问的候选节点;ann:当前找到的较优近邻集合。
搜索广度为 \(k'\),通常 \(k'>k\)。论文实验通过改变 \(k'\) 调节召回率和 QPS。
基本循环是:
- 从
pool中取出与 q 最近的节点 u; - 获取 u 在查询时间 \(t_s\) 的邻居;
- 把尚未访问且在 \(t_s\) 有效的邻居加入候选;
- 维护最多 \(k'\) 个当前最优候选;
- 当剩余候选不可能优于现有结果时停止;
- 返回距离最近的 k 个结果。
与普通 HNSW 唯一但关键的区别是:
普通 HNSW:
直接读取当前邻居 G[u]
本文方法:
根据查询时间 ts 动态重建 TG[u, ts]
9. 如何重建某节点在 ts 时刻的邻居
对于访问到的节点 u,系统执行 Algorithm 5。
9.1 扫描当前邻居列表
\(L_{now}\) 中的点按开始时间排序。
系统从前向后扫描:
- 如果点在 \(t_s\) 时有效,加入邻居集合;
- 当遇到开始时间晚于 \(t_s\) 的点时停止。
9.2 遍历 HNT
从 HNT 根节点开始:
- 在当前树节点中加入 \(t_s\) 时有效的点;
- 若 \(t_s=n.t\),直接结束;
- 若 \(t_s<n.t\),走左子树;
- 若 \(t_s>n.t\),走右子树;
- 到达叶节点后完成重建。
每个 HNT 节点中的点同时按开始时间和结束时间排序:
- 查询时间早于节点时间时,按开始时间扫描;
- 查询时间晚于节点时间时,按结束时间扫描。
这样,每层最多多访问一个无效点。
重建复杂度为:
\ O(\\log n+M_r) \\
其中 \(M_r\) 是重建出的邻居数量。论文实验中最大 \(M_r\) 约为 \(1.31M\),即 M=16 时通常不超过约 21 个邻居。
10. 返回最终结果
搜索结束后,系统从 ann 中选出距离 q 最近的 k 个向量,并保证这些向量在查询时间 \(t_s\) 有效。
准确率使用 Recall@k:
\ Recall=\\frac{\|r\\cap r\^\*\|}{k} \\
其中:
- r:TANNS 返回结果;
- \(r^*\):在 \(t_s\) 时刻所有有效向量上执行精确 kNN 得到的真值。
论文实验使用 \(k=10\)。
11. 完整生命周期示例
假设节点 o 最初有:
主邻居:u3、u4
备份邻居:u1、u2
完整演化过程如下:
- \(t_1\):o 建立主邻居
{u3,u4},备份{u1,u2}。 - \(t_2\):u4 失效,从备份中选择 u1 补位,主邻居变成
{u1,u3}。 - \(t_3\):u5 插入,但不够好,进入 o 的备份列表。
- \(t_4\):u3 失效,从备份中选择新邻居,主邻居变为
{u1,u2}。 - \(t_5\):u6 插入并取代 u1,u1 被移入备份列表。
- \(t_6\):u2 失效,u1 再次从备份中恢复,主邻居变成
{u1,u6}。 - \(t_7\):o 失效,清空当前备份,但保留 o 的全部历史关系。
- 查询 \(t_4\):系统通过 HNT 恢复 o 当时的邻居
{u1,u2}。 - 查询 \(t_8\):o 已失效,因此不会进入有效搜索结果。
12. 最终复杂度与效果
| 阶段 | 压缩时间戳图 |
|---|---|
| 向量插入 | \(O(\log^2N)\) |
| 向量失效 | 摊销 \(O(\log^2N)\) |
| TANNS 查询 | \(O(\log^2N)\) |
| 历史邻居重建 | \(O(\log n+M_r)\) |
| 总索引空间 | \(O(MN)\) |
最终实验结果:
- 四个数据集召回率均可超过 99%;
- 95% 召回率下,相比现有方案加速 4.4×--138.1×;
- M=16 时支持超过 1000 次更新/秒;
- 1000 万 DEEP 向量上达到 857 QPS、99% 召回率;
- 压缩结构占用 46.7GB ,内存最多降低 66.8%。
最核心的理解是:它并没有为历史时间生成完整图快照,而是把图的变化记录下来;查询某一历史时刻时,只对搜索途中访问到的节点按需恢复当时的邻居关系。这样同时避开了"为每个时间点复制整张图"和"先搜索大量无效向量再过滤"这两类开销。
doubao版
整个流程围绕时间戳图(Timestamp Graph, TG) 和历史邻居树(Historic Neighbor Tree, HNT) 两大核心结构展开,覆盖索引构建与压缩 → 查询执行 → 动态更新三大阶段,全程按时间维度增量推进,无需全量重建索引。
一、索引构建与压缩全流程
该阶段的目标是:把带有效期的高维向量,构建成一张支持所有历史时间戳检索、且经过极致压缩的统一图索引。 论文采用离散时间戳假设:每个时间戳恰好有 1 个向量生效或过期,索引按时间顺序增量构建,而非批量生成。
1. 初始状态与数据准备
- 输入:N 个高维向量,每个向量附带生效时间 s 和过期时间 e(写入时 e 未知,后续动态触发)。
- 初始化:
- 空的多层时间戳图 TG(继承 HNSW 的多层小世界图结构,每层都是一个子图)。
- 每个向量节点预留两个核心存储:
L_now:当前最新的主邻居列表(M 个)+ 备份邻居列表(M 个)。- 历史邻居树 HNT:用于归档所有曾经出现过、但已不在当前列表中的邻居,按有效时间段压缩存储。
2. 增量构建时间戳图(按时间顺序插入向量)
每到一个新时间戳,若事件是「新向量生效」,则执行点插入算法(论文 Algorithm 3),步骤如下:
步骤 1:搜索候选邻居
在当前已有的时间戳图中,对新向量o执行一次标准 HNSW 搜索,检索出M'个距离最近的候选邻居(M'远大于 M,默认 200),保证邻居质量。
步骤 2:分配主备双邻居
从M'个候选中,选出距离最近的2M个点:
- 前 M 个作为主邻居 ,存入
TG[o](对应图中的实线连接); - 后 M 个作为备份邻居 ,存入
B[o](对应图中的虚线连接)。
设计目的:后续主邻居过期时,可直接从备份里补充,避免图连通性下降。
步骤 3:反向更新邻居节点
新向量o需要被加入到它所有邻居的对应列表中,保证图的双向性:
- 对每个主邻居
u:如果o比u当前最远的主邻居更近,就把u最远的主邻居挤到备份列表,把o加入u的主邻居;否则直接加入u的备份列表。 - 对备份列表超过 M 个的节点,删掉最远的备份点,维持长度固定。
步骤 4:记录历史邻居版本
所有被修改了主邻居列表的节点,都在自己的历史邻居列表末尾,追加一条带当前时间戳的新版本记录。
例:节点 u 在 t1 时刻邻居是 {A,B},t2 时刻新增 C 变成 {A,B,C},则 u 的历史列表会记录「t1: {A,B}」「t2: {A,B,C}」两个版本。
3. 历史邻居树(HNT)增量压缩
原始时间戳图的历史列表存在大量重复(同一个邻居可能在多个版本中反复出现),因此每次邻居列表变动时,都会同步通过追加邻居列表算法(论文 Algorithm 6) 压缩到 HNT 中。
核心逻辑
每个节点的邻居分为两部分:
L_now:存放当前最新的有效邻居,按生效时间排序。- HNT:存放所有已从 L_now 中移除的历史邻居,按有效时间段树状归档,同一个邻居只存一次。
压缩执行步骤
当节点的L_now发生变化、有邻居p被移除时:
- 新邻居归档 :如果
p从未在 HNT 中出现过,从 HNT 的最新叶子节点向上遍历,找到能容纳p的最高层节点(即p的有效期覆盖该节点的时间戳),将p存入该节点。 - 节点分裂平衡:如果叶子节点的点数超过阈值 μ(默认 μ=8),则该叶子节点标记为 "已满",新建一个内部节点作为它的父节点,同时新建一个空的叶子节点作为兄弟节点,承接后续新归档的邻居。
- 已有邻居调整 :如果
p之前已经在 HNT 中(即之前被移除过、后来又加回来了),则找到它所在的节点,尝试向上移动到更高层的祖先节点,保证存储位置最高、冗余最少。
通俗理解:HNT 就像给每个节点的 "通讯录历史" 做了压缩归档 ------ 重复出现的联系人只存一次,标注好它的有效时间段;需要查某个时间点的通讯录时,再临时拼出来。
二、TANNS 查询执行全流程
当收到查询请求(查询向量q、查询时间戳ts、返回数量k)时,执行完整的时态近邻搜索,核心是先重建对应时间的邻居,再执行贪心搜索。
1. 邻居列表重建(压缩版特有步骤)
对搜索过程中访问到的每个节点u,都需要先通过邻居列表重建算法(论文 Algorithm 5) ,还原出ts时刻的完整有效邻居列表L_t。
重建步骤
- 扫描当前列表 :遍历
u的L_now,把所有在ts时刻有效的邻居加入结果集,直到遇到第一个生效时间晚于ts的点,停止扫描。 - 遍历历史邻居树 :从 HNT 根节点开始向下遍历:
- 若当前节点的时间戳等于
ts,直接加入该节点所有有效点,结束遍历; - 若
ts< 当前节点时间戳,向左子树走,同时按生效时间顺序扫描节点内的点,加入有效点; - 若
ts> 当前节点时间戳,向右子树走,同时按过期时间顺序扫描节点内的点,加入有效点;
- 若当前节点的时间戳等于
- 到达叶子节点后,加入叶子里所有
ts时刻有效的点,最终得到完整的L_t。
论文验证:重建出的邻居列表大小平均仅为 1.31M,和原始 M 非常接近,不会带来显著的计算开销。
2. 多层贪心搜索(和 HNSW 逻辑兼容)
邻居重建完成后,搜索逻辑和标准 HNSW 基本一致,全程在ts对应的有效子图中导航:
- 初始化 :从图的最顶层入口点开始,维护两个集合:
pool:候选导航点,用于继续向更近的方向探索;ann:潜在最近邻,用于保存当前找到的最近点。
- 逐层导航:从最顶层开始,每层都执行贪心搜索,找到该层离 q 最近的点,作为下一层的入口点。
- 底层精细搜索 :在最底层(数据层)持续迭代:
- 从 pool 中取出离 q 最近的点 u;
- 重建 u 在 ts 时刻的邻居列表
L_t,把未访问过的邻居加入 pool 和 ann; - 当 ann 的大小达到
k'(搜索范围参数,k'>k)、且 pool 中所有点都比 ann 中最远的点远时,终止搜索。
- 返回结果:从 ann 中选出距离最近的 k 个点,作为 TANNS 查询结果。
三、动态增量更新全流程
向量数据库是持续写入的,新向量不断生效、旧向量不断过期,时间戳图支持增量更新,无需重建全图。
1. 新向量插入流程
和索引构建阶段的插入逻辑完全一致(对应 Algorithm 3):
- 搜索候选→分配主备邻居→反向更新邻居→追加历史版本→同步归档 HNT。
- 每次插入仅影响 O (M) 个节点的邻居列表,时间复杂度 O (log²N)。
2. 旧向量过期流程
当向量o到达过期时间,执行点过期算法(论文 Algorithm 4) ,核心是只修改受影响的邻居,保留历史数据。
过期步骤
- 遍历
o的所有主邻居:- 如果
o在对方的备份邻居列表里:直接删除即可,不影响主连通性。 - 如果
o在对方的主邻居列表 里:- 若对方备份列表非空,就从备份里选一个最近的点,补充到主邻居里;
- 若备份列表为空,就重新执行一次搜索,为该节点选出新的 2M 个主备邻居。
- 如果
- 所有被修改了主邻居的节点,都追加一条带当前时间戳的历史邻居版本,并同步更新 HNT。
- 对过期向量
o本身:清空它的备份列表,但保留所有历史邻居记录,保证历史时间戳的查询仍然可以正常使用。
关键优势:每次过期仅影响 O (M) 个节点,绝大多数情况只需备份补位,无需重新搜索,更新复杂度 O (log²N),远优于朴素方案的全量重建。
完整流程闭环总结
数据写入 → 按时间增量插入 → 构建时间戳图TG → 增量压缩进历史邻居树HNT
↑ ↓
│ 持续动态更新 查询请求到来
│ ↓
└──── 向量过期/新增 ←──── 重建ts时刻邻居列表 ←── 解析查询时间戳ts
↓
多层贪心导航搜索
↓
返回top-k结果
整个流程的核心设计思想是:利用时间局部性复用索引结构,通过主备机制保障动态稳定性,通过树状归档压缩历史冗余,最终在单索引的存储成本下,实现了全历史时间戳的高效近似近邻搜索。