Skip to content
Back to posts

Lance 向量索引原理与源码分析(六)

索引维护、调参与工程选型

Table of contents

本文源码基于 Lance v10.0.0 的提交 95f2f36b。文中的代码片段保留关键控制流,省略日志、错误处理与本文无关的分支。

上一篇已经说明:正常向量查询会把索引支路与未索引 fragment 的 Flat KNN 支路合并,所以 append 后通常不会立即返回错误结果。但“仍然正确”不等于“索引不需要维护”。

随着 append、update、delete 和 compaction 持续发生,同名向量索引会形成多个不可变 segments;查询要打开更多索引文件,或扫描更多未覆盖数据。工程上真正要管理的是三件互不等价的事:

维度问题验证方法
正确性当前有效行、过滤和删除语义是否完整与当前 Dataset 的 Flat 结果、行数和过滤条件核对
ANN 召回率已索引行中的真实 Top-K 是否进入候选与 Flat Top-K 的 row ids 计算 Recall@K
速度查询、构建和维护是否满足资源与延迟目标p50/p95、构建时间、索引字节、内存与缓存命中

本文不提供脱离数据集的“最佳参数”,而是从 v10.0.0 的生命周期实现出发,建立一套可复现实验和选型方法。

1. 向量索引 Segment 为什么保持不可变

Lance 不会在每次写入时就原地修改 IVF partitions、量化编码和 HNSW 图。索引 metadata 记录每个 segment 的 UUID、创建时对应的 Dataset version 和 fragment bitmap;新数据可以先保持未索引,也可以写成新的 delta segment,最终再通过一次 Dataset transaction 发布新的 segment 集合。

这种设计把长时间运行的索引构建与 manifest 提交分离:读者要么看到旧集合,要么看到提交后的新集合,不会看到一个只写完部分 partitions 的原地索引。但查询成本会随状态变化:

base segment(F0..F99)
  + append F100..F109       -> Flat fallback for uncovered fragments
  + optimize append         -> base segment + delta segment
  + more delta segments     -> query fan-out grows
  + merge / retrain         -> publish replacement segment(s)

optimize_indices 的实现先按 logical index name 收集 segments,为每组构建新 segment,然后在一个 CreateIndex operation 中同时提交 new_indicesremoved_indices。完整控制流见 DatasetIndexExt::optimize_indices

for segments in group_by_logical_name(indices) {
    let Some(merged) = merge_indices(dataset, segments, options).await? else {
        continue;
    };
    new_indices.push(merged.new_metadata());
    removed_indices.extend(merged.removed_indices());
}

commit(CreateIndex {
    new_indices,
    removed_indices,
}).await?;

这是维护时的首要不变量:先生成完整的新索引文件,再原子替换 manifest 中的引用

2. 四种数据操作分别怎样改变索引状态

Append:新增未覆盖 fragments

append 产生新 fragments,但旧 segment 的 fragment bitmap 不会变化。普通查询使用 Flat fallback 扫描这部分数据,因而覆盖仍完整;未索引行持续增长时,查询会越来越像“ANN + 一段全量扫描”。fast_search=True 则主动跳过这部分数据,换取 indexed subset 上的低延迟。

Update:删除旧行,再把新值写到新 fragments

v10.0.0 的 update 是 rewrite rows。执行层扫描目标行并写出新 fragments,然后对原地址应用 deletion;提交注释明确写着 updated rows are moved,即 deleted and appended。源码见 UpdateJob::execute_implcommit_impl

因此,更新向量后会同时出现两个状态:

  1. 旧 segment 中仍有旧 row address,但 deletion mask 会阻止它被返回;
  2. 新向量位于新 fragment,在索引追上前由 Flat fallback 搜索。

这保证的是当前版本语义,不代表 update 会增量修改原 HNSW 图或原 PQ codes。

Delete:逻辑删除先于索引物理清理

delete 同样不会逐条改写不可变索引。查询创建 DatasetPreFilter,为索引覆盖的 fragments 加载 deletion files 或 stable row-id allow list,并把删除行排除在 ANN 结果之外。源码见 DatasetPreFiltercreate_deletion_mask_impl

