一个被问了无数遍的问题全文检索、语义搜索、文件存储能不能各司其职又能无缝协作引子一个 ISO 9001 文档引发的思考前几天我把一份 ISO 9001:2015 质量管理体系的 PDF 扔进了本地搭建的知识库系统里。文档是英文的12 页讲的是七大质量管理原则。然后我用中文搜了一句「质量管理七大原则是什么」结果很尴尬——向量相似度只有 0.06。不是召回的问题是 Embedding 模型根本就不认识中英文之间的语义映射。这让我重新审视了一个老问题单一的检索引擎到底能不能扛住真实场景下的复杂查询答案是不能。而且解决之道不在于选一个更强的模型而在于——架构层面的分工协作。三种检索三种基因在聊架构之前先搞清楚每个组件的底层基因。Elasticsearch倒排索引的暴力美学ES 的本质是一个加强版的 CtrlF。它把文档拆成词条Term建一个巨大的倒排表quality → [doc1:pos3, doc2:pos15, doc3:pos7] management → [doc1:pos4, doc3:pos12] principles → [doc1:pos5]查询时直接用 BM25 算法算相关性分数整个过程不涉及任何 AI 模型。它的优势极其鲜明精确匹配搜 quality management一定命中包含这两个词的文档毫秒级响应倒排索引查起来几乎没有延迟高亮支持命中位置一目了然但它的天花板也很明显——只认字面不懂语义。你搜「如何降低内部成本」虽然文档里写着 bringing internal costs downES 是无法把这两者关联起来的。Milvus向量空间的语义牢笼Milvus 走的是另一条路。入库时文本被 Embedding 模型压成一个高维向量Customer focus is the primary focus of quality management │ ▼ all-MiniLM-L6-v2 │ [0.023, -0.147, 0.891, ..., 0.034] ← 384 维向量查询时用户的查询也被同一个模型编码成向量然后在向量空间里找距离最近的邻居。这就是语义搜索——不看字面看意思。问题在于向量搜索对精确关键词不敏感。搜 ISO 9001 certification 和搜 certification ISO 9001在语义空间里几乎一模一样但它没法告诉你「这两个词在文档里到底出现了几次、离得有多远」。MinIO被低估的静默守护者MinIO 的角色最简单却也最容易被忽略——它就是存文件的。不建索引不算向量不做搜索。但它的价值在于只有它手里握着原始文件。ES 和 Milvus 存的都是派生数据——索引和向量。用户最终要下载的永远是 MinIO 里的那份源文件。三合一让三个引擎各自做最擅长的事理解了各自的基因之后协作方案就呼之欲出了上传一条文档 │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ MinIO ES Milvus 存 .md 源文件 全文索引 384维向量 file_id: abc file_id: abc file_id: abc │ │ │ └───────────────┼───────────────┘ │ 同一个 file_id 串联MinIO 是仓库ES 是目录Milvus 是智能检索。三者互不依赖只靠 file_id 关联。查询时的调度逻辑查询类型ESMilvusMinIOquality management 精确搜主力不参与补链接如何降低内部成本 语义搜补元数据主力补链接certification 相关风险评估 混合搜关键词过滤语义排序补链接代码实战从零搭建入库管线核心逻辑很简单——一条文档进来分三路写入from minio import Minio from elasticsearch import Elasticsearch from pymilvus import Collection from sentence_transformers import SentenceTransformer import hashlib from pathlib import Path # 初始化三个客户端 minio_client Minio(localhost:9000, access_key..., secret_key..., secureFalse) es_client Elasticsearch(http://localhost:9200) milvus_col Collection(docs) embedder SentenceTransformer(all-MiniLM-L6-v2) def ingest_document(file_path: str): 一条文档三路写入 content Path(file_path).read_text(encodingutf-8) doc_id hashlib.md5(content.encode()).hexdigest()[:16] obj_name f{Path(file_path).stem}_{doc_id}.md # ① MinIO — 存源文件不加工原样扔进去 minio_client.fput_object(documents, obj_name, file_path) # ② Elasticsearch — 存全文索引标题 正文 元数据 es_client.index(indexdocs, body{ doc_id: doc_id, title: extract_title(content), content: content, minio_path: fdocuments/{obj_name}, created_at: 2026-06-09T12:00:00 }) # ③ Milvus — 分块 → Embedding → 写入向量 chunks split_into_chunks(content, chunk_size500) vectors embedder.encode(chunks, normalize_embeddingsTrue) milvus_col.insert([ [doc_id] * len(chunks), # file_id list(range(len(chunks))), # chunk_id chunks, # chunk_text vectors.tolist() # embedding ])注意以上是简化示例实际工程中需要处理分块策略、重试机制、事务保证等。核心思路不变三条写入路径共享同一个doc_id后续检索靠这个 ID 关联。几个关键设计决策ID 由内容哈希生成而非文件名——同一份内容无论改什么名ID 不变天然去重分块粒度 300~500 字太短语义不完整太长向量噪音大向量归一化内积IP 余弦相似度检索时更快MinIO 不做任何加工它就是存文件的连分块都不参与进阶当 ES Milvus 还不够好上面这套方案跑起来没问题但有一个硬伤两路分数不可比。ES 的 BM25 分数可以是 0 到无穷大Milvus 的向量相似度是 0 到 1。把这两路结果粗暴合并就像把马拉松成绩和跳远成绩加在一起排名——没有意义。解法一Milvus 原生 Hybrid SearchMilvus 2.4 开始支持在一个 Collection 里同时做稠密向量语义和稀疏向量关键词搜索from pymilvus import AnnSearchRequest, RRFRanker # 稠密检索 dense_req AnnSearchRequest(dense_vec, embedding, param{metric_type: IP}, limit20) # 稀疏检索BM25 向量 sparse_req AnnSearchRequest(sparse_vec, sparse_vec, param{metric_type: IP}, limit20) # 一次调用内部 RRF 融合 results collection.hybrid_search( [dense_req, sparse_req], rerankRRFRanker(k60), limit5 )这样 ES 就不需要参与检索链路了退化为元数据存储 Kibana 可视化。解法二RRF 融合排序RRFReciprocal Rank Fusion不关心原始分数只关心排名RRF(chunk, k60) 1/(60 rank_dense) 1/(60 rank_sparse)举例ChunkDense 排名Sparse 排名RRF 得分最终Process approach...321/63 1/62 0.0320Customer focus...151/61 1/65 0.0318Engagement...281/62 1/68 0.0308解法三Cross-Encoder 精排Bi-Encoder入库用的那种把 query 和文档分别编码速度快但交互不充分。Cross-Encoder 把(query, document)拼在一起编码精度高得多from sentence_transformers import CrossEncoder reranker CrossEncoder(cross-encoder/ms-marco-MiniLM-L-6-v2) # 候选池 15 条 → 逐对打分 → 取 Top 5 pairs [(query, chunk) for chunk in candidates] scores reranker.predict(pairs) final sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue)[:5]三阶段流水线粗筛Milvus Hybrid 2000万个→20条→ 融合RRF 统一排名→ 精排Cross-Encoder 20条→5条。这是目前工业界验证最充分的检索增强RAG架构。为什么不是 All-in-One你可能想问让 Milvus 既存关键词索引又存向量甚至附带存文件不是一个更简单的方案吗技术上可行但违背了一个核心原则存储与计算分离检索与索引解耦。方案优点代价All-in-One部署简单换引擎迁移全部数据一个挂了全挂三件套分立各自独立扩展换模型只重建 Milvus多一个 file_id 关联逻辑真实场景中MinIO 可能要扩到 TB 级ES 的 Kibana 要给业务团队做看板Milvus 的模型可能要 3 个月迭代一次——把它们绑在一起是给自己埋雷。总结组件核心能力技术原理适合场景MinIO存储源文件S3 兼容对象存储存一切原始文件Elasticsearch关键词检索 元数据倒排索引 BM25精确匹配、全文浏览、Kibana 可视化Milvus语义检索 混合检索向量相似度 RRF 融合模糊查询、跨语言、语义理解一条黄金法则让 MinIO 管存储ES 管关键词和元数据Milvus 管语义。三者之间只靠一个 file_id 沟通不传数据不分职责。这套架构不仅适用于文档检索把它换成图片MinIO 存原图 ES 存标签 Milvus 存 CLIP 向量、视频MinIO 存视频 ES 存字幕 Milvus 存帧向量、甚至代码仓库逻辑完全一致。本文给出的代码均为独立可运行的简化示例完整项目入库管线 搜索 API Docker Compose 部署可参考各组件官方文档组合实现。撰文时使用的技术栈Elasticsearch 8.x Milvus 2.4 MinIO FastAPI sentence-transformers
别再只用向量数据库了:ES + Milvus + MinIO 三剑合璧的文档检索实战
一个被问了无数遍的问题全文检索、语义搜索、文件存储能不能各司其职又能无缝协作引子一个 ISO 9001 文档引发的思考前几天我把一份 ISO 9001:2015 质量管理体系的 PDF 扔进了本地搭建的知识库系统里。文档是英文的12 页讲的是七大质量管理原则。然后我用中文搜了一句「质量管理七大原则是什么」结果很尴尬——向量相似度只有 0.06。不是召回的问题是 Embedding 模型根本就不认识中英文之间的语义映射。这让我重新审视了一个老问题单一的检索引擎到底能不能扛住真实场景下的复杂查询答案是不能。而且解决之道不在于选一个更强的模型而在于——架构层面的分工协作。三种检索三种基因在聊架构之前先搞清楚每个组件的底层基因。Elasticsearch倒排索引的暴力美学ES 的本质是一个加强版的 CtrlF。它把文档拆成词条Term建一个巨大的倒排表quality → [doc1:pos3, doc2:pos15, doc3:pos7] management → [doc1:pos4, doc3:pos12] principles → [doc1:pos5]查询时直接用 BM25 算法算相关性分数整个过程不涉及任何 AI 模型。它的优势极其鲜明精确匹配搜 quality management一定命中包含这两个词的文档毫秒级响应倒排索引查起来几乎没有延迟高亮支持命中位置一目了然但它的天花板也很明显——只认字面不懂语义。你搜「如何降低内部成本」虽然文档里写着 bringing internal costs downES 是无法把这两者关联起来的。Milvus向量空间的语义牢笼Milvus 走的是另一条路。入库时文本被 Embedding 模型压成一个高维向量Customer focus is the primary focus of quality management │ ▼ all-MiniLM-L6-v2 │ [0.023, -0.147, 0.891, ..., 0.034] ← 384 维向量查询时用户的查询也被同一个模型编码成向量然后在向量空间里找距离最近的邻居。这就是语义搜索——不看字面看意思。问题在于向量搜索对精确关键词不敏感。搜 ISO 9001 certification 和搜 certification ISO 9001在语义空间里几乎一模一样但它没法告诉你「这两个词在文档里到底出现了几次、离得有多远」。MinIO被低估的静默守护者MinIO 的角色最简单却也最容易被忽略——它就是存文件的。不建索引不算向量不做搜索。但它的价值在于只有它手里握着原始文件。ES 和 Milvus 存的都是派生数据——索引和向量。用户最终要下载的永远是 MinIO 里的那份源文件。三合一让三个引擎各自做最擅长的事理解了各自的基因之后协作方案就呼之欲出了上传一条文档 │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ MinIO ES Milvus 存 .md 源文件 全文索引 384维向量 file_id: abc file_id: abc file_id: abc │ │ │ └───────────────┼───────────────┘ │ 同一个 file_id 串联MinIO 是仓库ES 是目录Milvus 是智能检索。三者互不依赖只靠 file_id 关联。查询时的调度逻辑查询类型ESMilvusMinIOquality management 精确搜主力不参与补链接如何降低内部成本 语义搜补元数据主力补链接certification 相关风险评估 混合搜关键词过滤语义排序补链接代码实战从零搭建入库管线核心逻辑很简单——一条文档进来分三路写入from minio import Minio from elasticsearch import Elasticsearch from pymilvus import Collection from sentence_transformers import SentenceTransformer import hashlib from pathlib import Path # 初始化三个客户端 minio_client Minio(localhost:9000, access_key..., secret_key..., secureFalse) es_client Elasticsearch(http://localhost:9200) milvus_col Collection(docs) embedder SentenceTransformer(all-MiniLM-L6-v2) def ingest_document(file_path: str): 一条文档三路写入 content Path(file_path).read_text(encodingutf-8) doc_id hashlib.md5(content.encode()).hexdigest()[:16] obj_name f{Path(file_path).stem}_{doc_id}.md # ① MinIO — 存源文件不加工原样扔进去 minio_client.fput_object(documents, obj_name, file_path) # ② Elasticsearch — 存全文索引标题 正文 元数据 es_client.index(indexdocs, body{ doc_id: doc_id, title: extract_title(content), content: content, minio_path: fdocuments/{obj_name}, created_at: 2026-06-09T12:00:00 }) # ③ Milvus — 分块 → Embedding → 写入向量 chunks split_into_chunks(content, chunk_size500) vectors embedder.encode(chunks, normalize_embeddingsTrue) milvus_col.insert([ [doc_id] * len(chunks), # file_id list(range(len(chunks))), # chunk_id chunks, # chunk_text vectors.tolist() # embedding ])注意以上是简化示例实际工程中需要处理分块策略、重试机制、事务保证等。核心思路不变三条写入路径共享同一个doc_id后续检索靠这个 ID 关联。几个关键设计决策ID 由内容哈希生成而非文件名——同一份内容无论改什么名ID 不变天然去重分块粒度 300~500 字太短语义不完整太长向量噪音大向量归一化内积IP 余弦相似度检索时更快MinIO 不做任何加工它就是存文件的连分块都不参与进阶当 ES Milvus 还不够好上面这套方案跑起来没问题但有一个硬伤两路分数不可比。ES 的 BM25 分数可以是 0 到无穷大Milvus 的向量相似度是 0 到 1。把这两路结果粗暴合并就像把马拉松成绩和跳远成绩加在一起排名——没有意义。解法一Milvus 原生 Hybrid SearchMilvus 2.4 开始支持在一个 Collection 里同时做稠密向量语义和稀疏向量关键词搜索from pymilvus import AnnSearchRequest, RRFRanker # 稠密检索 dense_req AnnSearchRequest(dense_vec, embedding, param{metric_type: IP}, limit20) # 稀疏检索BM25 向量 sparse_req AnnSearchRequest(sparse_vec, sparse_vec, param{metric_type: IP}, limit20) # 一次调用内部 RRF 融合 results collection.hybrid_search( [dense_req, sparse_req], rerankRRFRanker(k60), limit5 )这样 ES 就不需要参与检索链路了退化为元数据存储 Kibana 可视化。解法二RRF 融合排序RRFReciprocal Rank Fusion不关心原始分数只关心排名RRF(chunk, k60) 1/(60 rank_dense) 1/(60 rank_sparse)举例ChunkDense 排名Sparse 排名RRF 得分最终Process approach...321/63 1/62 0.0320Customer focus...151/61 1/65 0.0318Engagement...281/62 1/68 0.0308解法三Cross-Encoder 精排Bi-Encoder入库用的那种把 query 和文档分别编码速度快但交互不充分。Cross-Encoder 把(query, document)拼在一起编码精度高得多from sentence_transformers import CrossEncoder reranker CrossEncoder(cross-encoder/ms-marco-MiniLM-L-6-v2) # 候选池 15 条 → 逐对打分 → 取 Top 5 pairs [(query, chunk) for chunk in candidates] scores reranker.predict(pairs) final sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue)[:5]三阶段流水线粗筛Milvus Hybrid 2000万个→20条→ 融合RRF 统一排名→ 精排Cross-Encoder 20条→5条。这是目前工业界验证最充分的检索增强RAG架构。为什么不是 All-in-One你可能想问让 Milvus 既存关键词索引又存向量甚至附带存文件不是一个更简单的方案吗技术上可行但违背了一个核心原则存储与计算分离检索与索引解耦。方案优点代价All-in-One部署简单换引擎迁移全部数据一个挂了全挂三件套分立各自独立扩展换模型只重建 Milvus多一个 file_id 关联逻辑真实场景中MinIO 可能要扩到 TB 级ES 的 Kibana 要给业务团队做看板Milvus 的模型可能要 3 个月迭代一次——把它们绑在一起是给自己埋雷。总结组件核心能力技术原理适合场景MinIO存储源文件S3 兼容对象存储存一切原始文件Elasticsearch关键词检索 元数据倒排索引 BM25精确匹配、全文浏览、Kibana 可视化Milvus语义检索 混合检索向量相似度 RRF 融合模糊查询、跨语言、语义理解一条黄金法则让 MinIO 管存储ES 管关键词和元数据Milvus 管语义。三者之间只靠一个 file_id 沟通不传数据不分职责。这套架构不仅适用于文档检索把它换成图片MinIO 存原图 ES 存标签 Milvus 存 CLIP 向量、视频MinIO 存视频 ES 存字幕 Milvus 存帧向量、甚至代码仓库逻辑完全一致。本文给出的代码均为独立可运行的简化示例完整项目入库管线 搜索 API Docker Compose 部署可参考各组件官方文档组合实现。撰文时使用的技术栈Elasticsearch 8.x Milvus 2.4 MinIO FastAPI sentence-transformers