首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >5 层调用、2 套引擎:Milvus 3.0 的全文检索为什么像“两条铁轨”

5 层调用、2 套引擎:Milvus 3.0 的全文检索为什么像“两条铁轨”

原创
作者头像
术哥
发布2026-07-27 22:38:58
发布2026-07-27 22:38:58
1670
举报
文章被收录于专栏:运维有术运维有术

🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 178 篇,Milvus 最佳实战「2026」系列第 23

大家好,欢迎来到 术哥无界 | ShugeX | 运维有术

我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者

Talk is cheap, let's explore。无界探索,有术而行。

Milvus 3.0 全文检索两条铁轨信息图封面
Milvus 3.0 全文检索两条铁轨信息图封面

读 Milvus 3.0 的全文检索代码,最容易先入为主:既然项目里已经嵌了 Tantivy,那么 BM25、text_match、短语和模糊匹配,应该都由这套倒排引擎处理。

源码给出的答案刚好相反。BM25 根本不经过 Tantivy。它把文本变成 map[uint32]float32,塞进 Milvus 已有的稀疏向量框架,再交给 knowhere::sparse 排序。Tantivy 负责的是另一件事:执行文本谓词,返回 bitset。

这不是实现细节上的偶然分叉,而是 Milvus 3.0 Full-Text Search Stack 最关键的设计判断:ranking 与 filtering 被拆成两条铁轨。一个负责“谁更相关”,一个负责“谁满足文本条件”。用户看到的是同一组全文检索能力,内部却没有一台统一的“搜索引擎”。

先把本文依据压缩成一张事实清单。截至 2026 年 7 月 25 日,官方源码和设计文档明确支持以下判断:

问题

源码事实

BM25 在哪里生成

internal/util/function/bm25_function.goBM25FunctionRunner.run

BM25 的中间表示

analyzer 分词后,经 HashString2LessUint32 生成 map[uint32]float32

BM25 在哪里评分

sparse vector 检索框架,底层为 knowhere::sparse

文本谓词输出什么

Tantivy 查询经 DirectBitsetCollector 返回 bitset,不负责 ranking

fuzzy 路径多深

Protobuf、Go parser、C++ executor、C++ index wrapper、Rust Tantivy binding,共五层

中文拼音在哪里扩展

pinyin_filter.rs 的 analyzer filter,在 token 空间内展开

真正值得分析的,不是 Milvus “支持哪些 API”,而是它为什么没有把这些能力硬塞进同一个内核。

1. BM25 在这里是一种函数,不是一台搜索引擎

BM25 常与 Elasticsearch、Lucene 这类倒排系统绑定出现,所以“BM25 = 搜索引擎内部评分器”几乎成了直觉。Milvus 3.0 选择复用自己的向量检索抽象:把 BM25 产物建模成 sparse vector(稀疏向量)。

关键代码在 internal/util/function/bm25_function.go 第 174—202 行。BM25FunctionRunner.run 接收字符串数组,输出跳过 posting list,也跳过 Tantivy document,落点是一组稀疏映射:

代码语言:go
复制
func (v *BM25FunctionRunner) run(data []string, dst []map[uint32]float32) error {
    tokenizer, err := v.tokenizer.Clone()
    // ...
    for i := 0; i < len(data); i++ {
        if len(data[i]) == 0 {
            dst[i] = map[uint32]float32{}
            continue
        }

        embeddingMap := map[uint32]float32{}
        tokenStream := tokenizer.NewTokenStream(data[i])
        for tokenStream.Advance() {
            token := tokenStream.Token()
            // TODO More Hash Option
            hash := typeutil.HashString2LessUint32(token)
            embeddingMap[hash] += 1
        }
        tokenStream.Destroy()
        dst[i] = embeddingMap
    }
    return nil
}

这里没有 BM25 公式计算。runner 做了三件很克制的事:analyzer 切 token;typeutil.HashString2LessUint32 把 token 映射到 uint32 维度;同一 token 重复出现时,用 embeddingMap[hash] += 1 累加词频 TF。