删除比例升高时,即使结果正确,索引仍可能读取和遍历大量最终会被 mask 掉的条目。compaction 可以物理移除删除行,索引维护再处理新的覆盖状态。

Compaction:重写文件,因此必须处理行身份

compact_files 会移除 deleted rows、移除已 drop 的列并合并小 fragments,见 compact_files。它不改变向量本身,却可能改变 fragment id 与物理 row address。

v10.0.0 有三条边界清晰的路径:

  • address-style row ids 且没有 defer 时,提交 compaction 前立即生成地址映射,并只 remap 覆盖受影响 fragments 的 indices;
  • stable row ids 不依赖物理地址重写索引条目,但提交仍要更新 fragment 覆盖信息;
  • defer_index_remap=True 时,先写 Fragment Reuse Index 记录旧地址到新地址的关系,后续再让 index remap 追上。

相关实现见 commit_compactionDatasetIndexRemapper::remap_indices。remap 修正的是 row identity 和 fragment coverage,不会重新训练 IVF centroids、PQ codebook 或 HNSW 图结构。

Lance 向量索引维护状态图

可编辑图源:vector-index-operations.excalidraw

3. 先观察覆盖与 Segment,再决定维护动作

v10.0.0 的首选元数据入口是 describe_indices()。它返回 logical index 的字段、已索引行数、segments 和总字节数;每个 segment 还包含 UUID、fragment ids、index version 与 size_bytes。结构定义见 IndexDescriptionIndexSegmentDescription

desc = next(d for d in dataset.describe_indices() if d.name == "vector_idx")

print(desc.index_type, desc.num_rows_indexed, desc.total_size_bytes)
for segment in desc.segments:
    print(segment.uuid, len(segment.fragment_ids), segment.size_bytes)

stats = dataset.stats.index_stats("vector_idx")
print(stats["num_indexed_rows"], stats["num_unindexed_rows"])
print(stats["num_indices"], stats["num_indexed_rows_per_delta"])

dataset.stats.index_stats() 提供维护视角的 JSON 统计;其 Rust 结果包含 num_indices、每个 delta 的行数、indexed rows 与 unindexed rows,见 index_statistics。旧的 dataset.index_statistics() 在 v10.0.0 仍存在,但 Python 已标记 deprecated,应改用前述两个入口,见 LanceDataset.index_statistics

不要只用“每天跑一次”作为维护策略。至少同时记录:

  • num_unindexed_rows / current_rows:正常查询 Flat fallback 的规模;
  • segment 数与每个 segment 的行数:ANN fan-out 是否持续增加;
  • total_size_bytes 与 segment size:索引存储和缓存工作集;
  • append 后的 p95 与 Recall@K:新数据分布是否仍适合旧模型;
  • compaction 的 remap backlog:是否长期依赖 Fragment Reuse Index。

触发阈值必须来自业务 SLO 与实测曲线,不应从另一份数据集复制一个固定百分比。

4. optimize_indices 的三个动作不要混用

v10.0.0 的 OptimizeOptions 把维护分为 append、merge 与 retrain,源码见 OptimizeOptions

创建 Delta Segment

dataset.optimize.optimize_indices(
    index_names=["vector_idx"],
    num_indices_to_merge=0,
)

num_indices_to_merge=0 只把当前未索引 fragments 写成新 segment,不合并旧 segments;若没有未索引数据则 no-op。向量路径复用 logical index 最后一个 segment 的模型,使连续 delta 可保持可合并性。实现见 merge_indices_with_unindexed_frags

适合:先快速消除不断变大的 Flat fallback。代价是 segment fan-out 增加。

合并 Delta Segments

dataset.optimize.optimize_indices(
    index_names=["vector_idx"],
    num_indices_to_merge=3,
)

显式 N 会把未索引更新与最新 N 个 segments 合并成一个新 segment。适合:delta 已经积累,查询打开的 segments 过多。合并行为由 merge_indices 驱动。

重新训练

v10.0.0 的 Python docstring 虽然仍列出 deprecated 的 retrain=True,但 native binding 实际只读取 num_indices_to_mergeindex_names;传入 retrain 会被忽略,不能作为可用的 Python 重训入口。这个不一致可直接对照 DatasetOptimizer.optimize_indicesnative binding

