稀疏、密集、二进制向量选型与Milvus实战全解
做向量检索这几年我接到最多的一个问题不是Milvus 怎么用而是我到底该用稀疏向量、密集向量还是二进制向量。很多人默认三种向量只是精度和存储的取舍实际完全不是一回事——稀疏、密集、二进制对应的是三种不同的数学建模思路选错了后面所有调优都是在错误的地基上盖楼。这篇文章会把三种向量在 Milvus 里的原理、建集合、写数据、检索、选型、混合排序一次性讲清楚并且把我在真实项目里踩过的坑一并列出来。适合刚接触向量检索的读者通读也适合已经跑通 Demo、准备上生产的人对照自查。文章涉及的操作基于 Milvus 2.4 以上版本Python 客户端 2.4.x。1. 三种向量到底差在哪先看数据是怎么进库的1.1 同样的输入三种完全不同的翻译方式很多人容易把向量类型当成一种存储格式的差异比如密集向量是 float32二进制向量是压缩过的 float32。这个理解会直接误导后面的各种决策。举个具体的例子。我们现在有一篇文章内容是Milvus 支持主键查询、标量过滤和向量检索。面对这段文本三种向量会给出三份完全不同的数学表达密集向量用神经网络模型把整句话编码成一个固定长度的浮点向量比如 768 维。向量里每一个位置都不是某个词的身份而是整句话在语义空间中的坐标。它能回答和这句话意思相近的句子是哪句哪怕两句话一个词都不重叠。稀疏向量把文章分词得到Milvus、支持、主键、查询、向量、检索这些词给每个词分配一个唯一 ID然后把词频或 TF-IDF、BM25 权重填到对应维度上。向量维度通常等于词表大小可能有几十万甚至几百万维但绝大多数位置是 0只有命中的几十个位置有值。二进制向量把文本或图像通过某种二值化手段变成一串 0 和 1每 8 个比特压缩成 1 个字节存储。它回答的问题更粗糙——这两个样本的比特串有多像常用于指纹去重、超大规模粗筛。理解到这里就能明白一个关键点不是二进制是密集向量的压缩版而是它们各自表达的信息层次本来就不同。密集向量表达语义稀疏向量表达词项命中二进制向量表达模式相似度。1.2 稀疏向量高维、稀疏、可解释在 Milvus 里稀疏向量用DataType.SPARSE_FLOAT_VECTOR表示存储时只保存非零位置和值。比如{12: 0.7, 88: 1.2, 250: 0.3}就是维度 12 的值是 0.7维度 88 的值是 1.2维度 250 的值是 0.3其他维度全是 0。它最大的优点是可解释性。检索结果命中你知道是因为哪几个词命中了插入一条数据你也知道它在哪些词上有权重。这在搜索广告、法律条文检索、FAQ 匹配这类结果要能解释给业务听的场景里非常有用。缺点也很明显如果分词和词表处理得不好同义词、拼写变体这些问题它一概不认必须靠别的手段补。1.3 密集向量低维、稠密、黑盒密集向量由深度学习模型生成Milvus 里用DataType.FLOAT_VECTOR声明维度在 128 到 4096 之间比较常见。它对语义的理解能力强但结果不可解释——你说这两句话相似模型觉得相似但它不会告诉你因为哪个词。只要涉及图片、音视频、跨语言、长文本语义匹配这类场景密集向量基本是首选。它的问题主要是存储和算力开销大一亿条 768 维向量光原始数据就要 300 GB 量级索引和查询时的内存放大更加可观。1.4 二进制向量把浮点坐标压成一串比特二进制向量在 Milvus 里用DataType.BINARY_VECTOR声明核心思想很粗暴既然很多场景只需要像不像的粗糙判断那把每个浮点维度压成 1 比特两个向量之间的距离直接变成汉明距离或者杰卡德距离。原来的 768 维 float 向量要 3072 字节压缩成二进制只要 96 字节差了 32 倍。代价是精度损失非常明显所以它在生产环境里很少单独承担精排任务更常见的用法是二进制向量做超大规模粗召回把候选从一亿收缩到一万再用密集向量精排。这一点后面选型部分会展开说。2. 距离度量与内存账本省内存是有代价的2.1 三种向量分别用什么距离算像不像这是最容易被忽略的一层。很多人以为向量检索就是算余弦相似度其实不同向量类型能用的度量完全不一样在 Milvus 里选错度量甚至会直接报错。向量类型常用度量计算方式是否需要归一化密集向量L2、IP、COSINE浮点坐标的欧氏距离 / 点积COSINE 通常内部做归一化稀疏向量IP点积只算非零维度的逐项乘积之和归一化后等价于余弦二进制向量HAMMING、JACCARD统计比特位不同的个数 / 交集比重不需要密集向量的 L2 和 IP 大家比较熟。稀疏向量这里要特别注意官方主流支持的是 IP也就是点积。因为稀疏向量本身极稀疏用 L2 的话两条完全不重叠的向量距离反而比部分重叠的向量更近结果没有区分意义。实际使用中建议对稀疏向量做归一化这样 IP 就等于余弦相似度语义上更好解释。二进制向量的 HAMMING 和 JACCARD 在语义上很直观汉明距离统计两个比特串有多少位不同适合固定维度的二进制向量杰卡德距离则看两个集合的重合程度在有些位被置 1 的稀疏二值场景下更稳定。2.2 百万条数据的内存账别算错选型时大家最爱问这个方案内存要多少。我用一百万条 768 维向量来算一笔账这样感受更直观。密集向量768 × 4 字节 3072 字节/条一百万条约3.07 GB这还只是原始数据建索引之后内存会翻倍甚至更多。二进制向量768 ÷ 8 96 字节/条一百万条约96 MB索引开销也更小。稀疏向量取决于平均每条约多少个非零值。假设平均 20 个非零值每个非零维度用 int 维度号 float 值表示大约160~200 字节/条比二进制略大但比密集向量小了一个数量级。看到这个数字很多人会立刻说那就无脑用二进制好了。不行前面已经说过三种向量承载的信息层次不同。内存省下来的钱会在召回精度上还回去。正确思路是把内存模型当作筛选条件而不是决策条件只有业务上能接受粗糙相似度的场景才轮到二进制向量上场。2.3 近似检索的代价与召回率向量检索在超大规模下都是近似检索不是精确检索。Milvus 对不同向量类型提供了不同的索引默认 AUTOINDEX 会自动挑一个合理的索引但你不理解参数的话出了召回率问题根本无从下手。密集向量默认走 HNSW 或 IVF 族索引查询时要关心ef或nprobe稀疏向量走倒排索引查询参数里有drop_ratio_search用来控制在查询时把尾部低权重的维度丢掉多少默认 0.2二进制向量走 BIN_FLAT 或 BIN_IVF_FLATIVF 版本也会涉及nprobe。这些参数本质都是在速度和召回率之间滑动。我见过不少项目前期用默认参数跑得好好的数据量翻倍之后精度突然掉排查半天最后发现是nprobe太小候选桶只探了十分之一。索引参数不是一次性配好就不管的数据量涨一个数量级必须重新评估一遍。3. Milvus 实操建集合、插数据、检索引擎全流程3.1 环境准备与集合 Schema 设计实操部分基于pymilvus2.4.xMilvus 服务端 2.4 以上。先装客户端pip install pymilvus连接服务端from pymilvus import connections connections.connect(aliasdefault, hostlocalhost, port19530)建集合之前先想清楚一点Milvus 一个 Collection 里可以同时放多个向量字段。这非常重要因为后面讲混合检索时稀疏和密集两个向量字段要在同一个集合里各司其职。先演示一个同时包含稀疏向量和密集向量的 Schemafrom pymilvus import CollectionSchema, FieldSchema, DataType, Collection fields [ FieldSchema(nameid, dtypeDataType.VARCHAR, is_primaryTrue, max_length64), FieldSchema(nametext, dtypeDataType.VARCHAR, max_length2048), FieldSchema(namedense, dtypeDataType.FLOAT_VECTOR, dim768), FieldSchema(namesparse, dtypeDataType.SPARSE_FLOAT_VECTOR), ] schema CollectionSchema(fieldsfields, description混合向量检索demo) collection Collection(namehybrid_demo, schemaschema)注意一个细节SPARSE_FLOAT_VECTOR不需要指定 dim因为它只存非零值。很多第一次用稀疏向量的人会习惯性地写dim100000在 Milvus 里这个字段类型不接受 dim 参数写了反而报错。原因是稀疏向量的维度由插入的数据隐含决定系统会自己维护维度映射。3.2 稀疏向量从文本到入库的完整链路稀疏向量不会凭空产生它需要一套分词和权重计算的流程。我在项目里最常用的方案是 BM25。假设我们有这样一段文本向量数据库支持稀疏检索分词后得到 [向量, 数据库, 支持, 稀疏, 检索]。BM25 会给每个词算一个权重然后我们把词映射成全局词表里的 ID最终得到一个 dict词 ID 映射权重。这里直接给出一个简化但可用的生成示例。实际项目中词表和 IDF 要提前在训练集上统计好这里为了演示写死def text_to_sparse(text, token2id): 极简版分词后算词频乘上预设 idf。 真实项目请用 jieba 已训练好的 BM25 参数或者使用 Milvus 提供的 BM25EmbeddingFunction。 tokens simple_tokenize(text) tokens_set set(tokens) vec {} for token in tokens_set: if token in token2id: vec[token2id[token]] tokens.count(token) * idf.get(token, 1.0) return vec # 假设 token2id {向量: 12, 数据库: 88, 支持: 250, 稀疏: 66, 检索: 7} sparse_vec { 12: 1.2, 88: 0.8, 250: 0.5, 66: 1.5, 7: 1.1 }插入数据时稀疏向量的格式就是一个 Python dictkey 是 intvalue 是 floatimport random entity_ids [str(i) for i in range(3)] texts [向量数据库支持稀疏检索, 数据库支持主键查询, 二进制向量用于快速粗筛] dense_vectors [[random.random() for _ in range(768)] for _ in range(3)] sparse_vectors [ {12: 1.2, 88: 0.8, 250: 0.5, 66: 1.5, 7: 1.1}, {88: 1.1, 250: 0.9, 33: 1.3}, {101: 0.7, 202: 1.0, 66: 0.6} ] collection.insert([entity_ids, texts, dense_vectors, sparse_vectors])插入后必须走完三步才能检索建索引、加载到内存、然后才 search。顺序别反先 load 再建索引是新手高频报错现场from pymilvus import utility collection.create_index(sparse, { index_type: SPARSE_INVERTED_INDEX, metric_type: IP, params: {drop_ratio_build: 0.2} }) collection.create_index(dense, { index_type: AUTOINDEX, metric_type: IP }) collection.load()稀疏索引这里用的是SPARSE_INVERTED_INDEX默认实现就是倒排索引drop_ratio_build表示建索引时把非零权重排名后 20% 的维度直接丢弃用来降噪和压索引体积。如果你的数据本身噪音少可以调小到 0.1如果数据很脏0.3 我都见过具体后面踩坑部分再说。检索稀疏向量query_vec {12: 0.9, 88: 0.6, 250: 0.4, 66: 1.0, 7: 0.8} results collection.search( data[query_vec], anns_fieldsparse, param{ metric_type: IP, params: {drop_ratio_search: 0.2} }, limit5, output_fields[text] )返回的results[0]里每条结果都有id、distance和text。3.3 二进制向量的字节打包细节二进制的坑比稀疏向量更隐蔽主要在数据格式上。BINARY_VECTOR 不是让你提交一个 0/1 的 list而是要求提交bytes 对象并且维度必须是 8 的倍数——因为底层 8 个比特打包成 1 个字节。写一个打包函数先说明问题def pack_binary(bits): 把长度为 dim 的 0/1 list 打包成 bytesdim 必须是 8 的倍数。 if len(bits) % 8 ! 0: raise ValueError(binary vector dimension must be multiple of 8) packed bytearray(len(bits) // 8) for i in range(0, len(bits), 8): byte 0 for j in range(8): byte (byte 1) | bits[i j] packed[i // 8] byte return bytes(packed) # 128 维二进制向量示例 bits [0, 1, 0, 1, 1, 0, 0, 1] * 16 binary_vec pack_binary(bits)建集合和插入fields_bin [ FieldSchema(nameid, dtypeDataType.VARCHAR, is_primaryTrue, max_length64), FieldSchema(namefingerprint, dtypeDataType.BINARY_VECTOR, dim128), ] schema_bin CollectionSchema(fieldsfields_bin) col_bin Collection(namebinary_demo, schemaschema_bin) col_bin.insert([ [str(i) for i in range(3)], [pack_binary([0, 1, 0, 1, 1, 0, 0, 1] * 16), pack_binary([0, 1, 0, 1, 1, 1, 0, 1] * 16), pack_binary([1, 1, 0, 0, 1, 0, 1, 0] * 16)] ]) col_bin.create_index(fingerprint, { index_type: BIN_IVF_FLAT, metric_type: HAMMING, params: {nlist: 128} }) col_bin.load() query_binary pack_binary([0, 1, 0, 1, 1, 0, 0, 1] * 16) res_bin col_bin.search( data[query_binary], anns_fieldfingerprint, param{metric_type: HAMMING, params: {nprobe: 8}}, limit5 )这里要特别提醒二进制向量插入时不检查维度是否能被 8 整除但查询时如果维度不对或者 index 和查询的 metric 不一致报错会很隐晦。我建议打包函数里显式抛错宁可本地报错不要等到 Milvus 返回一堆难懂的底层错误。3.4 混合检索把稀疏和密集的分数合并只聊单一种类其实没有完全吃到 Milvus 在 2.4 之后的核心红利——多向量混合检索。最经典的做法是密集向量负责语义召回稀疏向量负责关键词硬匹配各自取一批候选然后合并排序。先构造两个检索请求from pymilvus import AnnSearchRequest, RRFRanker, WeightedRanker req_dense AnnSearchRequest( data[query_dense_vec], anns_fielddense, param{metric_type: IP, params: {ef: 64}}, limit10 ) req_sparse AnnSearchRequest( data[query_sparse_vec], anns_fieldsparse, param{metric_type: IP, params: {drop_ratio_search: 0.2}}, limit10 )然后合并排序。Milvus 里最重要的两个 Ranker 是 RRF 和 Weighted# RRF倒数排名融合不看具体分数只看排名 res_hybrid collection.hybrid_search( reqs[req_dense, req_sparse], rerankRRFRanker(k60), limit5, output_fields[text] ) # WeightedRanker给两种向量各自分配权重 res_hybrid collection.hybrid_search( reqs[req_dense, req_sparse], rerankWeightedRanker(0.7, 0.3), limit5, output_fields[text] )这两个 Ranker 的差别很关键。RRFRanker不看相似度绝对值只看每个请求返回的排名适合两个向量字段分数scale差异巨大的情况。比如稀疏向量 IP 算出来的分可能到 10 以上密集向量语义相似度只有 0.7直接加权会完全被稀疏分主导。WeightedRanker则适合你先确认两边分数分布可比的场景权重直接反映业务偏好。我项目里默认走 RRF因为省心不会因为两边分数 distribution 漂移而失效。4. 场景选型不同业务到底该选哪种向量4.1 关键词匹配类业务稀疏向量是主战场搜索词命中、FAQ 匹配、法律条文检索、商品名匹配这类业务有个共性用户真的在对关键词而且要能跟业务方解释为什么召回这条结果。稀疏向量在这种场景下非常合适。比如一个电商后台的店铺搜索用户输入无线耳机你希望先召回标题里包含无线、耳机、蓝牙、降噪这些词的商品而不是召回一个语义上类似但标题完全不沾边的商品。稀疏向量显式地建模这种词项命中关系可解释、可调试分词词典更新后行为可预期。实际项目中我常把稀疏向量和 BM25 绑定使用。Milvus 2.5 开始官方提供了BM25EmbeddingFunction可以直接把文本转成稀疏向量并自动管理词表省去了自己维护 token2id 的麻烦。但要注意它适合离线批量生成向量在线实时插入时还是要自己控制词表版本更新策略。4.2 图片、音频、跨语言语义匹配密集向量不可替代图片相似度、以图搜图、视频片段匹配、跨语言文档对齐这些场景的共同点是语义相似不等于字面相似。一张猫的照片和一段毛茸茸的动物的文字稀疏向量根本不可能建模这种跨模态关系只有神经网络生成的密集向量可以。这个共识大家都懂但我想强调一点不要让密集向量一个人干所有活。我在一个以图搜文项目里吃过大亏——刚开始只放了密集向量用户的精确 ID 查询比如找型号是 ABC-123 的图片语义模型根本匹配不准因为这种查询本质是标量匹配。后来在同一个 Schema 里加了稀疏向量做关键词通路效果立刻上了一个台阶。混合向量不是炫技是真的能补齐单一向量的盲区。4.3 大规模指纹去重与粗筛二进制向量最划算二进制向量最合适的是海量候选里做第一道粗筛这类任务。比如图片感知哈希pHash去重音频指纹匹配大规模样本去重人脸底库的候选粗筛先汉明距离筛出几千个候选再上密集向量精排这类任务的特点样本量大到千万甚至亿级、精度要求没到必须有排名解释的程度、单次查询要快。我遇到过一个具体案例某图像去重项目要处理一亿条图片指纹如果全部用 768 维密集向量做暴力扫描成本完全不可接受。最后方案是二进制向量128 维16 字节做粗召回候选集缩到一万条再用密集向量做精排查询耗时从秒级降到几十毫秒精度反而因为候选集变小精排阶段可以加载更重的模型而提升了。4.4 同一个集合里放多个向量字段的决策建议Milvus 允许一个 Collection 里有多个向量字段这在架构上很有用但也带来一个需要注意的点查询时只能对单个字段调用search要走hybrid_search才能同时用多个向量字段。所以建 Schema 时就要想清楚稀疏向量和密集向量共存的集合Search 前要分别构造 query 向量。二进制向量一般单独用或者作为粗筛字段和密集向量一起走 hybrid_search。一个常见问题我能不能把二进制和密集放在同一个字段答案是不能向量的 metric、索引、存储都不同不能混在一个字段里。但你可以把两类向量放在一个 Collection 的不同字段中再走混合检索。我建议把稀疏密集作为默认组合二进制只在超大底库场景下作为一个额外字段加入。5. 我在生产环境里踩过的坑几条可复用的经验5.1 稀疏向量到底要不要归一化先说结论如果你用 IP 度量归一化后点积就是余弦相似度语义上更干净推荐做。但有一个例外当我用 BM25 产生稀疏向量时我不做归一化。因为 BM25 的权重里天然包含文档长度归一化再人为做一次向量归一化会把长文档和短文档的天然权重差异磨平反而让召回偏向短文档。这个坑我是在一次 FAQ 检索项目里发现的——归一化之后长文档的召回率明显下降去掉归一化就恢复了。所以我的建议是如果你的权重来自神经网络的 embedding归一化如果来自 BM25 这类检索算法先跑一版对比实验再决定不要默认归一化。5.2 二进制向量的维度必须是 8 的倍数不能商量我见过不止一个人在这个地方卡住维度填了 100插库不报错一 search 就报错日志信息又看不懂排查半天才发现问题。因为 BINARY_VECTOR 底层每 8 个比特打包成 1 字节维度不是 8 的倍数时打包边界是乱的查询计算汉明距离时对不上。所以开发阶段就要在工具函数里加上校验宁可在本地就把错误拦下来也不要把脏数据推进 Milvus。另外BINARY_VECTOR 如果使用BIN_IVF_FLATnlist的选择和 IVF 密集向量是同一套逻辑——数据量在百万级时nlist128起步查询nprobe从 8 开始调不够再加。5.3 混合检索的 limit每路候选数千万别只给 5混合检索最常见的效果问题是明明单路 query 能召回正确结果hybrid_search 却丢了。原因多半是每路AnnSearchRequest的 limit 设太小。假设hybrid_search最终要 top 5而两路请求各只返回 5 条RRF 之后能参与排名的就 10 条一旦某一路的正确答案排在第 6 位就直接被截断了。我一般会把每路 limit 设为最终limit的 5~10 倍。比如最终要 20 条每路请求给 200 条候选让 RRF 有足够的排名信息做融合。这个倍数关系在一个 5000 万数据的搜索项目里实测下来既是内存和耗时的可接受范围也是召回率最稳的配置。5.4drop_ratio_build不是越小越好很多人在建稀疏索引时看到drop_ratio_build以为是丢数据的比例第一反应是设为 0。我一开始也是这么干的结果索引体积暴涨查询也没变快。drop_ratio_build的真实作用是丢弃稀疏向量里权重排名靠后的维度。一条文本里那些只出现一次、权重很低的长尾词对检索贡献不大但会让倒排链非常长。设为 0.2 意味着每个向量保留权重前 80% 的维度索引压缩明显召回率几乎无损因为丢掉的都是尾部噪音。如果你发现稀疏索引内存吃紧优先调大drop_ratio_build到 0.3 试试而不是去加机器。反过来如果你的数据每个向量非零维度本身就不多比如少于 30 个再砍 20% 可能砍掉有效信息这时候老老实实设为 0.05~0.1。5.5 查询一致性刚插入的数据搜不到是正常的用 Milvus 时如果插入完立刻 search 发现结果不全先别怀疑代码。默认一致性级别下新插入数据要等 flush 或者下一个时间戳才能被查询到。我在测试环境踩过一次循环插入一个批次、立刻 search结果总是少最新几条一度以为索引没建对。解决办法写死一条经验插入大量数据后显式调用collection.flush()再 search生产环境如果需要写入后立即可查的场景把集合的一致性级别设置为较强的那一档或者接受秒级延迟在业务层做补偿。相比在代码里反复调试这个操作规范值得一开始就定下来。5.6 一个我现在的默认配置可以直接抄最后给一套可复用的默认模板。如果你刚开始做向量检索、场景没有明显倾向就用稀疏密集混合向量索引和参数直接用下面这套起步fields [ FieldSchema(nameid, dtypeDataType.VARCHAR, is_primaryTrue, max_length64), FieldSchema(nametext, dtypeDataType.VARCHAR, max_length2048), FieldSchema(namedense, dtypeDataType.FLOAT_VECTOR, dim768), FieldSchema(namesparse, dtypeDataType.SPARSE_FLOAT_VECTOR), ] index_params_dense {index_type: AUTOINDEX, metric_type: IP} index_params_sparse { index_type: SPARSE_INVERTED_INDEX, metric_type: IP, params: {drop_ratio_build: 0.2} }混合查询用 RRF每路 limit 给最终 limit 的 10 倍。这套配置我用了大半年从千万级到亿级数据量都没出过方向性错误。至于二进制向量把它当成超大底库粗筛的专用武器就好不要因为省内存就把它放到需要语义精排的业务里。这个选型边界想清楚了整个项目开展起来会顺很多。