查得到,也要搬得走:Zvec 全量遍历的设计与实现

Zvec v0.7.0 引入 DocIterator:流式读出整个集合。迭代器在创建时定格一份隔离视图,遍历期间可进行查询、写入和删除,且不影响一致性;文档按窗口物化,内存开销与集合的大小无关;可以只取部分字段、跳过向量。提供多种编程语言的 SDK 接口。Zvec Studio 则把它包装成界面化的数据导出,并配套了导入与整库迁移。

在 Zvec 中,可以利用向量高效检索相似数据,也可以结合标量条件过滤结果,但如果要将已经存入的数据完整导出,应该怎么做?

在 v0.7.0 之前,Zvec 缺少全量遍历接口,fetch()/query()这类检索接口也无法保证覆盖整个集合。新增的 DocIterator 补齐了这一能力,它面向的是与“检索”不同的另一类需求:不关心相似度、不关心排序,只要求完整、稳定、低开销地把每条数据读出来。这类需求在实际使用中并不少见:

  • 几台机器分别跑完 embedding,要把结果合并成一个集合;
  • 给 Agent 的长期记忆做离线整理、去重、重新分片;
  • 导出一份数据集,交给别的语言、别的工具处理;
  • 迁移或备份,在另一台机器上原样重建一个集合。

本文讲清楚三件事:为什么原有接口行不通、DocIterator 怎么用、以及它内部是怎么在“恒定内存”和“一致视图”之间做设计取舍的。

一、为什么原有接口行不通

在有 DocIterator 之前,想遍历整个集合,有三条看起来可行的路。它们各自卡在不同的地方,而这些卡点恰好解释了为什么遍历需要一个专门的接口。

路一:按主键批量 fetch() fetch() 一次能取多条、也能裁剪字段,看起来够用——但它按主键取,而你得先有一份全部主键的清单。Zvec 没有“列出所有主键”的接口,因为那本身就是一次全量遍历,这是先有鸡还是先有蛋的问题。

路二:把 topk 调大,一次全查回来。 query() 的语义是返回最相似的 k 条,topk 有上限(当前为 10 万)。拿它做全量导出有两个硬伤:你手里根本没有一个查询向量,只能凭空造一个;而且集合超过 10 万条就查不全。

路三:绕过接口,直接拷贝数据文件。 这条路最诱人也最危险——因为 Zvec 的内部存储不是、也不该是导出格式:

障碍具体是什么
向量和标量分开存标量在正排文件里,向量在各自的索引文件里,拷正排文件拿不到向量
正排里混着系统列除了用户字段,还有 _zvec_g_doc_id__zvec_row_id_ 这类内部行号列
有一部分数据还没落盘正在写入的段数据在内存里,文件层面根本看不到
文件布局不固定一个段对应多个文件,且格式随存储配置在 Arrow IPC 与 Parquet 之间变化

这三条路的共同结论是:“读出用户当初写进去的那条文档”这件事,只有引擎能做。它知道 schema、知道当前有哪些段、哪些行已被删除、某个行号该去哪个索引文件取向量——这些信息应用层拿不全,也不该去拼。DocIterator 就是把这件事做成了一个正式接口。

三种查询方式各有各的定位,不是谁替代谁:

query()fetch()iter_docs()
用途查最相似按主键取读整个集合
需要查询向量需要不需要不需要
能否覆盖整个集合上限 10 万条需先有全部主键可以
单次内存至多 topk 条所请求的全部文档一个窗口,流式
一致性范围单次调用内单次调用内整个遍历过程

二、DocIterator能做什么

最直接的印象来自代码。遍历一个集合、导出成 JSON Lines,就是一个循环:

import json

with collection.iter_docs() as docs:
    for doc in docs:
        line = {"id": doc.id, "fields": doc.fields, "vector": doc.vector("embedding")}
        print(json.dumps(line))

