1. 从标题拆解:为什么“数据检索选型”成了绕不开的坎
“Lance 数据检索怎么选,当然阿里云 Milvus 向量湖”——这个标题第一次看到的时候,我脑子里冒出来的第一个念头是:又是一个把“向量检索”和“数据湖”两个概念揉在一起的方案。但仔细琢磨了一下,它其实点出了一个很现实的问题:当你的数据量从百万级涨到十亿级,当你的检索需求从“搜个关键词”变成“以图搜图、以文搜视频、多模态混合查询”,原来那套基于倒排索引或者简单向量库的方案,基本上就撑不住了。
我自己在过往的项目里踩过这个坑。最早做推荐系统的时候,用单机版的向量检索库,几百万条数据跑得挺欢,延迟也就几十毫秒。后来业务方说要把用户行为日志、商品图文特征、短视频封面全部接进来做统一检索,数据量直接翻了两个数量级,查询模式也从单一向量变成了“向量+标量过滤+聚合分析”的混合负载。那时候才发现,选型这件事不是“哪个快选哪个”,而是“哪个能让你在数据涨十倍的时候不用重写架构”。
这个标题里提到的Lance和Milvus 向量湖,其实是两个层面的东西。Lance 是一种面向多模态数据的列式存储格式,主打的是“随机访问效率高、支持向量和标量混合存储”;而 Milvus 向量湖,则是把向量检索能力嵌入到数据湖架构里,让你不用把数据搬来搬去就能做相似性搜索。两者结合的核心逻辑是:用 Lance 管数据,用 Milvus 管索引和检索,形成一个既能存又能查的闭环。
这篇文章适合谁看?如果你正在做推荐系统、多模态搜索、RAG 应用,或者任何需要处理“海量数据+高维向量+复杂过滤条件”的场景,那这篇内容应该能帮你少走一些弯路。我会从架构选型的逻辑讲起,然后拆解 Lance 和 Milvus 向量湖各自解决什么问题,再给出一套可复现的实操方案,最后把我在实际部署中遇到的坑和排查技巧整理出来。不堆概念,只说人话。
2. 核心架构选型:为什么不是“随便找个向量库”就行
2.1 传统向量检索方案的三个天花板
先说说为什么很多人一开始会选错。最常见的做法是:拿一个开源的向量检索库,比如 Faiss 或者 HNSWlib,把特征向量灌进去,建个索引,然后对外提供一个查询接口。这套方案在数据量小、查询模式单一的时候确实好用,代码量少,部署也简单。但它有三个绕不过去的天花板。
第一个天花板是存储与计算耦合太紧。传统向量库通常要求你把向量数据放在它自己的存储格式里,标量字段要么单独存数据库,要么以 payload 的形式挂在向量旁边。一旦你需要做“先按时间范围过滤,再按品类过滤,最后做向量相似度排序”这种复合查询,性能就会断崖式下跌。因为向量索引本身不擅长处理标量过滤,它只能先召回一大批候选,再在内存里做过滤,数据量大了之后内存根本扛不住。
第二个天花板是数据更新代价高。很多向量索引是静态构建的,新增数据需要重建索引或者做增量合并。如果你的业务是实时写入的,比如用户每刷一次视频就产生一条行为向量,那索引的更新频率会非常高,后台合并线程一直跑满,查询延迟也跟着抖动。
第三个天花板是多模态数据管理混乱。文本、图像、视频、音频的特征向量维度不同、更新频率不同、生命周期也不同。传统方案往往要求你把所有向量统一到一个索引里,结果就是要么维度对齐时丢失信息,要么查询时被迫扫描大量无关数据。
2.2 Lance 格式的定位:不只是“另一种列式存储”
Lance 这个格式,很多人第一眼看到会以为它是 Parquet 的替代品。确实,它也是列式存储,也支持压缩和编码,但它的设计目标跟 Parquet 有本质区别。Parquet 是为“全列扫描”优化的,适合做批量分析;而 Lance 是为“随机访问”优化的,适合做点查和向量检索。
具体来说,Lance 在以下几个方面做了针对性设计。第一,它支持向量列的 native 存储,不需要你把向量序列化成二进制再塞进一个 blob 字段。这意味着你可以直接在 Lance 文件上建向量索引,而不需要把数据导出到另一个系统。第二,它的随机读取延迟很低,官方 benchmark 里提到在千万级行数下,随机读取单行的延迟可以控制在毫秒级,这得益于它的页式存储布局和轻量级索引结构。第三,它天然支持 schema evolution,你可以随时新增一个向量列或者标量列,而不需要重写整个数据集。
我自己的理解是:Lance 把“数据湖的灵活 schema”和“向量数据库的检索友好”这两个特性捏在了一起。你可以在一个 Lance 数据集里同时存用户 ID、时间戳、品类标签、图像特征向量、文本嵌入向量,然后针对不同的列建不同的索引。查询的时候,先通过标量索引快速缩小范围,再在候选集上做向量相似度计算,这个流程比“全量向量召回再过滤”要高效得多。
2.3 Milvus 向量湖的角色:索引与检索的分离
Milvus 本身是一个成熟的向量数据库,支持多种索引类型(IVF、HNSW、DiskANN 等),也支持标量过滤和混合查询。但“Milvus 向量湖”这个概念,重点不在于 Milvus 本身,而在于它跟数据湖存储的集成方式。
传统的 Milvus 部署模式是:数据存在 Milvus 内部,索引也在 Milvus 内部,计算和存储是一体的。这种模式在数据量可控的时候没问题,但当你的数据量达到十亿级甚至百亿级,存储成本就会变得很高,而且扩容时需要做数据迁移,运维复杂度直线上升。
向量湖的思路是:原始数据和向量索引都放在对象存储上,Milvus 只负责索引构建和查询计算。查询的时候,Milvus 从对象存储拉取需要的索引分片和向量数据,在内存里做计算,然后把结果返回。这样一来,存储成本降下来了,扩容也变得简单——加节点就行,不需要搬数据。
Lance 在这里扮演的角色是“数据组织层”。Milvus 向量湖可以读取 Lance 格式的数据集,利用 Lance 的随机访问能力快速定位到需要的行,然后加载对应的向量做计算。这种分工的好处是:Lance 负责高效存储和过滤,Milvus 负责高效索引和检索,各司其职。
2.4 选型对比:什么场景该用这套组合
不是所有场景都适合上 Lance + Milvus 向量湖。我整理了一个简单的对比表,帮你快速判断。
| 场景特征 | 传统向量库 | Lance + Milvus 向量湖 |
|---|---|---|
| 数据量 | 百万级以下 | 千万级到百亿级 |
| 查询模式 | 单一向量检索 | 向量+标量混合过滤 |
| 数据更新频率 | 低 | 高,支持实时写入 |
| 多模态支持 | 需要额外拼接 | 原生支持多列向量 |
| 存储成本敏感度 | 一般 | 高,对象存储更便宜 |
| 运维复杂度 | 低 | 中,需要理解湖架构 |
如果你的数据量在千万级以下,查询模式也比较单一,那用传统向量库就够了,没必要上这套组合。但如果你已经开始遇到“查询延迟随数据量线性增长”“标量过滤拖慢向量检索”“存储成本涨得比业务还快”这些问题,那就值得认真考虑 Lance + Milvus 向量湖的方案。
3. Lance 数据组织与索引构建的实操细节
3.1 数据写入:如何设计 schema 才能兼顾检索和过滤
Lance 的 schema 设计直接决定了后续查询的性能。我踩过的第一个坑就是:一开始把所有标量字段都塞进一个 JSON 列里,结果过滤的时候只能全表扫描,性能惨不忍睹。后来改成“常用过滤字段单独成列,冷门字段放 JSON”,查询效率才提上来。
一个典型的 Lance schema 长这样:
import lance import pyarrow as pa schema = pa.schema([ pa.field("id", pa.int64()), pa.field("timestamp", pa.timestamp("us")), pa.field("category", pa.string()), pa.field("price", pa.float32()), pa.field("image_vector", pa.list_(pa.float32(), 512)), pa.field("text_vector", pa.list_(pa.float32(), 768)), pa.field("metadata", pa.string()), # JSON 字符串,存冷门字段 ])这里有几个设计要点。第一,向量列要明确指定维度,这样 Lance 在存储的时候可以做对齐和压缩优化。第二,时间戳字段用 timestamp 类型,而不是字符串,这样范围过滤的时候可以直接做数值比较。第三,品类字段用 string 而不是 int,虽然 int 更省空间,但 string 在可读性和调试友好度上更好,而且 Lance 对 string 列的过滤做了字典编码优化,性能差距不大。
写入的时候,建议按时间分区,每个分区一个 Lance 数据集。这样做的好处是:查询的时候可以按时间范围裁剪分区,减少扫描量;同时增量写入只需要追加新分区,不需要重写历史数据。
# 按天分区写入 import datetime def write_partition(data, date): uri = f"s3://my-bucket/lance/data_{date.strftime('%Y%m%d')}" lance.write_dataset(data, uri, schema=schema, mode="append")注意:Lance 的 append 模式不会自动合并小文件,如果每天写入的数据量很小,建议攒几天再写一次,或者定期做 compaction,否则小文件过多会影响查询性能。
3.2 索引构建:什么时候建、建什么类型
Lance 支持在向量列上建 IVF_PQ 索引,也支持在标量列上建 BTree 索引。索引不是越多越好,因为每个索引都会增加写入时的开销和存储占用。我的经验是:只给高频过滤字段和向量列建索引,低频字段靠扫描就行。
向量索引的构建参数需要根据数据量和查询延迟要求来调。以 IVF_PQ 为例,核心参数是num_partitions和num_sub_vectors。num_partitions决定了倒排列表的数量,一般设置为sqrt(行数)左右;num_sub_vectors决定了 PQ 编码的子空间数量,一般设置为向量维度的 1/4 到 1/8。
# 构建 IVF_PQ 索引 dataset = lance.dataset("s3://my-bucket/lance/data_20240101") dataset.create_index( column="image_vector", index_type="IVF_PQ", num_partitions=256, num_sub_vectors=64, )标量索引的构建就简单很多,直接指定列名和索引类型即可:
dataset.create_index(column="category", index_type="BTREE") dataset.create_index(column="timestamp", index_type="BTREE")实操心得:索引构建是计算密集型操作,建议在单独的节点上跑,不要跟查询服务混部。另外,构建索引的时候会占用大量内存,如果数据量很大,可以分片构建再合并。
3.3 数据更新与版本管理:怎么做到“写入不影响查询”
Lance 支持 ACID 事务和版本管理,这是它比 Parquet 强的地方。每次写入都会生成一个新版本,查询的时候可以指定版本号,也可以默认查最新版本。这意味着你可以在写入的同时保持查询服务不中断。
但这里有一个坑:频繁的小批量写入会产生大量版本,导致元数据膨胀。Lance 的元数据是存在 manifest 文件里的,版本越多,manifest 越大,查询时加载元数据的时间就越长。我的做法是:实时写入走“小文件追加”,然后每隔一段时间做一次 compaction,把小文件合并成大文件,同时清理旧版本。
# 手动触发 compaction dataset.compact()另外,如果你需要做“写入后立即可查”,Lance 的 append 模式是支持的,但查询的时候可能会读到正在写入的分片。为了避免这个问题,可以在写入时加一个“提交标记”,查询时只读已提交的分片。
4. Milvus 向量湖的检索实现与性能调优
4.1 查询流程拆解:从 SQL 到向量相似度
Milvus 向量湖的查询流程跟传统 Milvus 有一个关键区别:数据不在 Milvus 内部,而是在 Lance 数据集里。所以查询的时候,Milvus 需要先跟 Lance 交互,拿到候选数据,再做向量计算。
具体流程是这样的:用户发起一个混合查询,比如“找过去 7 天内、品类为‘数码’、与目标图像最相似的前 100 条记录”。Milvus 首先解析查询条件,把标量过滤部分下推给 Lance,Lance 利用 BTree 索引快速定位到满足条件的行,返回行 ID 列表和对应的向量数据。然后 Milvus 在内存里对这些向量做相似度计算,排序后返回 Top-K 结果。
这个流程的关键在于过滤下推。如果 Milvus 不支持把标量过滤下推到 Lance,而是先把所有向量召回再过滤,那性能会差很多。所以选型的时候一定要确认:Milvus 向量湖是否支持谓词下推,以及下推的粒度有多细。
4.2 索引类型选择:IVF、HNSW 还是 DiskANN
Milvus 支持多种向量索引类型,每种都有适用的场景。我在实际项目里主要用这三种:
IVF 系列适合数据量大、查询延迟要求不极端的场景。它的原理是把向量空间划分成多个簇,查询时只扫描最近的几个簇。优点是内存占用小,构建速度快;缺点是召回率受簇数量影响,需要调参。
HNSW适合查询延迟要求高、数据量在千万级以内的场景。它是基于图结构的索引,查询时在图上做贪心搜索,延迟可以做到毫秒级。缺点是内存占用大,因为要存整个图结构;而且构建时间随数据量增长很快。
DiskANN适合数据量超大、内存放不下的场景。它把索引存在 SSD 上,查询时从磁盘加载,用少量内存做缓存。优点是存储成本低,缺点是延迟比内存索引高一个数量级。
| 索引类型 | 适用数据量 | 查询延迟 | 内存占用 | 构建速度 |
|---|---|---|---|---|
| IVF_PQ | 千万到百亿 | 10-50ms | 低 | 快 |
| HNSW | 百万到千万 | 1-10ms | 高 | 慢 |
| DiskANN | 十亿以上 | 50-200ms | 极低 | 中等 |
我的建议是:如果数据量在千万级以内,优先用 HNSW;如果数据量在千万到十亿之间,用 IVF_PQ;如果超过十亿,考虑 DiskANN 或者 IVF_PQ 加量化。
4.3 混合查询的写法:标量过滤和向量检索怎么组合
Milvus 的混合查询语法跟 SQL 有点像,但有一些特有的函数。一个典型的查询长这样:
from pymilvus import Collection collection = Collection("my_collection") results = collection.search( data=[query_vector], anns_field="image_vector", param={"metric_type": "IP", "params": {"nprobe": 16}}, limit=100, expr="timestamp >= 1704067200 and category == '数码'", output_fields=["id", "timestamp", "category"], )这里的expr就是标量过滤条件,Milvus 会把它下推给 Lance 执行。nprobe是 IVF 索引的查询参数,控制扫描的簇数量,值越大召回率越高但延迟也越高。
注意:
expr里支持的运算符有限,复杂的逻辑组合可能需要拆成多次查询再合并。另外,如果过滤条件的选择性很高(比如只命中几百行),那向量检索的收益就不大,直接走标量索引可能更快。
4.4 性能调优:从 100ms 降到 10ms 的实操记录
我在一个项目里遇到过查询延迟从 20ms 涨到 200ms 的问题。排查下来发现是几个原因叠加:一是 Lance 数据集的小文件太多,元数据加载慢;二是 Milvus 的nprobe设置过大,扫描了太多簇;三是标量过滤条件没有走索引,变成了全表扫描。
优化过程分三步。第一步,做 Lance compaction,把小文件合并成大文件,元数据加载时间从 80ms 降到 5ms。第二步,调整nprobe,从 64 降到 16,召回率只掉了 0.5%,但延迟降了 40ms。第三步,给过滤字段加 BTree 索引,标量过滤时间从 100ms 降到 2ms。
最终查询延迟稳定在 10-15ms,P99 在 30ms 左右。这个优化过程让我意识到:向量检索的性能瓶颈往往不在向量计算本身,而在数据组织和过滤环节。
5. 常见问题与排查技巧实录
5.1 查询延迟突然飙升,怎么快速定位
延迟飙升是最常见的问题,排查思路可以按这个顺序来:
- 看 Lance 元数据加载时间。如果数据集版本太多或者小文件太多,元数据加载会变慢。用
dataset.version查看版本数,如果超过 100,建议做 compaction。 - 看 Milvus 的查询计划。Milvus 提供了 explain 接口,可以看到查询是否走了索引、扫描了多少行、过滤下推是否生效。
- 看系统资源。CPU 打满可能是向量计算太密集,内存打满可能是索引太大,磁盘 IO 高可能是 Lance 读取太频繁。
我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 延迟从 10ms 涨到 100ms | 小文件过多 | 查看数据集文件数 | 执行 compaction |
| 延迟波动大 | 索引未加载完 | 查看 Milvus 日志 | 预热索引 |
| 过滤后延迟反而更高 | 过滤条件未下推 | explain 查询计划 | 检查 expr 写法 |
| 写入后查询变慢 | 版本膨胀 | 查看版本数 | 清理旧版本 |
5.2 召回率不达标,是索引问题还是数据问题
召回率低的时候,先别急着调索引参数。我的经验是:先确认数据本身有没有问题。比如向量是否归一化、是否有大量零向量、特征提取模型是否一致。这些问题不解决,调索引参数都是白搭。
如果数据没问题,再检查索引参数。IVF 索引的nprobe太小会导致召回率低,可以逐步增大直到召回率达标。HNSW 的ef参数也有类似效果。另外,如果用了 PQ 量化,量化误差也会影响召回率,可以尝试减小num_sub_vectors或者改用 SQ 量化。
实操心得:召回率评估一定要用真实的查询集,不要用随机向量。随机向量的分布跟真实数据差别很大,评估结果没有参考价值。
5.3 数据一致性怎么保证:写入后多久可查
Lance 的写入是原子性的,写入完成后立即可查。但 Milvus 的索引更新是异步的,新写入的数据可能需要等几秒到几十秒才能被检索到。如果业务要求“写入后立即可查”,有两个方案:一是查询时同时扫描 Lance 的最新分片和 Milvus 的索引,合并结果;二是降低 Milvus 的索引刷新间隔,但会增加后台负载。
我一般推荐第一个方案,因为对查询性能的影响可控,而且不会增加写入压力。具体实现是在查询层加一个“补漏”逻辑:先查 Milvus 索引,再查 Lance 最新分片,去重后返回。
5.4 成本控制:存储和计算怎么平衡
向量湖方案的一个主要卖点是存储成本低,因为原始数据存在对象存储上。但计算成本可能会上升,因为查询时需要从对象存储拉数据。如果查询频率很高,对象存储的请求费用和流量费用可能会超过省下来的存储费用。
平衡的方法是:对高频查询的数据做本地缓存。Milvus 支持把热点索引分片缓存在本地 SSD 上,Lance 也支持把热点数据缓存在本地。这样既能享受对象存储的低成本,又能保证高频查询的低延迟。
另外,索引类型的选择也影响成本。HNSW 内存占用大,需要高配机器;IVF_PQ 内存占用小,可以用低配机器。如果成本敏感,优先选 IVF_PQ。
6. 从落地到扩展:这套方案还能怎么用
6.1 多模态检索的扩展思路
Lance + Milvus 向量湖的架构天然适合多模态检索。你可以在一个 Lance 数据集里存多种向量列,比如图像向量、文本向量、音频向量,然后针对每种向量建独立的索引。查询的时候,可以单模态查,也可以多模态融合查。
多模态融合查的实现方式是:分别用每种向量召回一批候选,然后用加权融合或者学习排序的方式合并结果。Milvus 支持多向量字段的查询,但融合逻辑需要自己在应用层实现。
6.2 实时推荐场景的适配
实时推荐场景的特点是:用户行为实时产生,推荐结果要求毫秒级返回。这套方案可以通过以下方式适配:用户行为向量实时写入 Lance 的最新分片,Milvus 索引定期刷新,查询时同时查索引和最新分片。这样既能保证实时性,又能利用索引的检索效率。
6.3 从单集群到多集群的演进
当数据量和查询量继续增长,单集群可能撑不住。这时候可以考虑多集群部署:每个集群负责一部分数据分片,查询时由路由层分发到对应集群,然后合并结果。Lance 的分区特性天然支持这种水平扩展,Milvus 也支持分片查询。
我在实际项目里从单集群扩展到三集群,查询吞吐提升了近三倍,延迟基本没变。关键是要设计好分片策略,避免热点分片。
最后分享一个小技巧:如果你不确定该选哪种索引类型,可以先在小数据集上做 benchmark,用真实的查询负载测试不同索引的延迟和召回率,然后再决定。不要凭感觉选,数据会告诉你答案。