Skip to main content

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 检查服务是否启动

重点看 milvusmysqlapi 是否处于 running/healthy 状态。如果 Milvus 或 MySQL 没起来,后面的 collection、active 版本、检索演示都会失败。

2.2 检查当前场景和 active 版本

期望看到:
  • scenario 是当前讲解场景,例如 enterprise_knowledge
  • faq_collectiondoc_collection 有明确名称
  • active 不是 None
如果 active=None,说明还没有激活知识库版本,在线检索不知道该查哪一批数据。

2.3 检查 Milvus Collection 是否存在

列表中应该包含当前场景的 FAQ/Doc collection。例如企业知识场景通常需要看到:

2.4 如果没有数据,先做一次预置入库

第二阶段不讲入库细节,但课前最好把 8 个业务场景一次性初始化好。 新环境首次初始化,或者之前改过 Milvus schema,使用 --reset-collections 重建全部 8 个场景:
如果之前已经存在知识库,只是资料内容变化,批量刷新 8 个场景时不要删除 collection:
批量脚本会逐个为 8 个冻结场景创建新知识库版本、强制入库、执行质量门禁并激活。这样第 8 讲切换任意业务场景时,Milvus 都有可检索数据。 如果只想补一个场景,例如企业知识场景,也可以执行单场景预置:
如果之前改过 Milvus schema,或者遇到 BM25 Function / sparse 字段不兼容,需要删除旧 collection 后重建:
本讲边界:
第 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 不是简单数关键词出现了几次。它会同时考虑三个问题:
  1. 查询词有没有出现:用户问”入职材料”,文档里出现”入职”和”材料”,相关性就会上升。
  2. 这个词是否有区分度:如果”材料”在很多文档里都出现,它的贡献会被降低;如果”入职”只在少数 HR 文档里出现,它的贡献会更高。
  3. 文档是否过长:一篇很长的制度汇编可能包含很多词,但不一定比一条短 FAQ 更精准,所以 BM25 会做文档长度归一化。
用口语表达就是:
BM25 更喜欢”命中了用户关键词、关键词又比较少见、文本长度还比较克制”的文档。
假设用户问题是:
有三条候选文本:
BM25 的排序通常会是:
原因:
  • A 同时命中”新人/入职/提交/材料”,而且内容集中,得分最高。
  • B 命中”材料”,但没有命中”入职”,只能算部分相关。
  • C 可能语义上属于公司制度,但没有命中关键查询词,BM25 分数低。