doc.id 是主键,doc.fields 是标量字段字典,doc.vector(name) 取某个向量字段(示例里的 embedding 是向量字段名)。读到末尾循环自然结束。这段代码无论集合里是几千条还是几千万条,内存占用都在同一量级——这是 DocIterator 最核心的性质,后面第三节会讲它是怎么做到的。

它给出三个保证,正好对应遍历这件事最容易出问题的三个点:

读得全,且不需要查询向量。 iter_docs() 把每条文档依次交出来(遍历顺序不作保证),回答的是“集合里都有什么”,而不是“什么最相似”。

读得稳:创建时定格一份视图。 迭代器创建的那一刻,当时正在写入的数据被一并纳入,删除标记被单独复制一份。此后无论集合里发生什么写入和删除,本次遍历看到的都是创建那一刻的样子。导出过程中即使发生写入集合的操作,也不会得到一份新旧混杂的数据。

读得省:内存与集合的大小无关。 文档按窗口物化、一条一条地交出来,整个窗口在物化下一窗口前释放。

在这三个保证之上还可以裁剪——只取需要的标量字段,或者不要向量:

with collection.iter_docs(
    output_fields=["title", "score"],  # 只取这些标量字段;主键始终返回
    include_vector=False,              # 默认 True;不需要向量时关掉更快
) as docs:
    for doc in docs:
        ...

裁剪不只是省字段,也直接影响速度。同一份 100 万条 256 维的集合,完整遍历一遍:

耗时吞吐
include_vector=True(默认)4.06 s25 万条/秒
include_vector=False1.03 s97 万条/秒

差距来自向量:向量不在正排文件里,取向量意味着为每条文档额外去向量索引读一次。导出场景里如果向量不是必需的(比如只想核对标量字段、统计分布),关掉它能快四倍。

三、内部设计:在一致和省内存之间取舍

遍历整个集合看似简单,真正要解决的是一对矛盾:既要让这次遍历看到一份自洽的数据(否则导出的集合是坏的),又不能为此把整个集合读进内存(否则大集合直接 OOM)。DocIterator 的设计就是围绕这对矛盾展开的。

3.1 一致性:定格,而不是加锁

最简单的一致是加一把大锁——遍历期间禁止一切写入。但全量导出可能持续几分钟到几小时,锁住整个集合显然不可接受。

Zvec 的选择是在创建时定格一份视图,之后遍历不再需要和写入争抢。create_iterator() 在创建那一刻做四件事:

  1. 若当前存在正在写入且非空的段,将其封存成一个不可变的持久段;
  2. 取此刻的持久段列表;
  3. 深拷贝一份删除标记
  4. 记下当前 schema。

这一切在一把短暂的写锁内完成,只占用毫秒级时间。此后:新写入进入一个新的段,不在已定格的段列表里,遍历看不到;新的删除改的是原集合的标记,而迭代器手里是自己那份克隆副本,不受影响。

一个实现上的细节值得一提:字段合法性校验被刻意放在封存之前。如果用户传了不存在的字段名,接口直接报错返回,不会留下一个白白封存的段。

需要说明的是,这不是 MVCC。Zvec 没有多版本存储,也不提供“回到任意历史时间点”的能力——它给的是一次性定格的隔离视图,足以保证单次导出的数据自洽,但不承诺你三天后还能读回今天的状态。对导出、备份、迁移这些场景,这个保证刚好够用,也足够轻量。

只读集合走更简单的路:不封存、不写盘(写盘违反只读语义),直接把包括正在写入段在内的所有段纳入遍历——只读集合本就没有并发写入,视图天然稳定。

创建时定格的视图:此后的写入与删除不影响本次遍历

3.2 省内存:三层惰性 + 窗口物化