于是,一段文本被压成类似 {term_hash: term_frequency} 的稀疏向量。schema 中,原始文本是 function 的 input field,目标字段则是 SPARSE_FLOAT_VECTOR。后续检索沿用 Milvus 已有的 sparse vector ANN 路径,并用 metric_type="BM25" 计算分数。官方文档中的 DAAT_MAXSCOREDAAT_WANDTAAT_NAIVE,也属于这条稀疏检索路线。

这个拆法有一个直接收益:BM25 ranking 可以进入 Milvus 原本的向量搜索组合体系。密集向量、稀疏向量、rerank 与过滤条件,不需要为了全文检索再造一套顶层执行模型。

代价也写在代码里。term 不再以字符串作为向量维度,而是经过 32 位 hash;BM25 statistics 还要独立维护。segment_c.h 中有 bm25_statsbm25_stats_sizesnum_bm25_statsPlanProto.cpp 第 128 行继续把 bm25_avgdl 放进 search params。

configs/milvus.yaml 则用 bm25StatsBytesPerEntry: 20 做内存核算,注释给出的实测范围是每项 12—19.5 字节。

所以,把这条路径称为“Milvus 内置了一个 BM25 搜索引擎”并不准确。更贴近源码的说法是:Milvus 增加了一个本地文本函数,把 analyzer 的输出编码成稀疏向量;评分仍由 sparse vector 基础设施完成。internal/core/thirdparty/tantivy/ 搜索 BM25 没有结果,也印证了两者并未接线。

BM25 与 Tantivy 两条平行路径架构图
BM25 与 Tantivy 两条平行路径架构图

2. 同叫全文检索,text_match只负责圈候选集

text_matchphrase_matchtext_match_fuzzy 走另一条路。它们落在 filter operator 这一侧,不属于 search operator。最终产物是一张 bitset:哪些行匹配,哪些行不匹配。

这个差别决定了两套能力可以叠加,却不能混称。比如一个请求先用 text_match 圈出包含某些词的文档,再在候选集里做向量相似度或 BM25 排名。前者回答布尔问题,后者计算相关性分数。

Milvus 为三个文本谓词复用了同一种计划表达。pkg/proto/plan.proto 第 145—159 行中,TextMatch = 13PhraseMatch = 14TextMatchFuzzy = 17 都落入 UnaryRangeExpr

代码语言:protobuf
复制
message UnaryRangeExpr {
  ColumnInfo column_info = 1;
  OpType op = 2;
  GenericValue value = 3;
  string template_variable_name = 4;
  repeated GenericValue extra_values = 5;
}

extra_values 是一个有意保留的共享槽位:text_matchmin_should_matchphrase_match 放 slop,fuzzy 放 max_edit_distance。这让新增 fuzzy 时不用再为 wire format 新开一套表达式,但类型语义被推迟到各执行层解释。灵活与复杂,来自同一处设计。

从一条 text_match_fuzzy(title, "allergy", max_edit_distance=1) 开始,调用会穿过五层。

Go parser 位于 internal/parser/planparserv2/parser_visitor.go。第 909—933 行的 parseTextMatchOperand 是三个 visitor 共用的前置逻辑;VisitTextMatchVisitTextMatchFuzzyVisitPhraseMatch 再各自解释参数。字段没有开启 match,会在第 921—923 行直接被挡住:

代码语言:go
复制
if !v.schema.IsFieldTextMatchEnabled(columnInfo.FieldId) {
    return nil, nil, "", false, merr.WrapErrParameterInvalidMsg(
        "field \"%s\" does not enable match", identifier)
}

fuzzy 的距离也在这里第一次受限。第 996—999 行要求 max_edit_distance 落在 [0, 2],否则返回参数错误。解析完成后,Go 侧产出 UnaryRangeExpr{Op: TextMatchFuzzy, extra_values: [1]},计划再进入 segcore。

C++ 层的入口是 internal/core/src/exec/expression/UnaryExpr.cpp 第 1990—2110 行,函数名为 PhyUnaryRangeFilterExpr::ExecTextMatch()。第 2060—2070 行按 OpType 分派:

