
1. 一个搜索需求引发的思考为什么单靠向量不够早先我在做知识库问答系统的时候遇到一个特别典型的检索问题。用户问的是“Milvus 连接超时怎么排查”系统返回的结果里前几条居然全是“MinIO 连接超时”的文档。从语义上看MinIO 和 Milvus 都是分布式系统里的组件“连接超时”也是同一个词向量距离确实很近但用户真正想看的其实是排在第 7 条、包含“Etcd 连接超时”的那篇故障手册。这个案例让我意识到纯向量检索有一个天然盲区它擅长理解“语义相似”但对“精确词命中”和“词频权重”非常迟钝。用户在搜一个具体产品名、一个报错关键字的时候往往更希望看到包含这个精确词的文档。这个需求其实就是混合检索要解决的问题。我当时的做法是在 Milvus 2.x 上同时启用稠密向量召回和稀疏向量召回再通过 Ranker 把两路结果融合最后用 Rerank 模型优化排序。整套链路用 v2 类型的 API 就能跑通不用自己维护两套数据库。你可能已经听说过“Dense Sparse Rerank”是 RAG 落地里比较稳的组合但真正上手时会发现Milvus 的 v2 API 里关于混合检索的配置项非常多BM25 字段、稀疏索引、Ranker 策略、多向量搜索每一步都藏着坑。这篇文章我不打算只讲概念我会把从部署 Milvus、设计 Collection Schema、写入数据、执行双路召回、RRF 融合、Rerank 重排到 Java 里用 LangChain4j 集成的完整链路都过一遍。适合两类读者一类是刚接触 Milvus 2.x、想在项目里引入混合检索但还没找到完整示例的开发者另一类是用 Java 做 RAG 应用、想搞清楚 LangChain4j 到底能不能直接支撑混合检索的工程师。看完之后你至少能清楚回答三个问题v2 API 和混合检索的关系是什么Collection 里 BM25 字段到底怎么设计多路召回之后分数怎么融合才合理。2. 先把 Milvus 2.x 跑起来部署方式与 v2 API 到底指什么2.1 v2 API 的两个理解层面很多人在查资料时会被“v2 API”这个说法绕晕因为它在 Milvus 语境里其实有两层含义。第一层是指 Milvus 2.x 整体 API 体系区别于 1.x。Milvus 1.0 时代无论有没有图形界面主要交互方式都是 gRPC 和旧版 SDK而 2.x 引入了 MilvusClient、统一的 Collection 概念、向量索引和标量索引分离这是架构级的变化。第二层是指 Milvus 提供的 RESTful v2 接口也就是/v2/vectordb/...这一组 HTTP 端点可以用来做建表、插入、搜索等操作对非 Python/Java 技术栈特别友好。我这次主要用 Python 的pymilvus来讲因为它在混合检索相关的 API 上覆盖最全、示例最多。但要注意pymilvus2.4 和 2.5 两个小版本在混合检索的写法上有差异尤其是 SearchRequest、RRFRanker 这些类2.4 开始引入2.5 才稳定。所以如果你的项目在生产环境我建议直接用 2.5.x 的 SDK并且锁版本别用latest。2.2 Docker Compose 快速部署 standaloneMilvus 本身不依赖容器但日常开发和测试最省心的方式还是 Docker Compose 跑 standalone 模式。它依赖三个组件etcd 存元数据、MinIO 存数据文件、Milvus standalone 本体做查询计算。我用的是下面这个 compose 文件版本固定、数据目录挂载在本地方便随时清理重建。version: 3.5 services: etcd: container_name: milvus-etcd image: quay.io/coreos/etcd:v3.5.18 environment: - ETCD_AUTO_COMPACTION_MODErevision - ETCD_AUTO_COMPACTION_RETENTION1000 - ETCD_QUOTA_BACKEND_BYTES4294967296 - ETCD_SNAPSHOT_COUNT50000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd command: etcd -advertise-client-urlshttp://etcd:2379 -listen-client-urlshttp://0.0.0.0:2379 --data-dir /etcd healthcheck: test: [CMD, etcdctl, endpoint, health] interval: 30s timeout: 20s retries: 3 minio: container_name: milvus-minio image: minio/minio:RELEASE.2024-12-18T13-15-44Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data command: minio server /minio_data --console-address :9001 healthcheck: test: [CMD, curl, -f, http://localhost:9000/minio/health/live] interval: 30s timeout: 20s retries: 3 standalone: container_name: milvus-standalone image: milvusdb/milvus:v2.5.21 command: [milvus, run, standalone] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus ports: - 19530:19530 - 9091:9091 depends_on: - etcd - minio启动方式很简单docker compose up -d等容器都变成 healthy 之后就可以用pymilvus的MilvusClient连http://localhost:19530了。19530是 Milvus 的默认端口RESTful v2 接口默认也走这个端口路径前缀是/v2/vectordb/。如果你自己改了milvus.yaml里的端口映射记得两侧保持一致。2.3 镜像拉取报错的常规处理方法这个环节现在几乎必踩坑执行docker compose pull或者启动时经常出现类似error response from daemon: get https://registry-1.docker.io/v2/: net/http: request canceled或者api error: 529 overloaded的报错。前者是网络链路问题后者是镜像仓库服务端过载限流都属于拉取镜像时的常见问题并不是 Milvus 本身的问题。我的处理经验是先检查 Docker 的镜像加速配置。Linux 下编辑/etc/docker/daemon.jsonWindows/Mac 在 Docker Desktop 的设置里找到 Docker Engine 配置加上镜像源{ registry-mirrors: [ https://docker.m.daocloud.io, https://dockerproxy.com ] }改完重启 Docker 服务再重新执行 compose up。要注意.m.daocloud.io这类公共镜像源偶尔也会不稳定所以配置多个备选源更保险。如果某个镜像始终拉不下来另一个实用技巧是找一台网络环境正常的机器把镜像docker save成 tar 包再拷贝到目标机器docker load。这个办法虽然笨但能绕开仓库限流问题。3. Schema 设计才是混合检索的关键向量字段与 BM25 字段如何共存3.1 为什么选择内置 BM25 函数而不是外部稀疏模型在 Milvus 里做混合检索稀疏向量有两个来源。一是用外部模型生成稀疏向量比如 BGE-M3 和 SPLADE它们会根据语义和词表给出稀疏表示适合多语言和深层语义场景。二是直接用 Milvus 2.5 内置的 BM25 函数由 Collection 在写入数据时基于文本字段自动生成稀疏向量。我这次选内置 BM25 函数。原因很直接它不需要额外跑模型、不需要为稀疏向量准备单独的服务Milvus 自己会对文本字段做分词和 TF-IDF 统计索引和查询都是原生支持。对很多知识库和文档检索场景来说BM25 的词法匹配能力已经足够补足 dense 向量的短板了。缺点也很明显内置 BM25 的分词效果取决于 Milvus 的 Analyzer 配置对中文不如英文友好这一点我放到最后一节单独讲。3.2 创建 Collection 的完整代码下面这段代码创建了一个名为doc_chunks的 Collection里面既有稠密向量字段也有由 BM25 函数生成的稀疏向量字段。from pymilvus import MilvusClient, DataType, Function, FunctionType client MilvusClient(urihttp://localhost:19530) schema client.create_schema(auto_idTrue, enable_dynamic_fieldTrue) schema.add_field(field_nameid, datatypeDataType.INT64, is_primaryTrue) schema.add_field(field_nametext, datatypeDataType.VARCHAR, max_length2000) schema.add_field( field_namedense_vector, datatypeDataType.FLOAT_VECTOR, dim1024 ) schema.add_function( Function( namebm25_fn, function_typeFunctionType.BM25, input_field_names[text], output_field_names[sparse_vector], ) ) schema.add_field( field_namesparse_vector, datatypeDataType.SPARSE_FLOAT_VECTOR ) client.create_collection( collection_namedoc_chunks, schemaschema )有几个细节值得注意。FunctionType.BM25要求输入字段是 VARCHAR 类型所以text字段必须有max_length。sparse_vector字段的DataType.SPARSE_FLOAT_VECTOR不需要指定维度因为稀疏向量的维度可以不一致。BM25 函数会根据text字段自动计算sparse_vector的值这意味着插入数据时你不需要手动提供稀疏向量Milvus 会自动生成。3.3 索引与加载两个向量字段的索引策略差异Collection 建好之后下一步是创建索引。稠密向量和稀疏向量的索引类型完全不一样这一点不能混。稠密向量我用 HNSW距离度量用IP内积。如果你的向量没有做归一化也可以用COSINE但内积在向量已归一化时性能更好。index_params client.prepare_index_params() index_params.add_index( field_namedense_vector, index_typeHNSW, metric_typeIP, params{M: 16, efConstruction: 200} ) index_params.add_index( field_namesparse_vector, index_typeSPARSE_INVERTED_INDEX, metric_typeIP ) client.create_index(doc_chunks, index_params) client.load_collection(doc_chunks)稀疏向量的索引选择是SPARSE_INVERTED_INDEX度量类型也用IP因为 BM25 内部的打分本来就是内积形式。加载 Collection 之后就可以写入数据了。写入时只传text和dense_vectorsparse_vector由 BM25 函数自动算出来data [ { text: How to configure TLS in Milvus standalone, dense_vector: [0.012, 0.034, ...] }, { text: MinIO connection timeout troubleshooting, dense_vector: [0.045, 0.021, ...] } ] client.insert(collection_namedoc_chunks, datadata)这里有一个容易被忽略的问题dense_vector的维度和生成模型的输出维度必须一致。如果你用 Qwen Embedding 或 BGE 系列模型要先确认模型输出的向量维度是 1024 还是 1536再配置dim。一旦 Collection 创建完成dim就不能改了只能重建 Collection。4. 混合检索的核心调用多路召回、Ranker 融合与重排4.1 先手动跑通双路召回在直接调用原生 Ranker 之前我建议先手动跑一遍双路召回把每一路的结果都打印出来看一下。这样能直观感受 dense 和 sparse 的差异也能帮你排查数据写入和索引配置是否有问题。第一路是普通向量搜索请求里传的是 query 的稠密向量query_text milvus tls configuration query_dense embed_query(query_text) # 调用 embedding 模型生成 1024 维向量 dense_res client.search( collection_namedoc_chunks, data[query_dense], anns_fielddense_vector, limit20, output_fields[id, text] )第二路是全文搜索直接把原始查询文本传给稀疏字段Milvus 会用同一个 BM25 函数计算查询文本的稀疏向量sparse_res client.search( collection_namedoc_chunks, data[query_text], anns_fieldsparse_vector, limit20, output_fields[id, text] )如果你用的 pymilvus 版本对data传字符串支持不好也可以手动构造稀疏查询向量传入格式是字典sparse_query { indices: [0, 5, 12, 18], values: [0.8, 0.4, 0.2, 0.1] }执行完这两路搜索后你大概率会发现一个现象dense 召回的结果语义接近但精确词命中率不高sparse 召回的结果能准确命中包含“milvus”和“tls”的文档但可能漏掉同义改写的内容。这正好说明了两者互补。4.2 RRF 融合原理与手动实现两路召回的结果都是独立的文档列表每个文档有自己的 score但这两个 score 的尺度完全不同。dense 向量分数通常是内积值范围可能从零点几到十几BM25 分数也可能在几十分不等。直接把分数相加没有意义所以业界最常用的融合方法是 RRFReciprocal Rank Fusion倒数排名融合。RRF 不看具体分数只看每条文档在各自列表里的排名。公式很简单score(doc) sum( 1 / (k rank(doc)) )其中rank从 1 开始k是一个平滑常数默认 60。意思就是如果某条文档在一路检索里排第 1它在这路里拿到的贡献就是1/(601)如果排第 10就是1/(6010)。两路排名都不错的文档会拿到最高的融合分。它的好处是不需要做分数归一化对 dense 和 sparse 这种分数尺度完全不同的场景特别合适。手动实现 RRF 也很简单不依赖 Milvus 原生支持from collections import defaultdict K 60 rrf_scores defaultdict(float) for rank, item in enumerate(dense_res[0]): pk item[id] rrf_scores[pk] 1.0 / (K rank 1) for rank, item in enumerate(sparse_res[0]): pk item[id] rrf_scores[pk] 1.0 / (K rank 1) top_results sorted(rrf_scores.items(), keylambda x: x[1], reverseTrue)[:5]我建议你把这个手动版本放进调试脚本里跑一遍把每个文档的 dense 排名、sparse 排名、融合分都打印出来。理解了 RRF 的行为之后再切换到 Milvus 原生 Ranker你会更容易判断输出结果到底合理不合理。4.3 用 Milvus 原生 Ranker 完成一次真正的混合检索手动 RRF 虽然简单但每搜一次都要把全部候选结果拉到应用层数据量大了之后网络和内存开销都不小。Milvus 2.4 之后的 v2 API 提供了原生 Ranker服务端直接完成多路召回和融合只返回最终 topK效率高很多。pymilvus 里用SearchRequest定义每一路检索然后把多个请求一起传给client.search再指定RRFRankerfrom pymilvus import SearchRequest, RRFRanker dense_req SearchRequest( data[query_dense], anns_fielddense_vector, limit20, search_params{metric_type: IP} ) sparse_req SearchRequest( data[query_text], anns_fieldsparse_vector, limit20, search_params{metric_type: IP} ) hybrid_res client.search( collection_namedoc_chunks, reqs[dense_req, sparse_req], rankerRRFRanker(k60), limit5, output_fields[id, text] )需要注意几个细节。RRFRanker(k60)里的k就是 RRF 公式里的平滑常数默认 60一般取值在 40 到 80 之间都合理。limit参数在这里有两层作用每路SearchRequest里的limit控制这一路召回多少个候选最外层client.search的limit控制最终返回多少条。如果最外层想返回 5 条每一路候选建议至少给到 20 条否则 RRF 可用的排名信息太少融合效果会退化。除了 RRFMilvus 还支持WeightedRanker也就是给每路分数乘一个权重再求和。但权重版要求各路分数本身可比否则你根本不知道该给谁分配更大权重。我实际用下来默认无脑选 RRF 基本不会错只有在明确知道某一路召回质量明显更好、需要加大其影响时才考虑 WeightedRanker。4.4 检索之后的重排RerankRRF 融合只是把两路结果合并排序它并没有改变“每个文档和 query 之间到底有多匹配”这件事。如果你对排序质量有更高要求可以在这之后加一层交叉编码器重排。交叉编码器模型会把 query 和候选文档拼接成一个输入计算一个精细的相关性分数比向量检索的双塔模型更准缺点是要对每一条候选都推理一次成本略高。我用的是BAAI/bge-reranker-v2-m3加载和调用都非常简单from sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-v2-m3) candidates [item[entity][text] for item in hybrid_res[0]] pairs [(query_text, text) for text in candidates] scores reranker.predict(pairs) reranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue)通常做法是先用混合检索拿到 20 到 50 条候选再用 Rerank 模型刷一遍取前 5 条交给下游。为什么不能一开始就用 Rerank因为 Rerank 是逐条打分候选集太大推理时间会线性增长而向量召回可以在毫秒级排除掉大量无关文档。5. Java 场景怎么落地LangChain4j 与 Milvus 混合检索5.1 LangChain4j 的 Milvus 集成边界如果你的服务端是 Java大概率会用到 LangChain4j。LangChain4j 提供了langchain4j-milvus模块核心类叫MilvusEmbeddingStore可以配置 Milvus 地址、Collection 名称、向量维度、用户名密码等使用起来很简洁。但这里要泼一盆冷水MilvusEmbeddingStore天然只做稠密向量检索它没有直接暴露 Milvus 的 BM25 字段和原生 Ranker 配置。也就是说你通过 LangChain4j 拿到的开箱即用能力是单路 dense 检索不是混合检索。要从 Java 端实现“混合检索 重排”不能指望 LangChain4j 默认支持。我见过的可行路径有两种下面分别说。5.2 Java 端混合检索的工程路径第一种路径是用 milvus-sdk-java 直接调混合检索 API把结果拿回来后再手动接入 LangChain4j 的Content和ContentRetriever。milvus-sdk-java 2.5.x 对多路搜索和 Ranker 的支持在逐渐完善但不同小版本的 API 变化比较频繁最稳妥的方式是直接调用 RESTful v2 接口用 Java 的 HTTP 客户端发 POST 请求到/v2/vectordb/collections/search请求体里带上多路向量字段和 rank 策略。第二种路径是保留 LangChain4j 的MilvusEmbeddingStore做常规召回再用 LangChain4j 内置的ReRankingContentRetriever在外面包一层重排逻辑。这种方案虽然本质上还是单路 dense 召回但因为补了重排效果往往比纯向量排序好不少。如果你想在 Java 生态里快速验证 RAG 效果这条路性价比最高。在实际项目里我倾向于这样组合Java 服务里同时引入milvus-sdk-java和 LangChain4j。milvus-sdk-java 负责调用混合检索接口拿到候选结果LangChain4j 只负责把结果转换成统一的Content列表再交给ContentRetriever和 LLM。这样既享受到原生混合检索的性能又保留了 LangChain4j 的 RAG 抽象两边各干各擅长的活。6. 实测效果与调优哪些参数真正影响召回质量6.1 一组直观的对比数据我在一个内部文档集上做了一个小实验文档量不大大约 800 段内容涵盖 Milvus、MinIO、etcd 的安装、配置和故障排查。查询词用“milvus tls configuration”分别跑纯 dense、纯 BM25、混合检索 Rerank记录第一名的文档内容。纯 dense 的第一名是“How to configure TLS in MinIO”相关文档因为“TLS”和“configuration”这两个词的向量很强把 Milvus 这个词的差异压下去了。纯 BM25 的第一名是“Milvus TLS configuration guide”精确命中两个关键词但如果是同义改写类查询比如用户问“怎么开加密连接”纯 BM25 就很吃力。混合检索 Rerank 的结果也是“Milvus TLS configuration guide”而且第二名开始排序也更合理。这个对比非常直观地说明了混合检索的价值dense 保证语义泛化能力sparse 保证精确词不被淹没。6.2 调优点K 值、候选集、权重、索引参数混合检索的效果不是配置完就一劳永逸有几个参数影响了最终表现。第一个是 RRF 的 k 值。k 越大排名的差异越被拉平极端情况下每条文档的融合分只取决于“进了几路候选”跟具体排名无关。k 越小排名越靠前的文档优势越明显。我的经验是候选集大、文档多的时候k 可以给到 60 到 80候选集小、topN 少的时候k 设为 30 到 40 效果更好。第二个是每路候选集的 size。假设最终只要 top5每路只召回 5 条那么末