定格解决了一致,但它定格的是段的列表,不是段里的数据——数据仍然是边遍历边读的。这里 DocIterator 做了三个层次的“不多拿”:

  • 段级:同一时刻只打开一个段的读取器,这个段读完就关掉,再打开下一个。任意时刻最多只有一个段的文件被打开;
  • 列级:从一个段读上来的一批数据是 Arrow 列式格式,转成 Document 时按列处理,比逐行更高效;向量不在正排里,需要时再按段内行号去向量索引取;
  • 窗口级:把一批数据转成 Document 时,以窗口为单位,一个窗口最多 4096 行(kMaxRecordBatchNumRows),这一窗交给用户、释放,再转下一窗。

所以任意时刻真正物化成 Document 的只有一个窗口,迭代器自身的内存开销是常数级,不随集合的大小增长。100 万条 256 维的集合,只取标量字段完整遍历一遍,内存增量全程是一条平线(实测稳定在 1.3 MB,与已读 20 万还是 100 万无关)。

如果连向量一起取,你会在监控里看到 RSS 随遍历一路爬升,这并不与前面矛盾,而是两层不同的内存:迭代器在堆上物化的 Document 始终只有一个窗口;而向量存在向量索引文件里、默认以内存映射(mmap)方式访问,遍历逐条读向量会依次触达它们所在的页,这些页作为文件缓存留在 RSS 里。它由操作系统管理、内存紧张时可回收,不是迭代器在堆上攒数据。

一句话:省内存指的是迭代器自身的常驻开销恒定在一个窗口。 带向量的全量导出终究要把每条向量都读一遍,这部分是数据本身的体量、与用哪个接口无关;只导出标量时用 include_vector=False,就回到那条平线。

3.3 遍历期间,集合还能不能用

定格保证了遍历不受写入干扰,反过来,遍历会不会拖住别的操作?答案是分两类:

遍历打开期间的操作行为
insert / upsert / update / delete✅ 正常(新写入对本次遍历不可见)
query / fetch / stats✅ 正常
flush✅ 正常
create_index / drop_index
add_column / alter_column / drop_column
❌ 立即报错
optimize❌ 立即报错
destroy / close❌ 立即报错
再创建一个迭代器✅ 允许,多个迭代器可并存

一句话概括:写入和查询完全不受影响,只有会改变集合结构的维护类操作(改 schema、优化、销毁)会被挡住。 反过来也一样——当一个维护操作正在进行时,create_iterator() 会立即报错而不是排队等待。

这里用“立即报错”而不是“阻塞等待”是有意为之:一个遍历可能开着几分钟,如果让 DDL 去等它,就等于让维护操作被一个不知何时结束的遍历无限期挂住。立即失败不改变任何状态,调用方关掉迭代器重试即可——这也是为什么在 Python 里推荐用 with:它保证遍历在正常结束、提前 break、中途异常等任何路径下都被及时关闭,不会因为忘记关闭而长时间挡住维护操作。

四、SDK调用方法

DocIterator 在多种语言提供一致的语义。

Python 最简洁,iter_docs() 返回的对象既是迭代器又是上下文管理器:

# 直接遍历
for doc in collection.iter_docs():
    process(doc.id, doc.fields, doc.vector("embedding"))

# 提前结束用 with,保证及时释放
with collection.iter_docs(include_vector=False) as docs:
    for doc in docs:
        if enough(doc):
            break

合并多个集合是个典型组合——读取侧流式、写入侧分批(单次写入上限 1024 条):

from itertools import islice

with src.iter_docs() as docs:
    for batch in iter(lambda: list(islice(docs, 1000)), []):
        target.insert(batch)

C++next() 是三态返回,出错和“读完了”分得清清楚楚:

IteratorOptions options;
options.output_fields_ = {"title", "score"};
options.include_vector_ = false;

auto it = collection->create_iterator(options).value();
while (true) {
  auto r = it->next();
  if (!r.has_value()) {             // 出错:r.error() 是具体原因,必须处理
    std::cerr << r.error().message() << std::endl;
    return 1;
  }
  if (r.value() == nullptr) break;  // 遍历结束
  const Doc::Ptr &doc = r.value();
  // doc->pk()、doc->get<std::string>("title");include_vector_=false,故文档中不含向量
}
it->close();  // 提前结束时显式释放;离开作用域也会自动释放