代码语言:cpp
复制
if (op_type == proto::plan::OpType::TextMatch) {
    res = index->MatchQuery(query, min_should_match);
} else if (op_type == proto::plan::OpType::PhraseMatch) {
    res = index->PhraseMatchQuery(query, slop);
} else if (op_type == proto::plan::OpType::TextMatchFuzzy) {
    res = index->FuzzyMatchQuery(query, max_edit_distance);
} else {
    ThrowInfo(OpTypeInvalid, "unsupported operator type for match query: {}", op_type);
}

这里还会在第 2023—2043 行重新检查 fuzzy 距离。注释写得很直白:parser 已经校验过,但 executor 必须防住 raw proto。也就是说,Go parser 不是唯一可信入口。

接下来是 internal/core/src/index/TextMatchIndex.{h,cpp}TextMatchIndex 继承 InvertedIndexTantivy<std::string>,说明 Milvus 没有为文本谓词另起索引生命周期,而是在既有 scalar inverted index 封装上增加三个方法。TextMatchIndex.cpp 第 383—418 行的 PrepareBitset() 会按需 Commit()Reload(),随后按照 Count() 分配 bitset;三个查询方法都复用这段前置动作。

再往下,wrapper_->fuzzy_match_query(...) 跨过 FFI。index_reader_text_c.rs 暴露 tantivy_match_querytantivy_phrase_match_querytantivy_fuzzy_match_query,把 C 字符串转成 Rust &str,再交给 IndexReaderWrapper

第五层才真正进入 Tantivy 查询对象。internal/core/thirdparty/tantivy/tantivy-binding/src/index_reader_text.rs 第 20—35 行的 tokenize_terms 先拿字段自己的 tokenizer,把查询文本变成带 position 的 Term。普通 match 构造 BooleanQuery;phrase 构造 PhraseQuery;fuzzy 则按 token 构造 FuzzyTermQueryDirectBitsetCollector 把命中结果写回 C++ 传来的 bitset 指针。

这五层并非“为了支持三种语言而堆出来的胶水”。proto 保持计划兼容,parser 处理用户表达式,executor 对 raw plan 做防御。再往下,index wrapper 对齐 Milvus segment lifecycle,Rust binding 负责 Tantivy 查询与 collector。删掉其中任何一层,另一个边界就会渗进来。

text_match_fuzzy 五层调用链图
text_match_fuzzy 五层调用链图

3. Fuzzy 最有意思的地方,是它知道什么时候不该 fuzzy

模糊匹配很容易写成“把编辑距离传给底层库”。Milvus 的实现更谨慎,因为编辑距离不是一个可以无限放大的旋钮。

index_reader_text.rs 第 73—106 行里,第一道 Rust 检查是 max_edit_distance > 2 直接返回 InvalidArgument。Go parser 和 C++ executor 已各检查一次,Rust 仍不信任上游。这不是重复劳动:代码随后要把 u32 转成 u8。若不先限制,像 256 这样的值可能截断为 0,最终悄悄变成 exact match,错误还不容易暴露。

K=0 则直接短路到 self.match_query(q, bitset)

代码语言:rust
复制
if max_edit_distance == 0 {
    return self.match_query(q, bitset);
}

这条分支省掉了为每个 token 创建 Levenshtein automaton。结果语义与精确 term match 一样,就没必要为了“走完整 fuzzy 流程”支付额外成本。这类短路很朴素,却比在上层增加更多配置更值钱。

K=1 或 K=2 时,代码才执行:

代码语言:rust
复制
FuzzyTermQuery::new(term, distance, true)

第三个参数 true 表示 transposition_cost_one。相邻字符交换按一次编辑计算,即 Damerau-Levenshtein 风格。对于 alelrgy 这类键入错位,交换两个相邻字符无需算作两次替换,因此更符合输入错误的实际形态。

多个 token 的 fuzzy query 最后由 BooleanQuery::union(queries) 连接,语义是 per-token OR。