这也是 BM25 的典型优势:对术语、编号、制度名称、合同条款、HS 编码、表单名称这类精确词非常敏感

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 讲的内容,这里做更深入的对比:

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 作为主键。这使得文档更新时可以按 ID delete(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。这样做有几个工程优势: 具体到本项目,Milvus 内置 BM25 带来这些收益:
  1. 入库简单add_documents() 只写入文本和 metadata,Milvus 服务端自动从 text 字段生成 sparse 向量。
  2. 查询简单:用户输入 query 后,Milvus 自动生成 sparse query representation,不需要业务代码手动调用 BM25 编码器。
  3. 融合自然:Dense 和 Sparse 在一次 Hybrid Search 请求里完成,避免 Python 侧分别查两套系统再手动 merge。
  4. 数据一致:文档文本、dense 向量、sparse 向量、metadata 都在同一个 collection 中,版本过滤、租户过滤、source 过滤可以一起生效。
  5. 更适合增量重建:删除旧 chunk、写入新 chunk 后,BM25 sparse 字段由 Milvus 重新生成,不需要额外维护外部倒排索引。
  6. 中文配置集中:中文分词器通过 analyzer_params={"type": "chinese"} 固定在 collection schema / function 配置里,避免不同脚本分词口径不一致。
所以这里的设计可以概括为:

4.5 Hybrid Search 的分数融合

当同时使用 Dense 和 Sparse 检索时,Milvus 内部如何融合两者的分数?
本项目的 Milvus 配置使用默认权重 0.5 : 0.5,语义和关键词各占一半。对于特定场景(如法律文档更依赖精确关键词),可以调整权重。

4.6 一次 Hybrid Search 的完整时序

把前面的 dense、sparse、BM25、过滤和融合串起来,一次检索大致是这样发生的: 口语化理解:
Dense 负责“意思像不像”,BM25 负责“关键词有没有精准命中”,Milvus 负责在同一个 collection 里把两种召回结果按权重融合,再把符合版本、租户、分类过滤条件的候选返回给 RAG 链路。

5. 过滤表达式构建

5.1 为什么需要过滤表达式

向量检索是在整个 collection 中找最相似的内容。但实际业务中,我们需要限制搜索范围:
  • 同一个 collection 中存了多个场景的数据 → 只搜当前场景的
  • 同一个场景中有多个知识库版本 → 只搜 active 版本的
  • 开启了数据隔离 → 只搜当前租户/数据集的
  • 前端选择了业务分类 → 只搜该分类的
这些限制通过 Milvus 的标量过滤表达式实现。

5.2 build_source_expr() 实现

5.3 拼接后的实际表达式

对于一次具体的查询,过滤表达式可能长这样:
这个表达式在 Milvus 内部先做标量过滤(缩小搜索范围),再做向量检索,大幅提升检索精度和效率。

5.4 安全转义


6. 多查询变体检索与合并

6.1 search_many() 的完整流程

6.2 文档去重逻辑

为什么需要去重?

6.3 Reranker 重排实现

Reranker 的计算代价
  • 向量检索(Bi-Encoder):O(n) 次向量比较,n=候选数,每次都是快速的向量内积
  • Reranker(CrossEncoder):O(k) 次 Transformer 前向传播,k=候选数(通常 20-50),每次都需要模型推理
这就是为什么 Reranker 只对检索召回的前 k 个候选做重排,而不是对整个 collection 做。如果对整个 collection(可能有几十万条)做 CrossEncoder,一次查询就要几分钟。

7. FAQ 与文档分集合设计

7.1 FAQ 分层检索策略

7.2 为什么分集合

7.3 两层检索的工作流

7.4 FAQ 的高置信直出


8. 连接管理与数据库初始化

8.1 Milvus 数据库创建

Milvus 2.4+ 引入了 Database 概念,类似于关系数据库的 Database。本项目的 MILVUS_DATABASE 默认为空,也就是使用 Milvus 默认 database;如果后续需要按环境或租户做更强隔离,本机 API 调试写在 .env,Docker Compose 部署写在 .env.compose

8.2 显式连接别名注册

项目采用清晰的连接适配方式:为每个 collection 生成稳定 alias,并在创建 langchain-milvus wrapper 前显式注册 PyMilvus ORM 连接。这样既保留 LangChain VectorStore 抽象,也避免底层 ORM API 找不到连接。这不是补丁式兼容,而是当前 langchain-milvus 底层仍依赖 PyMilvus ORM alias 的工程边界。

9. 本讲实践闭环

通过标准:Dense + Sparse 都参与召回,过滤表达式生效,Reranker 能对候选重排。

9.1 本讲从 0 到 1 实现闭环

实现完成后,相关代码结构应该是下面这张图:

9.1.1 :封装 Milvus BM25 Function

目标:让 Milvus 服务端根据 text 字段自动生成 sparse 向量。 来源:真实代码节选,见 qa_core/retrieval/milvus_compat.py::bm25_function()
设计解释:Python 不维护外部 BM25 倒排索引,dense 和 sparse 都交给同一个 Milvus collection 管理。

9.1.2 :实现过滤表达式

目标:所有检索都必须限制在当前场景、版本、租户、数据集和分类内。 来源:真实代码逻辑压缩版,对应 qa_core/retrieval/filters.py::build_source_expr()
关键点:source_filter 必须先过白名单,字符串值必须转义;scenario_idtenant_iddataset_idvisibilityallowed_roles 不是在这里手写,而是由 DataScope.expr_clauses() 统一追加。

9.1.3 :实现 MilvusHybridStore 懒加载

目标:第一次检索或入库时才创建 LangChain Milvus store,并做 schema 校验。 来源:真实代码逻辑压缩版,对应 qa_core/retrieval/store.py::MilvusHybridStore.store
设计解释:schema 不兼容要启动时暴露,不能等用户提问时才出现 nq [0] is invalidvalidate_hybrid_schema() 会检查 text analyzer、dense 字段、sparse 字段是否为 BM25 Function 输出,以及是否存在 text -> sparse 的 BM25 Function。

9.1.4 :实现单查询检索和多查询合并

来源:真实代码逻辑压缩版,对应 qa_core/retrieval/store.py::search()search_many()
设计解释:多个 query variants 可能命中同一个 chunk,合并时保留最高分,再统一 rerank;单查询检索使用 weighted ranker 融合 dense/sparse,当前权重是 dense 0.55、sparse 0.45。 异常处理也不能省略:如果 Milvus 报 nq [0] is invalid,项目不会降级到 dense-only,而是明确提示 collection schema 缺少正确 BM25 Function,需要 --reset-collections 重建。

9.1.5 :验收检索闭环

前置:当前场景已有 active 版本和 Milvus collection。 来源:命令行验收,对应 tests/test_retrieval_and_prompt.py
闭环验证重点: 通过标准:
  • 过滤表达式包含 kb_versiontenant_iddataset_idsource 等字段。
  • 多查询命中同一文档时能去重。
  • Reranker 只对候选 Top-K 精排。
  • 页面或检索脚本能返回 FAQ/Doc 命中,metadata 不缺关键字段。

10. 重点掌握

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/文档分集合是实现分层检索和动态阈值的基础
下一讲QAService 核心编排 — 服务门面模式、HTTP/WS 分工、事件生成器