
聊到 Milvus 的向量索引绕不开两个名字Faiss 和 Knowhere。不管你是刚刚跑通milvus standalone模式还是在调整nprobe、efSearch这俩查询参数本质上你都被这一层封装牵着走。今天这篇我想从源码角度把向量索引的存储结构和查询执行模型拆开揉碎——索引文件在磁盘上到底是什么形态、一条 query 向量进入 Milvus 之后走了哪些路径、Faiss 和 Knowhere 各自干了什么活。适合已经会建 collection、会用 Python SDK 做检索但还想更进一步、想搞懂“为什么这个参数影响这么大”的人。我尽量不写教科书式废话直接用我实际拆代码时的观察和踩坑记录来讲。看完之后你再回去看文档里的索引参数至少能明白它背后在调什么。1. 先搞清楚一件事Milvus 为什么非要绕开 Faiss 自己造轮子1.1 Knowhere 到底是什么不是从零造索引而是索引的统一调度层很多人第一次听到 Knowhere 这个名字会以为 Milvus 从零写了一套新的索引算法。实际上不是这样。Knowhere 是一个 C 层的索引适配层内部真正干重活的是 Faiss、HNSWLib、DiskANN 这些底层库。Knowhere 做的事情更像是“翻译官 管理员”它把 Faiss 的IndexIVF、IndexHNSW这些具体实现全部包装成一个统一的Index接口对外暴露Build、Query、Serialize、Deserialize这几个动作。为什么需要这一层因为 Faiss 本身是个研究向的库接口灵活但直接拿来做生产系统会有几个问题。第一Faiss 的索引对象是内存态的没有统一的持久化协议不同版本 dump 出来的二进制可能不兼容第二Milvus 的核心数据单元是 segment一个 segment 对应一个不可变的向量集合索引要跟着 segment 走要能单独存成一个文件也能单独加载到内存第三Milvus 需要支持 CPU、GPU 以及未来的异构硬件而 Faiss 的 GPU 接口和 CPU 接口是分开的。Knowhere 把这些差异全部藏在背后让上层 QueryNode 只需要面向一套简单 API。所以你在 Milvus 的配置里看到的index_type: IVF_FLAT、HNSW实际落地时都会先转成 Knowhere 的内部配置再传给真正的底层引擎。这里的metric_typeIP、L2、COSINE也是 Knowhere 负责翻译的底层 faiss 只认识METRIC_INNER_PRODUCT和METRIC_L2但 Milvus 给你暴露的是更友好的 COSINE。这就是为什么你在文档上能选“余弦值”但翻到底层实现时看到的却是内积计算——本质上 Knowhere 帮你做了向量归一化然后把 COSINE 转成了 Inner Product。1.2 从 Faiss 到 Knowhere 的核心矛盾索引生命周期和动态更新Faiss 在设计时其实没有考虑“不可变 segment”这种存储模型。它更偏向于一个可动态增删的索引对象你可以add_with_ids往同一个 index 里不断加向量也可以remove_ids删掉一些向量。但 Milvus 的 segment 模型不一样一个 segment 一旦 flush就是不可变的增删是通过新 segment 删除位图delete bitmap来体现。查询时要同时读存活 segment 的索引并根据位图过滤掉已删除的向量。这就带来一个关键矛盾Faiss 索引的add和remove本来是为了动态更新设计的但 Milvus 根本不需要在已构建的索引上做原地变更。所以 Knowhere 必须把 Faiss 的这一部分能力做一些限制和固化它只关心“给定一批向量构建一个只读索引”以及“给定一个 query搜索 TopK”。任何动态更新都发生在 Milvus 上层通过合并 segment 来实现而不是直接改索引文件。理解这一点很重要因为很多人在排查问题时会困惑“为什么我一个向量删掉了但查询结果里还能看到它” 其实索引文件本身没变只是查询时没带上删除位图过滤或者 compaction 还没跑完。这不是索引层的问题而是 Milvus 存储引擎的异步机制在起作用。1.3 部署形态和索引存储的对应关系顺便把热搜里的“本地加载milvus_uri: str ./data/milvus.db”这件事说清楚。Milvus 有两种常见的本地玩法一种是完整 standalone 模式通过 docker 把 etcd、minio、querynode 全跑起来索引文件默认放在容器里的/var/lib/milvus下面另一种是更轻量的本地模式pymilvus 支持用一个 SQLite 文件milvus.db来存元数据向量数据和索引则落在同一个本地目录里。我试过在 Mac 上用 docker 安装 standalone然后用默认配置建 HNSW 索引索引文件会被写进对象存储也就是本地 MinIO 里路径长得很奇怪像bucket/collection_id/segment_id/index/...。刚开始找索引文件时容易懵以为没落盘。其实只要用docker exec进容器里find /var/lib/milvus -name *.idx能看到一堆索引文件。在本地模式下你给./data/milvus.db指定的其实只是元数据库真正的向量索引文件也在./data目录下只是名字不一样所以网上很多人说“为什么只有 milvus.db 没有索引文件”就是没搞清楚这个目录结构。2. 向量索引的存储结构磁盘上的字节到底怎么排2.1 Segment 是存储的最小逻辑单元索引只是其中的一个“附件”在 Milvus 里向量数据不是以“一张大表”的形式存索引而是以 segment 为单位。每个 segment 对应一批已经 flush 的向量目录结构里能同时看到原始向量文件如vectors.bin、删除位图、统计信息以及可选的索引文件。索引文件不是必须的。如果你建 collection 的时候没建索引Milvus 查询时就用暴力扫描brute force虽然慢但正确。一旦你执行了create_index后台会启动索引构建任务把原始向量读出来用指定算法建索引最后写成一个独立的索引文件。这个文件在磁盘上的生命周期跟着 segment 走segment 被 compact 合并索引文件会重建或者删除。这种“索引是附件”的设计让我一度很受启发。它意味着索引文件和原始数据可以不同步存在索引炸了可以直接从原始向量重建不需要动数据文件。这也是为什么很多生产事故可以通过“删索引、重新建索引”来恢复而不是整个数据推倒重来。2.2 解剖一个 Faiss 索引的二进制布局以 IVF 为例如果要找一个最典型的索引文件来分析IVF_FLAT 再合适不过。一个 IVF 索引在 Faiss 里的核心对象是IndexIVF它包含三块粗量化器quantizer、倒排列表InvertedLists、以及编码后的向量数据。因为 IVF 的基本思想是“先粗分桶再精确算”所以它必须先有一个聚类好的码本也就是 nlist 个簇中心。这个簇中心本身就是一个小的IndexFlat在序列化文件里会完整保存下来包括中心向量的原始浮点数。faiss::write_index的序列化格式并不是什么私有黑箱它先写一个魔术数字fourcc表示索引类型比如Iw开头是 IVF然后依次写入索引的维度d、向量数量ntotal、以及每种索引特有的字段。对IndexIVF来说后面会跟nlist、code_size、nprobe注意 nprobe 是查询参数但 Faiss 会把默认值也写进去、quantizer 的 full 内容、以及 InvertedLists 的全部数据。InvertedLists 是 Faiss 里最关键的存储结构。它本质是一个数组每个元素是一个倒排链链里包含两类并行数组一类是ids原始向量的 id另一类是codes向量压缩后的字节IVF_FLAT 的 code 就是原始 float 数组。我在读InvertedLists源码时发现Faiss 为了兼顾 CPU cache把ids和codes拆开存放这样遍历一个 list 时可以先顺序读 ids再按需读 codes避免频繁跳跃。IVF 索引在构建时还有一个“训练”阶段。很多人以为train是加载训练数据其实是训练 k-means 聚类器。这一步要采样一定比例的数据参与聚类最终生成的nlist个码本中心会参与量化。如果你把nlist设得很大比如 1 万以上但数据量只有几万训练阶段会明显变慢因为 k-means 的复杂度对 nlist 不是线性的。这也是为什么 Milvus 的索引参数建议里nlist通常设为4 * sqrt(N)左右而不是拍脑袋乱填。2.3 HNSW 索引的存储结构图在内存和磁盘上的表现HNSW 是另一种完全不同的存储结构。它没有码本也没有倒排链而是维护一个多层跳表式的图每个节点代表一个向量节点按概率分层查询时从顶层入口出发贪心向下搜索。在 hnswlib 的源码里HierarchicalNSW的核心数据是std::vectorLinklist、std::vectortableint、以及原始数据数组。每个节点的邻居列表实际上是一个固定大小的数组数组第一格存邻居数量后面跟着 M 个邻居 id。层数level是通过随机数生成的这个随机性来自一个名为level_generator的分布函数。所以同一个数据集每次构建 HNSW 索引的图结构可能不完全一样这是随机化的结果不是 bug。Knowhere 序列化 HNSW 索引时会把图结构、向量数据、层数、M、efConstruction 等参数打包。磁盘上的布局大致可以想象成头部是元信息总节点数、维度、M、efSearch 等接着是所有向量的原始字节然后是按节点 id 顺序排列的邻接表。因为每个节点的邻居数量并不完全一样底层节点一般满 M 个高层节点少一些所以不能简单按定长数组读通常要额外记录每个节点的邻居数或者用偏移表索引。我实际拆过一个 HNSW 索引文件发现它在查询时会消耗的内存比文件体积大不少。原因是加载时不仅仅要读文件还要在内存里重建指针结构、邻接表索引等如果文件是 200 MB加载后可能占用 500 MB 内存。这和 IVF 不一样IVF 加载后内存和文件体积基本是 1:1 的关系。这点对于用 docker 限制内存的 Mac 用户尤其重要部署时别按文件体积去估内存。2.4 mmap 和 cache索引加载的秘密Milvus 在加载索引到内存时不一定是整文件一次性read到堆里而是广泛使用了 mmap 技术。mmap 可以把磁盘文件和进程地址空间直接建立映射操作系统按页加载内容。这样有几个好处一是虚存空间可以大于物理内存二是多个 segment 共享同一份内核页缓存三是冷启动加载速度更快。但 mmap 也有坑。最明显的是如果物理内存不够mmap 映射的页面可能会被换出查询遇到冷 page 时会卡在磁盘 IO 上。Milvus 里有一个mmap_enabled的配置开关默认在某些版本是关闭的但很多人以为开了 mmap 就万事大吉实际效果要看你磁盘是 SSD 还是 HDD。我在机械硬盘上试过开 mmap查询延迟能从个位数毫秒飙到几十毫秒因为换页太频繁。另一个点是Knowhere 在加载 Faiss 索引时会做一次反序列化把二进制字节码还原成 C 对象。这个过程如果你日志开着 debug能看到类似“start load index, index_type: ...”。如果加载慢不一定是磁盘慢也可能是 CPU 在解编码时占满了核。3. 查询执行模型一条向量从 Query 到 TopK 的全流程3.1 从客户端到 QueryNode查询请求怎么被路由在 Milvus 里你调collection.search(vectors, param{nprobe: 16})时请求会先到 Proxy然后被路由到 QueryNode。QueryNode 是真正负责加载索引、执行搜索的组件。它内部维护了一堆 segment 的索引引用每个 segment 可能已经加载到内存也可能还在磁盘上。QueryNode 收到请求后会根据查询涉及的 partition key 或者主键范围筛出需要查哪些 segment。如果某个 segment 的索引还没加载它会触发加载流程如果加载的 segment 太多还会触发内存淘汰。这里有个常见的性能坑如果你频繁在大量 partition 上查询即使每个 partition 数据量很少QueryNode 也要维护一堆 segment 的索引内存碎片和查询调度的开销都会明显上升。执行搜索时QueryNode 会并行遍历多个 segment。每个 segment 的搜索是独立的小任务最后再把结果 merge。这里有点像 MapReduce每个 segment 是 map最终结果是 reduce。所以 Milvus 的查询延迟跟 segment 数量强相关segment 越多并行调度和 merge 开销越大并不完全等于“只查一个 segment 的时间”。3.2 核心执行计算距离、排序、合并结果拿 IVF 来说单 segment 内的搜索可以拆成三个阶段。第一阶段是“粗筛”把 query 向量和 nlist 个簇中心做全量暴力距离计算找到距离最近的 nprobe 个簇。这一阶段的计算量是一个 nlist 次的距离计算维度等于向量维度。如果你的 nlist 是 4096向量维度是 768那就是 4096 次 768 维浮点运算开销不大但对延迟敏感时也值得优化。第二阶段是“精算”把 nprobe 个簇里的所有向量拿出来逐一精确计算距离取 topk。这个阶段的计算量取决于 nprobe 和每个簇的平均大小。如果每个簇平均有 1000 条向量nprobe16你就要精算 16000 条向量。这里用到的核心数据结构是一个最大堆C 里的maxheap堆顶始终是当前第 k 大的距离新算出的距离如果比堆顶小就把堆顶替换掉。Faiss 源码里IndexIVF::search_preassigned里这段逻辑很清晰手动维护一个大小固定的堆避免对所有候选排序。第三阶段是“排序合并”QueryNode 拿到每个 segment 的 topk 后再对所有结果做一个全局排序去掉被删除位图标记的 id返回最终 topk。注意这个 merge 不能简单地取每个 segment 的第一个还要考虑多个 segment 在同一主键或同一逻辑 id 上的去重以及删除了的部分。HNSW 的搜索逻辑完全不同。它在图上做贪心搜索从最高层的入口点开始每一层沿着邻居边走找与 query 距离最近的节点然后逐步下降到下一层。在底层它维护一个大小为efSearch的动态候选列表不断探索更近的节点直到候选列表里的最小距离不再变好。efSearch越大探索越充分召回越高但延迟也越高。我当时看 hnswlib 的searchKnn源码时最深的感受是它非常依赖“访问标记”和“上界剪枝”。如果M太大每跳一次的邻居列表很长虽然路径可能更短但访问过的节点数量会骤增性能反而下降。这也解释了为什么 HNSW 的M不是越大越好。3.3 距离度量与“余弦值”的真相热搜里频繁出现“milvus 余弦值”说明很多人最关心的还是相似度类型。Milvus 支持三种IP内积、L2欧式距离、COSINE余弦相似度。但在 Faiss 底层只有METRIC_INNER_PRODUCT和METRIC_L2两种Knowhere 做了个隐式转换。如果你选 COSINEKnowhere 在做索引构建和查询前会先把所有向量归一化成单位向量再把 COSINE 等价转换成 Inner Product。因为单位向量 a 和 b 的余弦相似度等于 a/|a| 和 b/|b| 的内积。这个转换逻辑简单但容易出问题的地方在于归一化是逐向量做的如果你原始向量有全零向量它没法归一化构建索引时会直接报错或产生 NaN。我踩过这个坑排查了很久才发现是数据里有几条零向量。另一个容易踩的坑是用 L2 和余弦值的效果差距极大。L2 关心的是绝对距离对向量的模长非常敏感余弦值只看方向不影响模长。如果你做的是文本 embedding模长本身往往没有语义信息用余弦更合理如果你做的是图像颜色直方图这种向量模长可能代表亮度L2 可能更合适。别只因为“大家都用余弦”就盲目选。3.4 参数数学与性能权衡一组实用对照我整理一份参数对照表方便你查。这里不写死极值因为不同数据分布下差异很大但数量级可以参考。参数所属索引控制的是什么调大带来的影响常见经验值nlistIVF 系列簇中心数量粗筛粒度训练更慢、存储略增粗筛阶段计算量增加但每簇更小、精算候选更集中约为4 * sqrt(N)nprobeIVF 系列查询时扫描多少个簇召回率提升查询延迟几乎线性增长如果nlist1024测试时从 8 起步MHNSW图每个节点的最大邻居数图中边更多构建更慢、内存更大召回率提升但路径跳转变多16~32efConstructionHNSW构建时动态候选集大小构建质量更好但构建耗时和内存明显增加100~200efSearchHNSW查询时动态候选集大小查询召回提升但延迟非线性增长边际递减64~256我在实际项目里有个习惯先建一个小规模数据集分别测不同参数下的召回率和 P99 延迟记成一张小表再上线。网上很多“参数推荐”只能作为起点最终还是要看你自己的数据分布。4. 源码解剖Knowhere 核心接口与关键调用链4.1 从 Python SDK 到 C IndexNode 的调用链用一张逻辑链路来说明pymilvus的Collection.search()通过 gRPC 把请求发给 ProxyProxy 转发到 QueryNodeQueryNode 内部通过 Milvus 的内部 C SDK 调用 Knowhere 的Index::Query方法。这个过程中param字典里的nprobe、efSearch会被解析成 Knowhere 的Json配置最底层再转成 Faiss 的SearchParameters。我特别喜欢 Knowhere 的一点是它把 Faiss 的索引对象包装成一个统一结构体后搜索接口非常简单// Knowhere 核心接口简化版 class Index { public: virtual void Build(const DataSet dataset) 0; virtual DataSetPtr Query(const DataSet query_data, const Json json, int64_t k) 0; virtual void Serialize(BinarySet binset) 0; virtual void Deserialize(const BinarySet binset) 0; };每个具体的 Index 实现类比如IVFSQIndex、HNSWIndex内部会维护一个 Faiss / hnswlib 的原生索引指针。选型逻辑在 Knowhere 的IndexFactory里其实就是一个巨大的if/else或者switch根据index_type字符串和metric_type创建不同对象。如果你想加一种新索引算法只要实现这个 4 个接口就能被 Milvus 上层接住。这种设计我非常推荐做中间件的人参考比搞一堆抽象的 interface 更实用。4.2 Faiss 的索引搜索源码关键路径IndexIVF::search我从 Faiss 源码里把核心路径简化一下。假设 nprobe256TopK10ntotal100 万nlist4096。// 伪代码展示核心逻辑 void IndexIVF::search( idx_t n, const float* x, idx_t k, float* distances, idx_t* labels, const SearchParameters* params) const { // 1. 对 query 向量做量化得每个 query 的最近的 nprobe 个簇 long nprobe params-nprobe; std::unique_ptridx_t[] idx(new idx_t[n * nprobe]); std::unique_ptrfloat[] coarse_dis(new float[n * nprobe]); quantizer-search(n, x, nprobe, coarse_dis.get(), idx.get()); // 2. 根据簇 id 找到对应倒排链 // 3. 对每个倒排链内向量精确计算距离 // 4. 用 maxheap 保持全局 topk search_preassigned(...); }仔细观察会发现nprobe 越大第 1 步 quantizer 搜索时扫描的簇中心数越多但这是一个非常快的暴力搜索因为 nlist 通常几千到几万第 3 步才是大头它要精确计算 nprobe 个簇中的所有候选向量。Faiss 在精确计算距离时有个优化如果 code 是浮点原始值它直接用 BLAS 的矩阵乘法做批量距离计算比 for 循环快得多。如果 code 是量化后的字节比如 IVFSQ8它要把字节反量化回浮点再做距离计算这会多一层运算所以 IVFSQ8 的召回率略低于 IVF_FLAT但内存占用小得多。4.3 HNSW 的搜索源码关键路径hnswlib::HierarchicalNSW::searchKnnhnswlib 的搜索逻辑更像 BFS 混合贪心。核心数据结构是std::priority_queuestd::pairfloat, tableint以及一个visited数组打标记。// hnswlib 逻辑简化版 void HierarchicalNSW::searchKnn(const void* query_data, size_t k, std::priority_queuestd::pairfloat, tableint result) { // 从最高层入口点 visited_top_ 开始 // 对每一层遍历邻居表找出距离最近的节点 // 每往下走一层扩大候选集 // 常规维护 upper_bound当前第 k 近的距离 // 如果新的候选比 upper_bound 更近就加入候选堆 // 直到小根堆中的候选都被探索完 }efSearch 体现在候选堆的大小上。你可以把 HNSW 的搜索理解成“在图上画圈找人”efSearch 就是这个圈的最大半径圈越大找到目标的机会越大但你要访问的节点也越多。每次从候选堆里弹出一个节点都要计算它所有邻居的距离如果节点邻居数太少M 小路径很绕如果邻居数太多M 大每个节点的展开成本高。我发现 HNSW 查询的一个特有意思的行为预热和冷启动差异巨大。第一次查询时visited 数组和内存中的距离缓冲要重新初始化耗时可能比后续查询多个数量级。如果你的线上服务有“P99 漂移”很可能就是这个原因。缓解办法很简单查询前先拿一条假向量跑一次“热身”。4.4 Knowhere 的封装版本兼容和二进制迁移Knowhere 还有一个比普通封装更细致的点版本兼容。因为 Milvus 的索引文件可能在不同版本之间迁移Knowhere 在Serialize/Deserialize时会在二进制流里写入版本号。升级 Milvus 后老版本索引文件如果格式不兼容会自动触发索引重建而不是直接崩掉。我记得在某个版本升级后打开 collection 时日志提示“index version mismatch, rebuild”一开始以为索引坏了后来发现是 Knowhere 的序列化格式变了属于正常现象。如果你在运维中看到类似提示先别急着回滚看看是不是版本兼容策略触发了重建。Knowhere 还统一了“距离归一化”的处理方式。你在 Faiss 里如果要取归一化后的余弦相似度必须自己先改向量但通过 Knowhere 接口它会根据metric_type自动做归一化。所以用 Milvus 时你不需要手动 L2 normalize embedding这也是很多人觉得“Milvus 比裸 Faiss 省事”的一个重要原因。5. 实战踩坑与调优笔记5.1 索引构建时内存爆掉的排查我遇到过最典型的问题是在一台 32G 内存的服务器上构建百万级向量的 IVF_PQ 索引时内存直接从 8G 冲到 30G最后 OOM。原因有两个一是训练阶段 k-means 要临时保存所有采样向量和累积统计量二是 Faiss 的 polylog 合并和倒排列表构建期间有大量中间缓冲。解决办法并不是调小 Milvus 配置里的maxMemory那个参数主要控 QueryNode 加载索引的内存不管索引构建。正确思路是将index_build任务调度到独立节点或者把nlist、nbits、M这些参数调小。另外Faiss 在构建时支持设置verbose日志如果你用 Milvus 看日志它会周期性地打印构建进度能看到内存占用是在训练阶段还是写倒排阶段飙升的。一个小经验如果数据量很大先用IVF_FLAT建一次索引测通整个链路再切到IVF_PQ或HNSW避免索引类型不合适导致返工。5.2 查询变慢的几大隐藏原因很多人一遇到慢查询就怪索引参数没调好但实际我把日志翻出来看发现慢的往往不在搜索本身。第一个隐藏原因是“加载过慢”。批量查询之前索引还没全部加载到内存第一次 search 会触发异常慢的 mmap 缺页中断。解决方法是预热服务启动后跑一个小的搜索请求强制把索引文件读取到 page cache。第二个隐藏原因是“segment 数量太多”。如果每次写入后频繁 flush可能产生几千个小 segment每个 segment 都要发一个搜索任务。查询延迟不是线性增长而是近似指数因为 QueryNode 的调度开销爆发了。解决方案是增大segment.maxSize或者让数据先攒够一定量再 flush。第三个隐藏原因是“删除位图过滤过于耗时”。文档里很少提到当你删除了大量向量但 compaction 没执行时每个 segment 的删除位图都很长查询时每一条命中的结果都要去查位图。如果位图是稀疏的用 Roaring Bitmap 还好但如果不是过滤耗时可能占整个查询的 20% 以上。所以大量删除后要及时 trigger compaction。第四个原因是“使用小批量查询”。我见过有人一次查 1000 条向量每条向量都是单独搜索内部却复用了同一个 maxheap 分配器导致大量小对象创建。Milvus 支持 batch search尽量将一批 query 一次性提交避免重复开销。5.3 从热搜词看高频部署问题Mac docker standalone 和本地模式那个“在 mac 上使用 docker 安装 milvus”的热搜我估计很多人卡在第一步docker compose up -d之后没有独立磁盘卷导致重启数据丢失。建议一定要把 etcd、minio 和 milvus 的数据目录都映射到宿主机目录。另外“服务器 linux 上使用 milvus”经常遇到的问题是防火墙默认没开 19530 端口pymilvus 连接直接被拒。这种排错比较机械先curl一下端口再看容器日志。如果索性在服务器本地跑记得别用localhost因为 pymilvus 默认握手可能走 IPv6导致连接超时——我在某些 Linux 发行版上踩过这个坑改成127.0.0.1就没事了。本地模式那个./data/milvus.db如果提示找不到文件多半是相对路径的问题。因为运行时当前工作目录和你以为的不一致建议用绝对路径。还有个更隐蔽的坑SQLite 本地模式不支持并发多进程访问如果你同时开两个 jupyter notebook 都连同一个 Milvus local 文件会出现数据库锁错误。这也是为什么我更推荐生产环境用 standalone 或 cluster本地模式只适合单机学习和调原型。5.4 不同索引类型的选型建议场景推荐索引理由数据量 100 万内存充足IVF_FLAT简单直接召回无损调参容易数据量 100 万 ~ 1000 万追求内存IVF_SQ8压缩到 8 比特内存降为 1/4召回损失较小数据量 1000 万追求极限内存IVF_PQ编码压缩率高但需要调 nbits、m召回需要验证高召回、低延迟、数据量千万内HNSW图索引的查询路径短对高维度数据大多表现好超大磁盘索引几十亿向量DISKANN建立在 SSD 上索引不常驻内存延迟可控我个人的建议是如果预算允许先上一台内存大点的机器用 HNSW不要一上来就 PQ 压缩。压缩索引的召回问题会让你后面排查到头大。等数据量真的涨上去了再考虑分片和 PQ 的替代方案。5.5 常见问题速查表现象可能原因处理方式查询结果召回率低nprobe或efSearch太小调大重测同时观察延迟内存一直增长不释放mmap 页面或者缓存持续增长开启缓存淘汰策略或减少加载 segment本地模式报 DB locked多进程连接同一个 SQLite 文件改为 standalone 或串行访问索引构建失败日志里有 NaN向量包含全零或异常值过滤异常向量检查数据清洗同一个数据删除后仍能搜到删除位图未过滤或未 compaction在 flush 后触发 compaction热重启后查询卡住索引重新加载慢缺页中断启动后做一次 query 预热我做向量检索这条线这么多年最大的一个体会就是索引和存储结构这些东西平时看着像黑盒但真正遇到性能问题时所有线索都在这些小地方。不要只盯着 Faiss 或者 HNSW 的参数表多花一点时间拆一拆源码里的存储布局和搜索循环很多“玄学性能问题”会变得特别清晰。最后再分享一个小技巧分析一个 query 调用了多少个候选向量可以在 Knowhere 源码里临时加上日志统计nprobe或efSearch实际访问的节点数。新手上路时我用这个办法修正过很多回参数比盲猜nprobe16还是64有用得多。调优这种事说到底就是根据真实数据分布做一次可量化对比而不是背文档里的推荐值。