因此需要重新训练时,应通过 create_index(..., replace=True) 完整重建同名索引,并在切换前后用固定查询集对照。只有当 Recall@K 已随新分布下降,而放大 nprobes / ef / refine 不能以可接受成本恢复时,才应承担完整重建成本。

这三类操作的顺序通常是:先 append delta 控制 fallback,再按 fan-out 合并;只有数据分布漂移有证据时才重新训练。不要把“覆盖已经追平”和“模型仍适合新分布”视为同一结论。

5. v10.0.0 的分布式构建边界

公开流程必须使用 create_index_uncommitted(...):它只为指定 fragments 创建 segment,并返回提交阶段需要的 Index metadata。普通的 create_index(fragment_ids=...) 包装层会丢弃这个返回值,因此不能串起分布式提交。最小流程如下:

segments = [
    dataset.create_index_uncommitted(
        "vector",
        "IVF_PQ",
        name="vector_idx",
        metric="cosine",
        fragment_ids=worker_fragment_ids,
        # Workers that must share model semantics also receive the same
        # ivf_centroids and pq_codebook here.
    )
    for worker_fragment_ids in fragment_assignments
]

dataset.commit_existing_index_segments(
    "vector_idx",
    "vector",
    segments,
)

各 worker 的 metadata 最后通过 commit_existing_index_segments(...) 一次发布。Python 文档明确支持两种 model scope,见 create_index_uncommittedcommit_existing_index_segments

  1. workers 共享相同 IVF centroids;IVF_PQ 还共享相同 PQ codebook。这样 segments 的模型语义一致,才适合做物理 merge;
  2. workers 各自训练模型。它们可以作为多个 UUID 一起提交,查询会分别解释各自的 partition ids,但不应直接物理合并。

分布式构建还必须固定同一个 Dataset snapshot,并让 fragment assignments 不重叠。全局训练辅助流程可以先准备 IVF/PQ artifacts,再让 workers 加载同一份模型;实现入口见 prepare_global_ivf_pq

这里没有“worker 越多一定越快”的保证。对象存储吞吐、shuffle、训练样本、commit 冲突和最终 segment fan-out 都必须单独测量。

6. 一套可重复的评估方法

固定实验输入

每轮比较都应固定:

  1. 同一个 Dataset version 与相同的非空向量集合;
  2. 一批从真实 workload 抽样并冻结的 query vectors;
  3. 相同 metric、K、过滤条件分布和返回列;
  4. 相同机器、并发度、对象存储位置与 cache 配置;
  5. warm-cache 与 cold-cache 两套独立结果。

Flat 基线必须显式关闭索引。测试 ANN 时,只改变索引类型及其参数,不改变 query set:

def search(dataset, vector, *, use_index, **ann):
    return dataset.scanner(
        columns=[],
        nearest={
            "column": "vector",
            "q": vector,
            "k": K,
            "metric": "cosine",
            "use_index": use_index,
            **ann,
        },
        with_row_id=True,
        prefilter=True,
        filter="tenant_id = 42",
    ).to_table()

truth = [search(dataset, q, use_index=False) for q in queries]
actual = [search(dataset, q, use_index=True, nprobes=16) for q in queries]

def recall(truth_table, actual_table):
    truth_ids = set(truth_table["_rowid"].to_pylist())
    if not truth_ids:
        return None  # Report empty-truth queries separately.
    actual_ids = set(actual_table["_rowid"].to_pylist())
    return len(truth_ids & actual_ids) / len(truth_ids)


per_query_recall = [
    value
    for t, a in zip(truth, actual)
    if (value := recall(t, a)) is not None
]
if not per_query_recall:
    raise ValueError("All filtered Flat truth sets are empty")
recall_at_k = sum(per_query_recall) / len(per_query_recall)

这里的 Recall@K 衡量 ANN 与同一语义下精确 Flat Top-K 的 row-id overlap。过滤后真值可能不足 K 条,所以分母使用实际 len(truth_ids);空真值 query 要单独计数,不能混入均值。若过滤、metric、Dataset version 或 K 不一致,这个数字没有可比性。

同时记录五组结果