限制也很明确。设计文档没有引入 Elasticsearch 式 fuzziness=AUTOprefix_length 实际为 0,也没有 max_expansions 上限。因此 distance=2 遇到大词表时,展开规模可能变得很大。

当前实现选择“只允许显式 K,并把 K 封顶为 2”,比表面上更智能的自动策略更可控,但它仍然只是 filtering。设计文档把 fuzzy scoring 留给 #50921,没有假装 bitset 已经解决相关性排序。

同一文件里还有一个相似的防崩处理:phrase_match_query 发现查询分词后不超过一个 term,就把 PhraseQuery 换成 BooleanQuery::new_multiterms_query(即 OR 路径),不再交给 Tantivy 的 PhraseQuery。原因不是语义偏好,而是 Tantivy 的 PhraseQueryterms.len() <= 1 时会 panic。封装层在这里承担了“把库的前置条件翻译成 Milvus 稳定行为”的职责。

4. VARCHAR 与 TEXT:两个入口背后是两种存储压力

全文检索的字段入口也容易被名称绕晕。官方全文搜索文档仍把 analyzer-enabled VARCHAR 作为用户入口:保存原始文本,设置 enable_analyzer,文本谓词还要求字段启用 match。IsFieldTextMatchEnabled 就是 parser 侧的硬门槛。

Milvus 3.0 同时引入了 TEXT,但它解决的核心问题是大文本存储。internal/datanode/compactor/bump_schema_version_compactor.go 显示,TEXT 可走 LOB(Large Object,大对象)路径;bump_schema_version_compactor_test.go 注释明确把 TEXT 描述为 “the LOB-carrying input of a BM25 function”。compactor 要维护 LOB 引用,segment 在 schema bump 时把 TEXT LOB 文件合并到目标 manifest。configs/milvus.yaml 中的 dataNode.text.inlineThresholdmaxLobFileBytesflushThresholdBytes 和 compaction hole ratio,都是存储层参数。

这里应当把边界说窄一些。analyzer-enabled VARCHAR 是当前文档化的 text_match 用户入口。TEXT 则可作为 BM25 function 的 input field,原文经 Function 层 tokenize 后生成 sparse vector;它本身的 LOB 管理与 BM25 stats 维护仍是两层独立机制。

这也解释了为什么“TEXT 出现了,所以 VARCHAR 会立刻退出全文检索”是误判。VARCHAR 代表已经打通的查询入口,TEXT 代表大字段的数据承载能力。二者最终可能收敛,但从现有代码看,它们还处在不同演进阶段。

5. 中文不是换个 tokenizer 就结束了

中文搜索的麻烦不只在分词。用户可能输入汉字、全拼、连写全拼,甚至首字母缩写。单纯把 jieba tokenizer 接到倒排索引,只覆盖第一种输入形式。

Milvus 在 internal/core/thirdparty/tantivy/tantivy-binding/src/analyzer/filter/pinyin_filter.rs 增加了 PinyinFilter,使用 Rust pinyin crate 0.10 的 ToPinyin 做转换。关键不在“支持拼音”这句功能描述,而在它如何扩展同一个 token 的索引表示。

PinyinOptions 有四个开关:

  • keep_original:保留原 token,默认开启;
  • keep_full_pinyin:按汉字生成独立全拼 token,默认开启;
  • keep_joined_full_pinyin:把一个词的拼音连成整体;
  • keep_separate_first_letter:把每个汉字的首字母连起来。

输入“中文测试”,经 jieba 得到“中文”和“测试”后,默认配置会保留原词,并扩展出 zhongwenceshi。打开 joined 选项还会产生 zhongwenceshi;打开首字母选项则增加 zwcs。全开时,设计文档给出的结果是 9 个 token。

这个位置选得很准:拼音作为 analyzer pipeline 中的一种 filter,不归查询层管。索引时和查询时共享 analyzer,汉字与拼音才能落到一致的 term 空间。代价同样明显——开关越多,token 数越多,词典和 posting 规模也会增长。配置不是越全越好,应当由真实输入形态决定。

