一个被问了无数遍的问题:全文检索、语义搜索、文件存储,能不能各司其职,又能无缝协作?
引子:一个 ISO 9001 文档引发的思考
前几天我把一份 ISO 9001:2015 质量管理体系的 PDF 扔进了本地搭建的知识库系统里。文档是英文的,12 页,讲的是七大质量管理原则。
然后我用中文搜了一句:「质量管理七大原则是什么?」
结果很尴尬——向量相似度只有 0.06。不是召回的问题,是 Embedding 模型根本就不认识中英文之间的语义映射。
这让我重新审视了一个老问题:单一的检索引擎到底能不能扛住真实场景下的复杂查询?
答案是不能。而且解决之道,不在于选一个更强的模型,而在于——架构层面的分工协作。
三种检索,三种基因
在聊架构之前,先搞清楚每个组件的底层基因。
Elasticsearch:倒排索引的暴力美学
ES 的本质是一个加强版的 Ctrl+F。它把文档拆成词条(Term),建一个巨大的倒排表:
"quality" → [doc1:pos3, doc2:pos15, doc3:pos7] "management" → [doc1:pos4, doc3:pos12] "principles" → [doc1:pos5]查询时直接用 BM25 算法算相关性分数,整个过程不涉及任何 AI 模型。它的优势极其鲜明:
精确匹配:搜 "quality management",一定命中包含这两个词的文档
毫秒级响应:倒排索引查起来几乎没有延迟
高亮支持:命中位置一目了然
但它的天花板也很明显——只认字面,不懂语义。你搜「如何降低内部成本」,虽然文档里写着 "bringing internal costs down",ES 是无法把这两者关联起来的。
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 关联。
查询时的调度逻辑:
| 查询类型 | ES | Milvus | MinIO |
|---|---|---|---|
| "quality 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="...", secure=False) 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(encoding="utf-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(index="docs", body={ "doc_id": doc_id, "title": extract_title(content), "content": content, "minio_path": f"documents/{obj_name}", "created_at": "2026-06-09T12:00:00" }) # ③ Milvus — 分块 → Embedding → 写入向量 chunks = split_into_chunks(content, chunk_size=500) vectors = embedder.encode(chunks, normalize_embeddings=True) 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 Search
Milvus 2.4 开始支持在一个 Collection 里同时做稠密向量(语义)和稀疏向量(关键词)搜索:
from pymilvus import AnnSearchRequest, RRFRanker # 稠密检索 dense_req = AnnSearchRequest(dense_vec, "embedding", param={"metric_type": "IP"}, limit=20) # 稀疏检索(BM25 向量) sparse_req = AnnSearchRequest(sparse_vec, "sparse_vec", param={"metric_type": "IP"}, limit=20) # 一次调用,内部 RRF 融合 results = collection.hybrid_search( [dense_req, sparse_req], rerank=RRFRanker(k=60), limit=5 )这样 ES 就不需要参与检索链路了,退化为元数据存储 + Kibana 可视化。
解法二:RRF 融合排序
RRF(Reciprocal Rank Fusion)不关心原始分数,只关心排名:
RRF(chunk, k=60) = 1/(60 + rank_dense) + 1/(60 + rank_sparse)
举例:
| Chunk | Dense 排名 | Sparse 排名 | RRF 得分 | 最终 |
|---|---|---|---|---|
| "Process approach..." | 3 | 2 | 1/63 + 1/62 =0.0320 | 🥇 |
| "Customer focus..." | 1 | 5 | 1/61 + 1/65 = 0.0318 | 🥈 |
| "Engagement..." | 2 | 8 | 1/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), key=lambda x: x[1], reverse=True)[: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