Milvus 混合检索
Written: 2026.06第 08 章跟敲代码:上一讲:查询改写与变体生成codealong/chapters/ch08_milvus_hybrid_search。 这部分代码是本章跟敲版,用来先跑通核心闭环;完整项目源码仍以本讲后文标注的qa_core/、scripts/等路径为准。
下一讲:QAService 核心编排
1. 本讲目标
- 深入理解 Milvus Dense + Sparse Hybrid Search 的实现细节
- 掌握 Milvus 过滤表达式的构建规则和安全校验
- 理解 Reranker 在检索链路中的角色和实现
- 了解多查询变体合并去重的完整流程
📖 前置阅读:如果你不熟悉 HNSW 索引原理或想看 pymilvus 基本操作(创建 Collection、建索引、插入、搜索),请先阅读 第4讲:Milvus 索引机制与基本操作。
2. 数据准备检查
第二阶段重点讲在线检索链路,不完整展开离线知识库构建。但在进入混合检索之前,必须确认 Milvus 中已经有可检索数据、有 active 知识库版本。可以把它理解为:讲 SQL 查询前,要先确认表和测试数据已经准备好。 下面所有docker compose --env-file .env.compose ... 命令都要求项目根目录已经存在
.env.compose。仓库只提交 .env.compose.example,新环境先执行:
2.1 检查服务是否启动
milvus、mysql、api 是否处于 running/healthy 状态。如果 Milvus 或 MySQL 没起来,后面的 collection、active 版本、检索演示都会失败。
2.2 检查当前场景和 active 版本
scenario是当前讲解场景,例如enterprise_knowledgefaq_collection和doc_collection有明确名称active不是None
active=None,说明还没有激活知识库版本,在线检索不知道该查哪一批数据。
2.3 检查 Milvus Collection 是否存在
2.4 如果没有数据,先做一次预置入库
第二阶段不讲入库细节,但课前最好把 8 个业务场景一次性初始化好。 新环境首次初始化,或者之前改过 Milvus schema,使用--reset-collections 重建全部 8 个场景:
第 8 讲暂时不展开“数据如何入库”,只确认“Milvus 里已经有可检索的数据”。完整的 FAQ、文档、表格资料如何通过 rebuild_kb_version.py 构建成 active 版本,放到第 16 讲系统讲。
3. 前置知识 — BM25 算法原理
3.1 什么是 BM25
BM25(Best Matching 25) 是信息检索领域最经典的关键词匹配算法,可以看作是 TF-IDF 的改进版。 BM25 的核心思想:一个词在一篇文档中出现的频率越高,这篇文档与该词的关联度越大;但如果这个词在很多文档中都出现(如”的”、“是”),它区分文档的能力就弱。
和第 2 讲的向量相似度有什么关系? Dense 检索会把文本转成连续浮点向量,再用 Cosine / IP / L2 这类几何相似度比较;本项目的 Sparse 检索使用 Milvus BM25 Function,它虽然落在 sparse 字段里,但评分核心是词频、逆文档频率和长度归一化,不是把 sparse 字段继续套余弦相似度公式。
- IDF(逆文档频率):稀有词(如”入职”、“Webhook”)贡献高,常见词(如”的”、“这个”)贡献低
- TF 归一化:词频高不一定分数高,BM25 对 TF 做了饱和处理(出现 3 次和出现 30 次的分差不大)
- 长度归一化:长文档不天然比短文档更有优势
3.2 BM25 的打分直觉
BM25 不是简单数关键词出现了几次。它会同时考虑三个问题:- 查询词有没有出现:用户问”入职材料”,文档里出现”入职”和”材料”,相关性就会上升。
- 这个词是否有区分度:如果”材料”在很多文档里都出现,它的贡献会被降低;如果”入职”只在少数 HR 文档里出现,它的贡献会更高。
- 文档是否过长:一篇很长的制度汇编可能包含很多词,但不一定比一条短 FAQ 更精准,所以 BM25 会做文档长度归一化。
BM25 更喜欢”命中了用户关键词、关键词又比较少见、文本长度还比较克制”的文档。假设用户问题是:
- A 同时命中”新人/入职/提交/材料”,而且内容集中,得分最高。
- B 命中”材料”,但没有命中”入职”,只能算部分相关。
- C 可能语义上属于公司制度,但没有命中关键查询词,BM25 分数低。
3.3 BM25 使用示例
如果不用 Milvus 内置 BM25,在 Python 里可以用rank_bm25 快速理解它的工作方式:
- 示例里已经手工把文本切成了空格分词,真实中文文档需要稳定的中文分词器。
- 示例每次启动都重新构建
BM25Okapi(tokenized_docs),真实系统不能每次查询都重建全量索引。 - 示例只有 3 条文档,真实项目会持续新增、删除、重建 chunk,需要知道哪些旧文本要移除、哪些新文本要加入。
- 示例只返回 BM25 分数,真实 Hybrid Search 还要和 Dense 向量检索结果合并、去重、排序。
rank_bm25 做在线检索,而是让 Milvus 在服务端完成中文分词、BM25 sparse 向量生成、索引和检索。
3.4 Dense vs Sparse 互补
回顾第 2 讲的内容,这里做更深入的对比:| Dense(BGE-M3 Embedding) | Sparse(BM25) | |
|---|---|---|
| 表示方式 | 1024 维浮点数向量 | 稀疏向量(大部分维度为 0) |
| 强项 | 语义相似、同义词、改写 | 精确关键词、专业术语、编号 |
| 弱项 | 专业术语可能召回不精准 | 不理解语义,“改密码”≠“重置密码” |
| 计算 | 需要 Embedding 模型(GPU 友好) | 纯统计计算(CPU 即可) |
| 例子 | ”入职需要什么” → 召回”报到材料" | "HS 编码 8471.30” → 精确匹配 |
4. Milvus 混合检索实现
4.1 双向量字段的 Schema
在 Milvus 中,每个 collection 有两个向量字段:4.2 LangChain Milvus 初始化
embedding_function:当调用add_documents()写入数据时,LangChain 自动调用 BGE-M3 对text字段生成 Dense 向量builtin_function:Milvus 2.5.x 可用的服务端内置函数,在写入时自动对text字段执行中文分词 + BM25 编码,生成 Sparse 向量vector_field=["dense", "sparse"]:声明两个向量字段,相似度搜索时会同时使用两者,Milvus 内部自动加权融合分数auto_id=False:使用入库时生成的稳定 chunk_id 作为主键。这使得文档更新时可以按 IDdelete(ids=old_ids)再add_documents(new_chunks)
4.3 BM25 中文分词配置
analyzer_params={"type": "chinese"} 确保 BM25 使用中文分词器(而不是默认的英文空格分词)。这样”企业知识库智能问答”会被正确拆分为”企业/知识库/智能/问答”,而不是按空格当成一个整体。
4.4 Milvus 内置 BM25 的优势
本项目没有在 Python 侧自己维护 BM25 索引,而是使用 Milvus 2.5.x 的BM25BuiltInFunction。这样做有几个工程优势:
| 方案 | 问题 |
|---|---|
| Python 自己跑 BM25 | Demo 很简单,但生产化时还要补中文分词、索引缓存、删除/新增 chunk 更新、BM25 与 Dense 结果合并去重 |
MySQL LIKE / 全文索引 | 可以做关键词匹配,但无法和 Dense 向量检索在同一套向量检索流程里融合 |
| Milvus 内置 BM25 | 文本写入时自动生成 sparse 向量,查询时自动生成 sparse query,并能和 dense 检索统一融合 |
- 入库简单:
add_documents()只写入文本和 metadata,Milvus 服务端自动从text字段生成sparse向量。 - 查询简单:用户输入 query 后,Milvus 自动生成 sparse query representation,不需要业务代码手动调用 BM25 编码器。
- 融合自然:Dense 和 Sparse 在一次 Hybrid Search 请求里完成,避免 Python 侧分别查两套系统再手动 merge。
- 数据一致:文档文本、dense 向量、sparse 向量、metadata 都在同一个 collection 中,版本过滤、租户过滤、source 过滤可以一起生效。
- 更适合增量重建:删除旧 chunk、写入新 chunk 后,BM25 sparse 字段由 Milvus 重新生成,不需要额外维护外部倒排索引。
- 中文配置集中:中文分词器通过
analyzer_params={"type": "chinese"}固定在 collection schema / function 配置里,避免不同脚本分词口径不一致。
4.5 Hybrid Search 的分数融合
当同时使用 Dense 和 Sparse 检索时,Milvus 内部如何融合两者的分数?4.6 一次 Hybrid Search 的完整时序
把前面的 dense、sparse、BM25、过滤和融合串起来,一次检索大致是这样发生的: 口语化理解:Dense 负责“意思像不像”,BM25 负责“关键词有没有精准命中”,Milvus 负责在同一个 collection 里把两种召回结果按权重融合,再把符合版本、租户、分类过滤条件的候选返回给 RAG 链路。
5. 过滤表达式构建
5.1 为什么需要过滤表达式
向量检索是在整个 collection 中找最相似的内容。但实际业务中,我们需要限制搜索范围:- 同一个 collection 中存了多个场景的数据 → 只搜当前场景的
- 同一个场景中有多个知识库版本 → 只搜 active 版本的
- 开启了数据隔离 → 只搜当前租户/数据集的
- 前端选择了业务分类 → 只搜该分类的
5.2 build_source_expr() 实现
5.3 拼接后的实际表达式
对于一次具体的查询,过滤表达式可能长这样:5.4 安全转义
6. 多查询变体检索与合并
6.1 search_many() 的完整流程
6.2 文档去重逻辑
6.3 Reranker 重排实现
- 向量检索(Bi-Encoder):O(n) 次向量比较,n=候选数,每次都是快速的向量内积
- Reranker(CrossEncoder):O(k) 次 Transformer 前向传播,k=候选数(通常 20-50),每次都需要模型推理
7. FAQ 与文档分集合设计
7.1 FAQ 分层检索策略
7.2 为什么分集合
7.3 两层检索的工作流
7.4 FAQ 的高置信直出
8. 连接管理与数据库初始化
8.1 Milvus 数据库创建
MILVUS_DATABASE 默认为空,也就是使用 Milvus 默认 database;如果后续需要按环境或租户做更强隔离,本机 API 调试写在 .env,Docker Compose 部署写在 .env.compose。
8.2 显式连接别名注册
langchain-milvus wrapper 前显式注册 PyMilvus ORM 连接。这样既保留 LangChain VectorStore 抽象,也避免底层 ORM API 找不到连接。这不是补丁式兼容,而是当前 langchain-milvus 底层仍依赖 PyMilvus ORM alias 的工程边界。
9. 本讲实践闭环
| 项目 | 内容 |
|---|---|
| 本讲类型 | 项目实现 |
| 实践产物 | retrieval/store.py、filters.py、ranking.py 的 Hybrid Search 能力 |
| 是否进入最终项目 | 是 |
| 验收方式 | 对已入库场景执行检索,返回 FAQ/Doc 命中,metadata 包含版本和过滤字段 |
| 后续落点 | 第 10 讲把检索结果放入完整 RAG Pipeline |
9.1 本讲从 0 到 1 实现闭环
实现完成后,相关代码结构应该是下面这张图:9.1.1 :封装 Milvus BM25 Function
目标:让 Milvus 服务端根据text 字段自动生成 sparse 向量。
来源:真实代码节选,见 qa_core/retrieval/milvus_compat.py::bm25_function()。
9.1.2 :实现过滤表达式
目标:所有检索都必须限制在当前场景、版本、租户、数据集和分类内。 来源:真实代码逻辑压缩版,对应qa_core/retrieval/filters.py::build_source_expr()。
source_filter 必须先过白名单,字符串值必须转义;scenario_id、tenant_id、dataset_id、visibility、allowed_roles 不是在这里手写,而是由 DataScope.expr_clauses() 统一追加。
9.1.3 :实现 MilvusHybridStore 懒加载
目标:第一次检索或入库时才创建 LangChain Milvus store,并做 schema 校验。 来源:真实代码逻辑压缩版,对应qa_core/retrieval/store.py::MilvusHybridStore.store。
nq [0] is invalid。validate_hybrid_schema() 会检查 text analyzer、dense 字段、sparse 字段是否为 BM25 Function 输出,以及是否存在 text -> sparse 的 BM25 Function。
9.1.4 :实现单查询检索和多查询合并
来源:真实代码逻辑压缩版,对应qa_core/retrieval/store.py::search() 和 search_many()。
nq [0] is invalid,项目不会降级到 dense-only,而是明确提示 collection schema 缺少正确 BM25 Function,需要 --reset-collections 重建。
9.1.5 :验收检索闭环
前置:当前场景已有 active 版本和 Milvus collection。 来源:命令行验收,对应tests/test_retrieval_and_prompt.py。
| 验证项 | 验证方式 | 期望结果 |
|---|---|---|
| BM25 Function | 查看 collection schema | sparse 是 BM25 Function 输出字段 |
| 过滤表达式 | 单测检查 expr 字符串 | 包含 scenario_id、kb_version、tenant_id、source |
| 单查询检索 | 用已入库问题检索 | 返回 FAQ 或文档候选 |
| 多查询合并 | 传入多个 variants | 同一文档去重,保留最高分 |
| Reranker | 开启 rerank | 候选顺序可被重排 |
| schema 兼容性 | 旧 collection | 启动或检索前明确报错,提示重建 |
| 空 query/k<=0 | 传入空 query 或 k=0 | 返回空结果,不访问 Milvus |
nq[0] 根因 | 复用旧 sparse schema | 抛出重建 collection 的明确错误 |
- 过滤表达式包含
kb_version、tenant_id、dataset_id、source等字段。 - 多查询命中同一文档时能去重。
- Reranker 只对候选 Top-K 精排。
- 页面或检索脚本能返回 FAQ/Doc 命中,metadata 不缺关键字段。
10. 重点掌握
| 优先级 | 内容 | 原因 |
|---|---|---|
| ★★★ 必会 | Milvus Hybrid Search 的双向量字段 Schema:dense(BGE-M3 Embedding)+ sparse(BM25 BuiltInFunction) | 混合检索的底层实现基础 |
| ★★★ 必会 | 过滤表达式构建(build_source_expr):source + kb_version + 数据隔离四字段拼成 Milvus expr | 确保检索不跨场景、不跨版本、不跨租户 |
| ★★★ 必会 | FAQ/文档分集合设计:FAQ 高置信直出(不调 LLM),FAQ 低分→文档检索 | 分层检索的核心架构 |
| ★★ 理解 | BM25 算法核心思想、简单使用示例,以及 Milvus 内置 BM25 的工程优势 | 理解 Sparse 检索的原理和本项目为什么不手写 BM25 |
| ★★ 理解 | search_many() 多查询变体合并流程:各自检索→按文档去重保留最高分→统一 Rerank | 多查询变体如何产生最终候选 |
| ★★ 理解 | Reranker(CrossEncoder)重排的实现和代价 | 理解为什么只对 Top-K 做重排 |
| ★ 了解 | 白名单校验 + 安全转义(escape_expr_value)防止注入 | 安全设计了解即可 |
| ★ 了解 | 显式连接别名注册的原因 | 连接适配,了解即可 |
11. 本讲小结
- BM25 是经典的词频-逆文档频率检索算法,擅长精确关键词匹配;Milvus 内置 BM25 可以自动生成 sparse 向量并和 dense 检索统一融合
- Milvus Hybrid Search 同时使用 Dense 向量(语义)和 Sparse 向量(关键词),默认 50:50 加权
- 过滤表达式将 source、kb_version、tenant_id 等拼成 Milvus expr,在执行检索前缩小搜索范围
- 白名单校验 + 安全转义防止无效值或注入攻击进入 Milvus 表达式
- 多查询变体分别检索后按文档去重合并,保留最高分
- Reranker(CrossEncoder)对候选做精排,代价高但精度高,只对 Top-K 候选使用
- FAQ/文档分集合是实现分层检索和动态阈值的基础

