Milvus 文本分析器 Pinyin Filter(拼音过滤器):从配置到端到端搜索实践
【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus
本文对应仓库设计文档:docs/design-docs/design_docs/20260209-pinyin_filter.md,即 Milvus MEP(Milvus Enhancement Proposal)中关于“Pinyin Filter for Text Analyzer”的实现提案。
导读
本文深入介绍 Milvus 全文检索文本分析器中的内置Pinyin Filter(拼音过滤器):它能把中文分词后的汉字 token 自动转写成拼音(拉丁字母),让用户直接用拼音输入即可命中中文内容,支撑人名/地名检索、输入法拼音联想(search-as-you-type)、以及无中文输入法环境下的跨输入法搜索等场景。读完本文,你将掌握该过滤器在 Milvus 配置 JSON 中的全部参数与默认值、它在 tantivy 分词管线底层的 token 展开实现原理,并能够基于官方测试用例在 Go SDK 中端到端地创建启用了拼音过滤的集合并用text_match完成中/拼音混合搜索。
1. 背景与动机:为什么需要在全文检索管线里做拼音转换
Milvus 对中文全文检索的支持此前依赖于 Jieba 等分词器完成“词切分”,但分词产物始终是汉字本身,没有任何内置手段用拼音输入去命中中文内容。这对大量中文场景是硬需求:
- 姓名/地名检索:用户习惯敲拼音,例如输入
zhangsan期望命中“张三”,输入beijing期望命中“北京”; - 自动补全与边打边搜:绝大多数设备的输入法是把拼音按键流转成汉字,若索引与查询两侧都能按拼音匹配,搜索体验会更快更自然;
- 跨输入法检索:部分用户环境没有中文输入法,只能使用拉丁字符检索中文数据。
没有拼音过滤器时,用户只能自维护一个拼音映射字段或在应用层做转换,既增加写入侧复杂度与存储开销,又难以保证两端转换规则一致。将其实现为“分词管线内的一个 filter”则可以在索引构建(写入)与查询改写两侧天然复用同一套逻辑,属于更优雅的方案。这也在该 MEP 的“Rejected Alternatives(被否决的备选方案)”中得到了印证:应用层维护拼音字段复杂且有存储开销,而独立拼音分词器不如 filter 可组合——filter 可以叠加在 Jieba、standard 等任意分词器之后,再与停用词、小写化等其它 filter 串联。
2. 公共接口:在 Analyzer 配置里启用"pinyin"filter
该过滤器以新的 filter 类型"pinyin"暴露在 analyzer 配置 JSON 中,可挂载到任意 analyzer 的 filter 管线。下面配置即官方 MEP 文档与 Rust 单测(pinyin_filter.rs)使用的形态:
{ "tokenizer": "jieba", "filter": [ { "type": "pinyin", "keep_original": true, "keep_full_pinyin": true, "keep_joined_full_pinyin": false, "keep_separate_first_letter": false } ] }2.1 四个布尔参数的含义与默认值
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
keep_original | bool | true | 输出中保留原始中文 token |
keep_full_pinyin | bool | true | 把每个汉字单独输出为对应拼音 token(例:"中文" →zhong、wen) |
keep_joined_full_pinyin | bool | false | 把整词所有汉字的拼音拼成一个连续 token(例:"中文" →zhongwen) |
keep_separate_first_letter | bool | false | 把整词每个字拼音首字母拼成一个 token(例:"中文" →zw) |
2.2 字符串简写形式
当不需要任何定制时,可以直接把"pinyin"作为字符串写进 filter 数组,此时使用全部默认选项(即keep_original=true、keep_full_pinyin=true、其余为false)。MEP 文档明确说明:“When used with no parameters (i.e.,"pinyin"as a plain string filter), the default options apply.”
从源码看,这一简写确实落到了SystemFilter的From<&str>分支:
"pinyin" => Self::Pinyin(PinyinFilter::default()),见 filter.rs;而 JSON 对象形式则由create_filter中"pinyin" => PinyinFilter::from_json(params)分支负责(见 filter.rs)。
使用提示(前提与限制):以上配置适用于启用全文检索能力(VARCHAR 字段开启 analyzer + match)的集合字段;不同语言 SDK 的“启用 analyzer”开关名称略有差异(Go 侧为
WithEnableAnalyzer(true).WithEnableMatch(true)),具体见下文第 6 节。若 analyzer JSON 中 filter 元素既非字符串也非含type的 JSON 对象,或type不是字符串、不属于已注册类型,构建 analyzer 都会失败并返回明确错误(如unsupport filter type: xxx、no type field in filter params),这部分校验逻辑同样位于 filter.rs。
3. 实现位置与整体架构
拼音过滤器的实现位于 Milvus 为全文检索准备的 tantivy-binding Rust crate 内,与 RegexFilter、SynonymFilter 等既有过滤器处于同一目录、同一种插件模式之下:
- pinyin_filter.rs —— 核心过滤器实现;
- filter.rs —— 在系统过滤器分发系统中完成注册;
- mod.rs —— 模块声明与导出;
- Cargo.toml —— 引入第三方依赖
pinyin = "0.10"(中文转拼音库)。
3.1 三个组成类型的职责
MEP 文档把实现拆成三层,源码中一一对应:
PinyinFilter—— 实现tantivy::tokenizer::TokenFiltertrait,内部只保存一份PinyinOptions配置;其transform()负责把上游 tokenizer 包装成新的 tokenizer。PinyinFilterWrapper<T>—— 泛型包装器,Tokenizer实现里创建出实际的 token 流对象并持有一份克隆的PinyinOptions(见 pinyin_filter.rs)。PinyinFilterStream<T>—— 真正执行转换的 token 流:通过缓存队列 + 游标方式,把上游进来的 1 个 token 展开成多个输出 token(cache: Vec<Token>+index: usize,见 pinyin_filter.rs)。
配置解析入口PinyinFilter::from_json会逐项读取四个 key,任何一项若传了非布尔值都会直接报错(例如keep_original must be a boolean value);未出现的 key 保持默认值,见 pinyin_filter.rs。
3.2 在全文检索整体链路中的位置
从调用关系看,该 crate 的create_analyzer/create_analyzer_by_json(analyzer.rs)负责把 analyzer 配置 JSON 解析成 tantivy 的TextAnalyzer,其中 filter 数组逐项生效:字符串元素走SystemFilter::from,对象元素走create_filter,最后统一transform(builder)追加到管线(见 analyzer.rs)。这套 analyzer 被 Milvus 的 DataNode/QueryNode(该 MEP 标记的 Component)在写入侧建索引与查询侧解析用户文本时共用,因此拼音过滤天然同时作用于“索引端分词”与“查询端分词”。
4. 底层原理:token 展开逻辑与细节校对
4.1 处理流程
PinyinFilterStream::advance()对上游(如 Jieba)传入的每个 token 执行如下处理(见 pinyin_filter.rs):
- 若
keep_original=true,先把原始 token 原样压入缓存队列; - 遍历 token 文本的每一个字符,通过
pinyincrate 的ToPinyintrait 转换(to_pinyin().flatten())——注意flatten()意味着无法转写的字符会被静默跳过,天然实现“只处理汉字、忽略非中文字符”; - 依配置产出派生 token:
keep_full_pinyin=true:每字拼音作为独立 token(char.plain());keep_joined_full_pinyin=true:逐字拼接进join_pinyin,非空才整体压入一个 token;keep_separate_first_letter=true:逐字取char.first_letter()拼进first_letter,非空才压入;
- 空转写结果(纯 ASCII/数字等 token)不会生成任何空 token。
4.2 一个值得注意的源码级细节:offset 与 position 的精确语义
MEP 文档概述称“All generated tokens share the sameoffset_from,offset_to, andpositionas the original token”。对照真实源码需要做一处更精确的说明:offset 确实完全继承原 token,但position 并非一律相同——
- 逐字全拼 token(
keep_full_pinyin)的 position 会按字序号递增:start_position = token.position + index(仅当index <= position_length),并且position_length强制设为1,其意图是让逐字拼音可作为相互独立的词位参与短语/临近匹配; - 而整词拼接 token(
keep_joined_full_pinyin、keep_separate_first_letter)则原样沿用原 token 的position与position_length。
if self.options.keep_full_pinyin { let mut start_position = self.tail.token().position; if index <= self.tail.token().position_length { start_position = start_position + index; } self.cache.push(Token { text: char.plain().to_string(), offset_from: self.tail.token().offset_from, offset_to: self.tail.token().offset_to, position: start_position, position_length: 1, }) }见 pinyin_filter.rs。
4.3 依赖选型
转换依赖 pinyin Rust crate(版本 0.10),它提供不带声调的纯拼音(plain())与首字母(first_letter())两类输出,正好覆盖本文档需要的全部三种拼音形态。其取舍(无音调、按字转换)也决定了该过滤器的定位是“辅助召回/联想”而非“语义理解”。
5. 分词输出示例速查
沿用 MEP 文档示例:输入文本“中文测试”,由 Jieba 分词为“中文”与“测试”两个 token 后,不同配置组合的最终 token 输出如下:
| 配置 | 输出 tokens |
|---|---|
keep_original=true, keep_full_pinyin=true | 中文、zhong、wen、测试、ce、shi |
keep_original=true, keep_joined_full_pinyin=true | 中文、zhongwen、测试、ceshi |
keep_original=true, keep_separate_first_letter=true | 中文、zw、测试、cs |
| 全部选项开启 | 中文、zhong、wen、zhongwen、zw、测试、ce、shi、ceshi、cs |
可以把上表理解为“索引侧倒排里每种形态各占一个词项”,查询文本在查询侧也会走同样的展开逻辑——这正是查询“中文”“zhongwen”“zw”都能命中同一批文档的根因。
6. 端到端落地:Go SDK 中的建集合、验词、检索
配套仓库在 tests/go_client/testcases/pinyin_filter_test.go 提供了完整的 L0 级(可合并进 CI 的轻量场景)Go SDK 端到端用例,可以直接当作使用范本。
6.1 定义 analyzer 与集合
用 Go 的字段属性开关 + analyzer JSON 创建一个 VARCHAR 字段参与全文检索(pinyin_filter_test.go):
func pinyinAnalyzerParams(keepOriginal bool) map[string]any { return map[string]any{ "tokenizer": "jieba", "filter": []any{ map[string]any{ "type": "pinyin", "keep_original": keepOriginal, "keep_full_pinyin": false, "keep_joined_full_pinyin": true, "keep_separate_first_letter": false, }, }, } } // 建集合:VARCHAR 字段开启 analyzer + match,并挂上含 pinyin filter 的 analyzer 参数 schema := entity.NewSchema().WithName(collectionName). WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithIsPrimaryKey(true)). WithField(entity.NewField().WithName("text").WithDataType(entity.FieldTypeVarChar).WithMaxLength(1024). WithEnableAnalyzer(true).WithEnableMatch(true).WithAnalyzerParams(analyzerParams)). WithField(entity.NewField().WithName("vector").WithDataType(entity.FieldTypeFloatVector).WithDim(2))提示:该用例中拼音开关组合是
keep_full_pinyin=false+keep_joined_full_pinyin=true,即只为每词保留一个整词拼音(如zhongwen),刻意不产生逐字拼音与首字母形式——这正好用来验证“没开的形态不会被命中”。
6.2 用 RunAnalyzer 直接观察分词结果(免建索引排障)
写入前就能用RunAnalyzer把 analyzer 实际跑一遍、核对展开后的 token(pinyin_filter_test.go):
results, err := mc.RunAnalyzer(ctx, client.NewRunAnalyzerOption("中文测试"). WithField(collectionName, "text")) require.NoError(t, err) tokens := make([]string, len(results[0].Tokens)) for i, token := range results[0].Tokens { tokens[i] = token.Text }用例断言:keep_original=true时“中文测试”应输出["中文", "zhongwen", "测试", "ceshi"],单独一个“中文”输出["中文", "zhongwen"];而当keep_original=false时输出只剩["zhongwen", "ceshi"](见 pinyin_filter_test.go)。这直观印证了第 5 节的表格,也是日常排查“为什么某拼音查不到”的首选工具。
6.3 写入、建索引后按拼音检索
测试覆盖了 sealed(已封口/已索引段)、unsealed(未索引封口段)与 growing(增长段)三类数据路径:先写入 3000 行、flush 成已索引 sealed 段,再写 500 行封口成未建索引 sealed 段,加载集合后再写入 500 行增长段。检索统一用全文匹配函数text_match作为 Search 的过滤条件(pinyin_filter_test.go):
filter := fmt.Sprintf("text_match(text, %q)", queryText) // 也可指定 minimum_should_match: // text_match(text, "中文", minimum_should_match=2) result, err := mc.Search(ctx, client.NewSearchOption(collectionName, limit, []entity.Vector{entity.FloatVector{0, 0}}). WithANNSField("vector"). WithFilter(filter). WithOutputFields("id", "text"))关键断言矩阵(pinyin_filter_test.go):
| 查询文本 | minimum_should_match | 期望结果 | 语义验证 |
|---|---|---|---|
zhongwen | — | 命中 3 个目标行 | 整词拼音可检索(核心场景) |
中文 | 2 | 命中 3 个目标行 | 原文检索不受影响 |
zhong | — | 空结果 | 未开启keep_full_pinyin,逐字拼音被正确禁用 |
zw | — | 空结果 | 未开启keep_separate_first_letter,首字母被正确禁用 |
可见拼音开关具备精确的启停语义:开哪个开关、就只会命中哪种拼音形态,不存在“漏禁”情况;minimum_should_match参数则保证“中文”这类跨两个分词词项的查询能要求全部词项命中,避免误召回。
6.4 单元测试侧的三场景验证
Rust 侧的单元测试(与实现同文件的 pinyin_filter.rsmod tests)同样覆盖三个场景,且全部以 Jieba 为上游分词器、用is_subset(子集匹配)做断言:
- 整词全拼:
keep_joined_full_pinyin=true→ 期望包含zhongwen、ceshi; - 逐字全拼:
keep_full_pinyin=true→ 期望包含zhong、wen、ce、shi; - 首字母:
keep_separate_first_letter=true→ 期望包含zw、cs。
单测与上述 E2E 用例在输入“中文测试”上完全一致,形成了“Rust 过滤逻辑 ↔ SDK 端到端行为”的双层证据闭环。
7. 兼容性、迁移与选型建议
- 完全向后兼容、纯增量特性:不修改任何既有 analyzer 语义,MEP 声明对现有配置无影响;已有集合无需迁移。用户只需在 analyzer 配置里主动添加
"pinyin"filter 即可“opt-in”启用。 - 二进制体积影响可控:新增依赖仅
pinyin = "0.10"一个 crate,MEP 评估为“slightly increases compiled binary size”,对部署影响很小。 - 推荐组合(供选型参考):若目标是中文姓名/地名拼音检索,通常建议
keep_joined_full_pinyin=true(支持整词拼音,如zhangsan、beijing),并搭配keep_original=true保住原文匹配;若还需要“首字母缩写”检索(类似输入法声母联想zs),再加keep_separate_first_letter=true;若需要容纳“拼音逐字匹配长词中某个字”,则开keep_full_pinyin=true。三个开关也可全开,代价只是倒排词项数变多。 - 注意事项:拼音转换只作用于汉字字符;数字、拉丁字符等无法转写的部分会被
flatten()跳过,但其原文仍会因keep_original(或分词器自身行为)保留,不会被误删。另外过滤器产出的全是无音调纯拼音,音调无关的模糊拼音本身就是其设计目标,若需要拼音与汉字的语义消歧(同音字),仍需配合其它字段/模型手段。
8. 延伸阅读
- 本提案原始文档:docs/design-docs/design_docs/20260209-pinyin_filter.md
- 核心实现:internal/core/thirdparty/tantivy/tantivy-binding/src/analyzer/filter/pinyin_filter.rs
- 过滤器注册与分发:internal/core/thirdparty/tantivy/tantivy-binding/src/analyzer/filter/filter.rs
- analyzer 解析入口:internal/core/thirdparty/tantivy/tantivy-binding/src/analyzer/analyzer.rs
- 依赖声明(
pinyin = "0.10"):internal/core/thirdparty/tantivy/tantivy-binding/Cargo.toml - Go SDK 端到端用例(建集合/验词/检索全覆盖):tests/go_client/testcases/pinyin_filter_test.go
【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考