1. 从 Faiss 到 Knowhere先搞懂为什么要多套一层做向量检索的人几乎都绕不开 Faiss。我自己是从图像召回场景入坑的最早用 Faiss 的习惯很粗暴Python 脚本里IndexFlatIP建个索引write_index存盘查询的时候再read_index拉回来。单机脚本这么玩完全没问题可一旦想把它嵌进一个需要横向扩展、支持多副本、还把索引当独立资源来管理的服务引擎里麻烦就成串地冒出来。Milvus 没有选择让引擎直接依赖 Faiss而是在两者之间插了一层叫 Knowhere 的抽象层。这个库是 Milvus 从引擎里抽出来的独立 C 项目仓库在 milvus-io/knowhere它把 Faiss、HNSWlib 以及后续接入的 DiskANN 等后端统一封装成一套接口。Milvus 的 IndexNode构建索引的组件和 QueryNode查询节点只跟 Knowhere 打交道根本不关心底层跑的是哪个库。很多人第一次看 Milvus 代码时会对这层感到困惑明明核心检索能力都是 Faiss 提供的为什么还要包一层包了之后索引文件变成什么样了这篇文章我就从存储结构和查询执行路径两条线把这层封装彻底拆开。1.1 裸用 Faiss 的三个硬伤第一个硬伤是接口分裂。Faiss 里每个索引类的 add、search 签名都不一样IndexIVFFlat要传 nprobeIndexHNSW要传 efSearchIndexPQ又有一套自己的训练流程。参数含义完全不同引擎层如果直接对接就得为每种索引单独写一套调用逻辑代码里全是if (type IVF_FLAT)这种分支。第二个硬伤是序列化格式不统一。Faiss 的write_index确实能把索引落盘但不同索引的磁盘布局差异非常大IndexFlat就是一堆原始向量从头排到尾IndexIVFFlat是聚类中心加 nlist 个倒排链表IndexHNSW又是另一套多层级图结构。而且原始二进制里缺少足够的元信息光看文件头很难判断这里面装的是什么索引、什么度量方式、什么版本。第三个硬伤是后端不可替换。Milvus 后来的版本里磁盘索引选了 DiskANN内存索引可以切 HNSW如果所有代码都写死在 Faiss 上换一个后端等于重写一遍引擎。Knowhere 把这层变化隔离掉了新后端只要实现一套统一的 IndexNode 接口引擎层一行都不用动。1.2 Knowhere 的核心抽象IndexNode 和 BinarySetKnowhere 最核心的抽象是IndexNode。所有索引类型都继承这个接口必须实现几个关键方法Train用样本数据训练索引比如 IVF 的聚类中心、PQ 的码本Build把训练好的索引和向量数据组装成可查询的结构Query给定查询向量和参数集返回 TopK 结果Serialize/Deserialize把索引转成二进制流或者从二进制流恢复索引。这里要注意Deserialize不是简单的 Faissread_index。Knowhere 序列化时会把索引数据和元信息打包成一个BinarySet本质上是一个 key-value 的二进制集合。每个后端索引往里面写入自己的数据块同时写入版本号、索引类型、维度、度量方式这些元数据。读取时先解析元数据再根据索引类型分发到对应的加载逻辑。你可能会问这不就是给 Faiss 套了个壳吗对但关键在壳里加了什么。Knowhere 在序列化层加了版本兼容机制索引文件里带着 Knowhere 自己的版本标识升级引擎时老索引文件还能识别并转换。在查询层则统一了参数结构所有索引的查询参数都收敛成一个通用的Json配置引擎层传参数不需要关心具体索引类型。2. 向量索引的存储结构磁盘和内存里到底长什么样聊索引不能只看接口得打开文件看布局。搞懂存储结构你才能推断一个查询大概会触发多少 IO、占多少内存、为什么某些索引构建慢、为什么某些索引对内存这么敏感。2.1 先建立整体认知索引文件和原始数据是分离的在 Milvus 里索引不是一个数据库对象而是 segment数据段的一个附属产物。原始向量存在 segment 的 binlog 里索引构建完成后生成一个独立的索引文件存放在对象存储或者本地磁盘上同时在元数据里记录一条索引信息。查询时 QueryNode 把索引文件加载进内存或者映射到磁盘用它替换掉全量暴力扫描。索引文件本身可以理解成为了加速检索而设计的辅助数据结构它不负责存储原始数据的完整副本。比如 IVF 索引里向量是以倒排链表的形式分散存到各个聚类桶里的ID 映射关系也在链表里HNSW 里每个节点的向量和它的邻居列表绑定在一起。所以索引文件的大小通常和原始数据不在一个量级PQ 量化后的索引甚至能比原始数据小一个数量级。2.2 IVF 系列索引的倒排表布局IVF 的全称是 Inverted File Index核心思想是先聚类再检索。以IVF_FLAT为例索引文件在磁盘上大致分三块第一块是训练好的聚类中心也就是一个nlist行、每行维度为 d 的浮点矩阵。这个矩阵承担着粗量化器的角色查询向量来了先算它和每个聚类中心的距离选出最近的 nprobe 个桶再只在这几个桶里精排。第二块是倒排表结构每个桶对应一个链表链表里存的是向量 ID 数组和向量数据数组。第三块是一些辅助信息比如每个桶的长度、索引的总向量数、IDMap。你把这个结构和暴力扫描对比一下就明白了。暴力扫描要拿查询向量和全量向量做距离计算IVF 则是先粗筛、再精排把计算量从 O(N) 降到 O(nprobe × N/nlist)nlist 越大召回粒度越细但构建时间和内存也随之上涨。IVF_PQ在 IVF 的基础上做了第二层压缩。每个桶里存的不是原始浮点向量而是经过乘积量化Product Quantization后的编码。PQ 的基本思路是把 d 维向量切成 m 段每段单独做 k means得到 m 组码本最终每个向量只需要 m 个码字索引就能表示。这样一来倒排链表里存的是极紧凑的编码代价是精度损失适合内存紧张、对召回率要求不那么苛刻的场景。2.3 HNSW 的分层图结构HNSW 和 IVF 是完全不同的思路。IVF 是先聚类再扫桶HNSW 是建一张多层图检索时从粗到细地走图。它的存储结构有三个关键部分第一节点向量数组。每个向量就是图里的一个节点节点 ID 和向量数据按序存放。第二多层邻居表。每个节点在不同层级上有不同的邻居列表高层邻居少、底层邻居多。第三入口点信息包括最高层编号和入口节点 ID。HNSW 的检索过程你可以理解成先坐电梯下楼再逐层徒步。从入口点开始在当前层做贪心搜索找离查询向量最近的节点然后往下层走每一层都扩大搜索范围最终在最底层用 efSearch 控制大小的候选堆做精细搜索。图结构的好处是检索路径短内存索引里它的召回率和延迟通常优于 IVF坏处是构建和插入的成本高而且对内存带宽要求高。2.4 Knowhere 的 BinarySet 和索引元数据有了上面的基础再看 Knowhere 对索引文件的封装就清晰了。IndexNode::Serialize会把 Faiss 索引的二进制内容拆成多个数据块写进BinarySet比如 IVF 索引会写聚类中心块、倒排表块、IDMap 块同时写一个专门的元数据块记录 Knowhere 版本号、索引名、度量类型、维度这些信息。实际查看 Milvus 的索引文件时你通常看到的是一堆.bin或.idx文件外加一个meta.json。meta 文件里存的就是这个索引的类型、参数、构建时间等信息。Knowhere 的 Load 流程先读 meta再根据 meta 里的索引类型选择对应的构造函数从 BinarySet 里把各数据块还原出来。这套设计让索引文件的兼容性得到了控制——版本变了至少能通过版本号判断能不能加载而不是直接崩在内存里。3. 查询执行模型一条 search 请求从进来到返回的全过程存储结构是静态的真正有意思的是查询时这些结构怎么被用起来。一条向量检索请求从客户端发出到拿到 TopK 结果中间要经过 Proxy、QueryCoord、QueryNode 好几个角色我按链路挨个拆。3.1 请求链路Proxy 到 QueryCoord 再到 QueryNodeMilvus 的查询入口是 Proxy 节点。客户端通过 SDK 发来SearchRequest带上了集合名、查询向量、topk、分区过滤条件、搜索参数比如 nprobe 或者 efSearch。Proxy 做的事情是把请求转成内部消息送往 QueryCoord由 QueryCoord 决定分发给哪些 QueryNode。QueryCoord 维护着整个集群里每个 QueryNode 上加载了哪些 segment、每个 segment 的副本分布在哪。它接到请求后找出所有满足分区条件的已加载 segment把查询请求广播到对应的 QueryNode 上去。这一步是分布式的核心一个查询不是只在一台机器上跑而是多台机器上的多个 segment 同时开始各自的局部搜索。3.2 Segment 内的搜索Knowhere 是怎么被调起来的QueryNode 收到子查询后会对每个 segment 执行本地搜索。每个 segment 维护着一张本地索引索引在 segment 加载时已经通过 Knowhere 的IndexNode加载完毕。这里的执行单元是SegmentIterator 按 segment 粒度搜索返回每个 segment 的局部 TopK。具体到 Knowhere 内部Query方法拿到的是查询向量的原始 float 数据、查询参数集和 topk。它根据索引类型分发到对应的实现IVF 走IndexIVF封装HNSW 走IndexHNSW封装暴力场景走平坦索引。这里有一个容易忽略的细节Milvus 的查询基本是按批处理的一次 search 请求可能带多条查询向量Knowhere 会尽量复用索引结构做批量搜索比如 IVF 里多个 query 共用同一个粗量化结果避免重复计算。3.3 nprobe 和 efSearch 到底在控制什么这两个参数是所有用 Milvus 的人都要面对的理解它们就是在理解查询执行模型。对于 IVF 系列索引nprobe 控制的是粗筛阶段打开多少个聚类桶。nprobe1 时只在最近的 1 个桶里搜速度快但可能漏掉真正近邻所在的桶nprobe 越大覆盖面越广召回率越高延迟也越高。实际调参时你可以把 nprobe 看成召回率旋钮它和 nlist 是配套的一般经验是 nprobe 取 nlist 的 1%~10% 起步。对于 HNSWefSearch 控制的是底层搜索时维护的候选堆大小。堆越大搜索路径上保留的候选节点越多结果越接近全量暴力搜索的召回率但内存访问量和耗时也越高。注意 efSearch 只影响查询阶段不影响索引构建阶段构建时的 efConstruction 才负责建图质量。3.4 多 Segment 结果合并与全局 TopK每个 QueryNode 上每个 segment 都返回了各自的局部 TopK接下来要做归并。这个归并发生在两个层级QueryNode 内部把多个 segment 的结果汇总成该节点上的 TopKQueryCoord 或者 Proxy 再把多个 QueryNode 的结果做一次全局归并最终返回全局 TopK 给客户端。归并逻辑本身不复杂核心是一个容量为 topk 的小顶堆逐个插入各局部结果淘汰最差的。真正要留意的是结果里的距离值口径。不同索引类型、不同度量方式算出来的分数含义不同L2 是越小越好IP 和 COSINE 是越大越好如果客户端没有做归一化展示出来的分数会让人误判。另外在归并阶段还要处理预过滤和表达式过滤的下推结果——标量过滤条件在 segment 内先执行把命中的向量子集传给搜索这样归并时不会混入不该出现的结果。4. 源码走读从 Faiss 到 Knowhere 的关键实现这节我们进源码。我不会贴大段代码而是把关键路径抽出来讲清楚你可以对照 Faiss 和 Knowhere 的开源仓库去看。4.1 Faiss IVF 搜索源码的关键路径Faiss 的IndexIVFFlat::search流程大致是四步。第一步把查询向量交给quantizer做粗量化也就是调用一次针对聚类中心的暴力搜索得到 nprobe 个最近的桶 ID。第二步遍历这 nprobe 个桶对应的InvertedLists拿到每个桶内的向量 ID 数组和向量数据。第三步对桶内向量计算精确距离把结果累加进一个容量为 topk 的堆结构里。第四步输出时做一次堆排序返回排序后的 ID 和距离。这里有个值得注意的实现细节Faiss 的桶内距离计算大量使用了 SIMD 指令尤其是fvec_L2sqr和fvec_inner_product这些底层函数会按 4 个 float 一组做向量化。所以在实际部署中CPU 的 AVX2 支持情况直接影响 IVF 的查询性能。你如果发现同样的参数下查询耗时差异很大先看看是不是跑在没开 AVX2 的容器里。4.2 HNSW 搜索源码的关键路径HNSW 的搜索核心在IndexHNSW::search里。入口点确定后算法维护两个结构一个优先队列candidates用来存待扩展的节点一个集合visited用来去重。从最高层开始每一层都做同样的操作弹出当前最近节点遍历它的邻居如果邻居比当前最优更近就继续扩展。当当前层无法再改进时下降到下一层重复直到最底层。最底层的搜索和上层不同它不追求尽快收敛到最近点而是尽可能多地探索候选路径把 efSearch 控制大小的堆塞满。这个阶段是 HNSW 查询的主要开销来源因为底层节点的邻居列表最长内存随机访问也最密集。所以 HNSW 查询性能对内存延迟极其敏感同一份索引放在不同内存通道数的机器上性能差距可能超过 30%。4.3 Knowhere 在 Faiss 之上到底加了什么直接看 Knowhere 源码你会发现在IndexIVF和IndexHNSW这两个包装类里大量代码是在处理配置解析和数据搬移。第一个加的是配置解析。Knowhere 把 Faiss 的各种原生参数包成了一个统一的Config结构从Json里读参数做类型检查、范围检查再转换成 Faiss 需要的结构。比如你传nprobe: 32它会检查 nprobe 不能超过 nlist不能为负数然后才设置到 Faiss 的SearchParameters里。第二个加的是索引数据的生命周期管理。Faiss 索引对象本身没有文件系统概念Knowhere 则负责把 Faiss 内存对象和磁盘文件进行同步。构建时它从 Milvus 的 chunk 数据里取向量灌进 Faiss 索引落盘时它把 Faiss 的二进制产物加上自己的元数据写成 BinarySet加载时它从 BinarySet 反序列化并构造 Faiss 索引。你甚至可以理解为Knowhere 把 Faiss 变成了一个可持久化的插件。第三个加的是度量方式的归一化处理。Faiss 原生支持 L2 和 IPMilvus 的 COSINE 距离通常是通过在输入阶段对向量做归一化转换成 IP 计算来实现的。Knowhere 在调用 Faiss 前会检查 metric 类型如果是 COSINE 且向量还没归一化会做一次原地归一化。这也是为什么你用 Milvus 查 COSINE 时结果分数往往和直接用原始向量做余弦计算的略有差异。5. 实操Mac 上用 Docker 跑 Milvus Standalone 并验证索引源码看再多不如本地跑一次。下面这套操作我在 Mac 上实测过多次步骤可以直接抄。5.1 单机版安装与资源准备Milvus 有 Standalone 和 Distributed 两种部署方式本地验证用 Standalone 就够了。Standalone 模式下所有组件Proxy、QueryNode、DataNode、IndexNode 等都打包在一个进程里由一个 etcd 实例提供元数据服务对象存储用的是本地文件系统。我用的启动方式是官方镜像直接跑docker pull milvusdb/milvus:v2.4.15 docker run -d --name milvus-standalone \ -p 19530:19530 \ -p 9091:9091 \ -v $(pwd)/milvus_data:/var/lib/milvus \ milvusdb/milvus:v2.4.15有两点必须提醒。第一Mac 上跑 Milvus 需要 Docker Desktop 给足内存我建议至少分配 6~8GB否则 etcd 和索引构建很容易 OOM。第二注意端口占用19530 是 gRPC 端口9091 是 metrics 端口如果本机有别的服务占了先改掉再启动。启动后用docker logs -f milvus-standalone观察日志出现milvus is ready to serve之类字样就说明起来了。然后装好 Python SDKpip install pymilvus2.4.x5.2 建集合、写数据、创建索引连上服务后我用一段最简代码完成建集合、插入 1000 条随机向量、创建 IVF_FLAT 索引from pymilvus import MilvusClient, DataType client MilvusClient(urihttp://localhost:19530) if client.has_collection(demo_ivf): client.drop_collection(demo_ivf) schema client.create_schema(auto_idTrue) schema.add_field(pk, DataType.INT64, is_primaryTrue) schema.add_field(embedding, DataType.FLOAT_VECTOR, dim128) index_params client.prepare_index_params() index_params.add_index( field_nameembedding, index_typeIVF_FLAT, metric_typeCOSINE, params{nlist: 128} ) client.create_collection( collection_namedemo_ivf, schemaschema, index_paramsindex_params ) import numpy as np data [{embedding: np.random.rand(128).tolist()} for _ in range(1000)] client.insert(demo_ivf, data)注意prepare_index_params里配置的是索引定义创建集合时把它传进去Milvus 会在数据写入后异步构建索引。这里很多人会误解建完索引参数并不代表索引已经构建完成得确认索引状态。5.3 查询验证与索引生效判断查询前先确认索引真的建好了print(client.list_indexes(demo_ivf)) print(client.get_index_state(demo_ivf))get_index_state返回IndexState.Finished才说明索引可用。然后发一条查询向量把search_params里的nprobe设到 8query_vec np.random.rand(128).tolist() res client.search( collection_namedemo_ivf, data[query_vec], limit5, search_params{metric_type: COSINE, params: {nprobe: 8}}, output_fields[pk] ) print(res)返回结果里每个 hit 都有pk和distance。这里我建议你做一个小实验把search_params里的params改成{nprobe: 1}再查一次对比两轮结果的命中集合。如果 nprobe1 的结果和 nprobe8 差很多说明数据分布下聚类桶的区分度不够此时要么调大 nlist要么让数据分布更均匀。这个小实验也是验证索引真正参与查询的好办法。6. 常见问题与避坑记录最后把我在本地和服务器上踩过的坑集中列一下有些问题排查了很久才找到原因。6.1 milvus_uri 指向本地文件时报错的排查很多人会看到这样一段代码client MilvusClient(uri./data/milvus.db)这不是连接 Standalone 服务而是把 pymilvus 切到 Milvus Lite 模式直接用本地文件当存储。这种模式下常见的报错有两类。第一类是路径不存在./data目录没有创建程序直接抛异常解决办法是先mkdir -p data或者改用绝对路径。第二类是和 pymilvus 版本不匹配Milvus Lite 是随 pymilvus 内置的某些精简安装方式没有带上 Lite 依赖会报找不到本地存储引擎的错误。我的建议是本地快速原型可以用uri./milvus.db但只要是稍微认真的项目直接用urihttp://localhost:19530连 Standalone省得后面迁移数据时踩一遍坑。6.2 余弦距离与归一化的坑用 COSINE 度量时的第一个坑是Milvus 的距离分数范围不是 [-1, 1] 的原始余弦值而是归一化后的内积值。原因我在前面讲 Knowhere 封装时提过COSINE 在底层被转换为向量归一化后的 IP 计算。所以你在结果里看到 0.98 这种分数别直接当成余弦相似度 0.98去和别的系统对比。第二个坑是查询向量和入库向量的归一化行为不完全一致。Milvus 在入库和查询时都会做归一化处理但如果你在入库前已经手动归一化过向量再叠加一层归一化虽然不影响相对排序却会影响绝对分数。建议整个流程只依赖 Milvus 的归一化自己不要额外处理。6.3 索引参数选择的几个经验值这部分是纯经验直接给数值。数据量在百万级、维度 128 左右、内存充裕优先选IVF_FLATnlist 取4 × sqrt(N)附近查询时本机验证推荐先试nprobe16。数据量千万级以上、内存吃紧上IVF_PQ或IVF_SQ8但要接受召回率损失m 和 nbits 需要做小规模评测。追求极致查询延迟且对内存延迟有把握选HNSWM 取 16~32efConstruction 取 200~400查询 efSearch 从 64 开始往上调观察召回率到达平台期后停手。还有一点容易被忽略索引参数不是建完就固定了。Milvus 允许你先用默认参数建索引验证全链路后续数据量涨了再重新建索引换参数这个过程不需要重建集合只是索引构建期间查询会退化为暴力扫描或旧索引影响的是查询延迟而不是正确性。个人在实际操作里的体会是向量索引的调优很多时候不是算法问题而是理解数据分布的问题。同样一组参数均匀分布的随机向量和真实业务 embedding 的表现可以差出一个数量级。所以别迷信网上给的默认值先小批量建索引、多跑几组 nprobe 或者 efSearch 对照实验把召回率和延迟的曲线拉出来再定参数这比看一百篇教程都有用。后续如果你想继续深挖可以再沿着 Knowhere 的扩展点去看 DiskANN 磁盘索引的接入方式理解了这一层抽象再看 Milvus 里其他索引类型就都是一样的套路了。