出错时显式返回错误而不是静默跳过,这一点对全量遍历尤其重要:一次遍历可能涉及几千万条数据,静默跳过意味着你拿到一份不完整的数据却毫不知情。

C API 对应 zvec_collection_create_iterator() / zvec_doc_iterator_next() / zvec_doc_iterator_close(),语义完全一致。

对于 Node.js 等语言调用方法可参考官方API文档 https://zvec.org/en/api-reference/

五、Zvec Studio:把遍历变成界面操作

命令行之外,Zvec Studio(Zvec 的桌面管理工具)在 DocIterator 之上做了图形化的数据导出,并配套了导入与整库迁移。进入任意集合,切到导入/导出标签页即可。

导入:读取本地 JSONL 分批写入,返回逐行报告。两种模式(更新 更新同主键 / 仅新增 只写新主键),两种错误策略:

  • skip:跳过坏行继续,成功数 + 失败数 = 总行数,一条坏行不会拖垮整批导入;
  • abort:遇到第一条失败行就停,并且已写入的行会被撤回,不会留下一个“导了一半”的集合。

导出:整个集合以 JSON Lines 流式下载,文件名自带时间戳。可以选择是否包含向量、是否包含标量字段、以及只导出指定字段。导出全程流式,导出的数据量不影响内存占用。

Zvec Studio 导入对话框:JSONL 文件、导入模式与坏行策略Zvec Studio 导出对话框:导出模式、向量/标量开关与字段筛选

整库迁移:导出时选择快照模式,会把完整 schema 和数据一起打包成 .tar.gz。在另一台机器上导入集合即可重建整个集合——写入前先做 schema 预检(不匹配会列出差异并拒绝),支持重命名,并且全成或全败:中途出错或任一行失败,新建的集合会被回滚,并指出第一条出错的行。

Zvec Studio 导入集合对话框:快照文件、目标目录与集合名

开头那几个场景到这里就都能落地了:每台机器各自导出快照,拷到一起逐个重建;或者先把 JSONL 合并,再一次性导入。这些能力同样有对应的 HTTP 接口,便于接进自动化流程。

六、边界与注意事项

  • 一致性是一次性定格,不是 MVCC:适合一次完整的导出/备份/迁移,不能当作可回溯的历史版本;
  • 遍历与维护操作互斥:迭代器打开期间,改 schema、optimize、destroy、close 会报错。长时间挂着的迭代器会挡住这些操作,用完记得关(Python 用 with 最稳妥);
  • 可写集合上创建迭代器可能封存一个段:频繁创建迭代器会积累出许多小段,需要时用 optimize() 合并;
  • 导出格式目前是 JSON Lines:其他格式(如直接产出 Parquet)尚未提供。

七、小结

向量数据库的价值不只在于查得准,也在于数据“进得来、出得去”。Zvec v0.7.0 的 DocIterator 把“完整读出一个集合”做成了一个语义清晰的引擎接口:

  • 读得全——覆盖整个集合,按需导出向量,不漏一条数据;
  • 读得稳——创建时定格隔离视图,遍历过程数据自洽,而代价只是毫秒级的封存,不阻塞并发读写;
  • 读得省——三层惰性 + 窗口物化,峰值内存与集合大小无关。

Zvec Studio 在此之上把导出做成了界面,并配套了导入与整库迁移,让“搬数据”这件事对命令行用户和图形界面用户都顺手。

如果你的数据正卡在“查得到但搬不走”,升级到 v0.7.0,用 iter_docs();偏好图形界面,直接用 Studio 的导入/导出。

Zvec 以 Apache 2.0 协议开源,欢迎体验、反馈与贡献。

GitHub:https://github.com/alibaba/zvec

文档:https://zvec.org