更重要的是,这套拼音扩展属于 Tantivy 文本谓词路线;BM25 runner 也依赖 analyzer,但最终产物仍是 hash 后的稀疏维度。两条路可以共享分析思路,却不会共享索引结构。

中文测试 pinyin filter 9 个 token 展开示意图
中文测试 pinyin filter 9 个 token 展开示意图

6. 为什么借 Tantivy,而不是自己写一套倒排

看到 Go → C++ → Rust 的跨语言调用,有一种很自然的质疑:为了几个文本谓词引入 FFI 和另一门语言,是否过重?

若只看 match_query 的十几行,答案似乎是肯定的。真正昂贵的部分却不在 query object,而在它背后的 posting list、segment、合并、压缩、commit、reload、reader 可见性和 collector。自己实现几个 term query 不难,维护一套可用于数据库生命周期的倒排基础设施才难。

Milvus 的取舍是保留边界,而不是隐藏边界。Tantivy 管倒排与查询执行;TextMatchIndex 把它接入 growing/sealed segment 生命周期;PrepareBitset() 处理 commit/reload;FFI 只暴露 match_queryphrase_match_queryfuzzy_match_query 等精确能力;自定义 analyzer filter 则补足拼音等场景。

这比“直接把 Tantivy 当成内部搜索引擎”更克制。它没有接管 BM25 ranking,也没有接管 Milvus 顶层计划。Tantivy 只是 text predicate 的执行后端。相应地,BM25 已经能在 sparse vector 框架里完成,就没必要再绕到 Tantivy 内做第二套评分。

我认为这是这套设计最成熟的地方:复用发生在基础设施层,而不是产品名词层。 用户文档可以统一称为 Full-Text Search,源码不必为了概念整齐而强行统一实现。数据库内部最怕“看起来统一”,最后却让 ranking、filter、存储和 segment lifecycle 相互绑死。

7. 接下来会收敛的,不一定是引擎

Milvus 3.0 目前留下了几条清晰的缝。fuzzy 只有 filter,评分仍在 #50921 的范围外;TEXT 已经承担 LOB 大文本存储,但文档化的文本谓词入口仍以 analyzer-enabled VARCHAR 为主;拼音 filter 扩大了中文召回面,也把 token 膨胀成本交给配置者。

我不认为下一步应该把 BM25 搬进 Tantivy,或者把 Tantivy 改造成统一全文内核。现有分工已经说明:Milvus 更看重统一的检索编排,而不是统一的索引实现。BM25 留在 sparse vector 路径,才能继续与 dense vector 和 rerank 组合;文本谓词留在 Tantivy,才能直接利用成熟倒排结构返回 bitset。

更值得观察的是边界能否变薄:TEXT 与 match 的用户入口是否统一,fuzzy scoring 会接入哪条 ranking 路径,analyzer 配置能否在两套机制间保持严格一致。只要这三处没有被“统一 API”的表象遮住,两条铁轨并行并不是技术债,反而是 Milvus 面向混合检索最务实的底座。

说明:本文内容基于 zilliztech/milvus-io/milvus v3.0 源码和同组织 web-content 官方文档镜像(v3.0.x 系列)的阅读整理,关键引用均给出文件路径与行号。源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证文中的机制描述和行号引用仅供参考,实际行为请以你拉取到的对应 commit 和最新官方文档为准。 如果你有实际验证经验或发现与新版本不符之处,欢迎在评论区分享交流。

好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. BM25 在这里是一种函数,不是一台搜索引擎
  • 2. 同叫全文检索,text_match只负责圈候选集
  • 3. Fuzzy 最有意思的地方,是它知道什么时候不该 fuzzy
  • 4. VARCHAR 与 TEXT:两个入口背后是两种存储压力
  • 5. 中文不是换个 tokenizer 就结束了
  • 6. 为什么借 Tantivy,而不是自己写一套倒排
  • 7. 接下来会收敛的,不一定是引擎
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档