指标记录方式
Recall@Koverlap / 实际非空 Flat truth 条数;空真值单独统计,再报告均值和低分位
查询延迟足够多次重复后的 p50、p95;cold/warm 分开
构建与维护时间初次 create、append delta、merge 分开计时
索引字节describe_indices().total_size_bytes,并保留每个 segment size
内存 / cache 工作集进程 peak RSS、index cache 配置、对象存储读取量或 cache hit 指标

此外要在 append、update、delete、compaction 之后重复 correctness suite。稳定态上的高 Recall 不能证明生命周期中的覆盖与 deletion mask 正确。

7. 只比较 v10.0.0 实际支持的索引类型

Python 在 v10.0.0 接受的向量索引类型只有七种,验证列表见 valid_index_types。Flat 是精确扫描基线,不是一个持久化 vector index type。

候选主要近似来源适合首先验证的场景主要代价 / 风险
Flat小数据、正确性基线、低频查询扫描成本随行数增长
IVF_FLATIVF 未探测 partitions原始向量可接受、希望隔离量化误差索引字节与向量读取较大
IVF_PQIVF + PQ 编码距离存储、内存或 I/O 受限的大规模向量需要训练 codebook,通常要测 refine
IVF_SQIVF + scalar quantization希望简单压缩且保留逐维结构仍需实测量化召回与原始向量精排成本
IVF_RQIVF + RabitQ 近似需要紧凑编码并愿意以实验验证召回不能套用 PQ 参数经验
IVF_HNSW_FLATIVF + partition 内 HNSWpartition 内线性扫描成为瓶颈图的字节、cache 与 ef 调参成本
IVF_HNSW_PQIVF + HNSW + PQ同时需要图搜索与压缩参数耦合最多,构建和诊断复杂
IVF_HNSW_SQIVF + HNSW + scalar quantization想要 HNSW 与较简单的定长量化组合仍要分别评估 nprobes、ef 与量化误差

“近似层更多”不等于“延迟一定更低”。当候选数很少、cache miss 严重或过滤已经把集合缩小后,复杂索引可能没有优势。必须让表中每一行跑同一套 workload,再画出 Recall@K 与 p95、index bytes 的 Pareto frontier。

8. 从场景而不是索引名称做决策

场景首批候选必须守住的边界
数据量小或查询低频Flat、IVF_FLAT先证明索引收益高于维护成本;IVF_FLAT 仍受 probes 近似影响
要求全局精确Flat不跳过 partitions;把 Flat 结果作为 correctness contract
存储 / 内存 / I/O 是主要瓶颈IVF_PQ、IVF_SQ、IVF_RQRecall@K、refine 原始向量 I/O
partition 内搜索成为主要 p95三种 IVF_HNSW 变体HNSW cache、ef 与构建成本
高频 append,但新数据分布稳定现有类型 + delta append / mergeunindexed ratio 与 segment fan-out
新数据出现明显概念漂移当前索引和 retrained rebuild 对照先用固定 query set 证明召回下降,不凭数据量猜测
高选择性 tenant / category 过滤Scalar prefilter + 各向量候选Scalar Index 覆盖、返回行数、过滤后的 Recall@K
强 read-after-append正常 search + 及时 optimize不用 fast_search 掩盖未索引覆盖

最终选型不是“选择一个索引类型”,而是确定一份可操作契约:索引类型与构建参数、查询 nprobes / ef / refine、prefilter 策略、允许的 unindexed ratio、delta 合并策略、重训证据和回滚基线。

9. 维护闭环

一套最小但完整的生产闭环可以写成:

Observe
  -> correctness suite still passes?
  -> Recall@K still meets target?
  -> p95 / unindexed rows / segments / bytes within budget?

Act
  -> append delta to remove Flat fallback
  -> merge segments to reduce fan-out
  -> retrain or rebuild only after distribution-drift evidence

Verify
  -> rerun Flat comparison and lifecycle cases
  -> publish new index metadata atomically

正确性是不能交换的底线;Recall 是近似搜索的质量目标;速度是同一正确性和召回约束下的优化结果。把这三者分开,再把 append、update、delete 与 compaction 放进同一套持续实验,Lance 向量索引才从一次性构建参数变成可维护的工程系统。


Share this post:
X