当业务需要根据车牌号的任意片段(前缀、中间、后缀)模糊搜索完整号牌时,ES 原生的 keyword 精确匹配和 prefix 前缀查询都无法胜任。本文详细分析如何通过 n-gram 分词器 + 双字段策略,利用倒排索引实现 O(1) 级别的任意片段搜索,并给出完整的索引 Mapping、查询示例和注意事项。
需求背景
某车辆编码登记系统需要支持根据车牌号的部分字符查询完整号牌。典型场景:
| 输入片段 | 片段位置 | 应匹配结果 |
|---|---|---|
京A |
前缀(2位) | 京A123456, 京A789012 ... |
234 |
中间(3位) | 京A123456, 沪B234567 ... |
56 |
后缀(2位) | 京A123456, 粤C000056 ... |
核心难点:片段可能出现在号牌的任意位置,长度不固定(1~多位),这不是简单的"前缀匹配"或"精确匹配"能解决的。
倒排索引能否解决?
能解决,但必须配合 n-gram 分词器。
原生 keyword 类型只支持精确匹配和前缀查询,无法满足"中间/后缀片段"搜索。
标准倒排索引的工作方式:文档经分词后,每个 token 指向包含该 token 的文档列表。如果车牌号 "京A123456" 只作为一个完整 token 存入倒排索引,搜索 "234" 时根本无法命中------倒排表里没有 "234" 这个 token。
而 n-gram 分词器会将 "京A123456" 拆解为所有长度的子串 token,写入倒排索引。查询时片段 "234" 也被分词为 token "234",直接在倒排表中命中。
匹配流程
输入片段 "234" → n-gram 分词 (min=2, max=8) → 生成 token "234" → 倒排索引命中 (O(1))
Token 生成示例
车牌号 "京A123456" 在 n-gram 分词后产生的 token(min=2, max=8):
京A A1 12 23 34 45 56 京A1 A12 123 234 345 456 京A12 A123 1234 2345 3456 京A123 ...
^^^
搜索 "234" 直接命中此 token
n-gram 分词器方案详解
核心设计:双字段策略
| 字段名 | 类型 | 分词器 | 用途 |
|---|---|---|---|
plateNumber |
keyword | 不分词 | 精确匹配、排序、聚合 |
plateNumberSearch |
text | n-gram 自定义 | 任意片段模糊搜索 |
入库时两个字段写入同一个车牌号值,ES 自动对 plateNumberSearch 做 n-gram 分词建倒排索引。
n-gram 分词器原理
n-gram 分词器将输入文本按滑动窗口切分为固定长度范围的子串:
min_gram:子串最小长度max_gram:子串最大长度- 对于
"京A123456"(8字符),min=2/max=8 时生成 (7+6+5+4+3+2+1) = 28 个 token
min_gram / max_gram 参数选择
| 配置 | 索引膨胀 | 查询能力 | 建议 |
|---|---|---|---|
| min=1, max=10 | 最大 | 支持单字符搜索 | 不推荐(1位搜索无实际意义) |
| min=2, max=8 | 适中 | 最少2位片段搜索 | 推荐方案 |
| min=3, max=6 | 较小 | 最少3位片段 | 索引空间受限时 |
💡 建议 min=2:1位搜索(如只搜"A")结果太泛且无实际意义,车牌号至少需要2位片段才有辨识度。百万级车牌、min=2/max=8 的索引体积约为原始数据的 3-5 倍,可控。
索引 Mapping 完整定义
创建索引
json
PUT /plate_index
{
"settings": {
"max_ngram_diff": 20,
"analysis": {
"tokenizer": {
"plate_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 8
}
},
"analyzer": {
"plate_ngram_analyzer": {
"type": "custom",
"tokenizer": "plate_ngram_tokenizer",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"plateNumber": {
"type": "keyword"
},
"plateNumberSearch": {
"type": "text",
"analyzer": "plate_ngram_analyzer"
},
"ownerName": {
"type": "keyword"
},
"plateType": {
"type": "keyword"
},
"registerDate": {
"type": "date",
"format": "yyyy-MM-dd"
}
}
}
}
全角/半角数字归一化(可选)
如果存在全角数字混入的情况,可添加 char_filter 做归一化:
json
"char_filter": {
"fullwidth_to_halfwidth": {
"type": "mapping",
"mappings": [
"0=>0", "1=>1", "2=>2", "3=>3",
"4=>4", "5=>5", "6=>6", "7=>7",
"8=>8", "9=>9"
]
}
}
然后在 analyzer 中引用:"char_filter": ["fullwidth_to_halfwidth"]
写入数据示例
json
POST /plate_index/_doc/1
{
"plateNumber": "京A123456",
"plateNumberSearch": "京A123456",
"ownerName": "张*",
"plateType": "电动两轮车",
"registerDate": "2026-03-15"
}
批量写入(bulk API)
json
POST /plate_index/_bulk
{"index":{"_id":"1"}}
{"plateNumber":"京A123456","plateNumberSearch":"京A123456"}
{"index":{"_id":"2"}}
{"plateNumber":"沪B234567","plateNumberSearch":"沪B234567"}
查询方式与示例
基础片段搜索(推荐)
json
GET /plate_index/_search
{
"query": {
"match": {
"plateNumberSearch": "234"
}
}
}
✅ 不需要 wildcard,不需要 match_phrase ,纯
match查询即可,走倒排索引,性能 O(1) 级别。
前缀精确搜索
如果明确知道是前缀,直接用 keyword 字段更高效:
json
GET /plate_index/_search
{
"query": {
"prefix": {
"plateNumber": "京A"
}
}
}
完整号牌精确查询
json
GET /plate_index/_search
{
"query": {
"term": {
"plateNumber": "京A123456"
}
}
}
片段搜索 + 相关性排序优化
n-gram 字段的 BM25 评分有偏差(短车牌号匹配到的 token 比例更高,得分可能偏高),可用 function_score 校正:
json
GET /plate_index/_search
{
"query": {
"function_score": {
"query": {
"match": { "plateNumberSearch": "234" }
},
"functions": [
{
"field_value_factor": {
"field": "plateNumberLength",
"factor": 0.1,
"modifier": "log1p"
}
}
]
}
}
}
或在应用层对返回结果做 contains 验证 + 自定义排序。
方案对比
| 方案 | 任意位置片段 | 性能 | 索引膨胀 | 实现复杂度 | 结论 |
|---|---|---|---|---|---|
| n-gram 分词器 | ✅ 支持 | 极快(走倒排) | 3-5x | 低 | ✅ 推荐 |
wildcard *234* |
✅ 支持 | 极慢(全扫描) | 无 | 低 | ❌ 不可用 |
| keyword prefix | ❌ 仅前缀 | 快 | 无 | 低 | ⚠️ 不满足 |
| CJK + ngram 混合 | ✅ 支持 | 快 | 较大 | 中 | ⚠️ 备选 |
| MySQL LIKE | ✅ 支持 | 慢 | 无 | 低 | ❌ 不可用 |
注意事项
1. 索引膨胀监控
n-gram 的本质是空间换时间 。以 8 位车牌 "京A123456" 为例,min=2/max=8 时生成 28 个 token。百万级车牌约产生 数千万 token ,加上重复合并,实际倒排表条目约 数百万 ,单分片占用约 1-2 GB。
- 设置
index.max_ngram_diff足够大(≥20) - 定期
_forcemerge减少段数 - 监控
store.size和segments.memory
2. 中文省份简称处理
车牌号含汉字(京、沪、粤等),n-gram 分词器默认按字符切割,中文字符能正确处理,但需注意:
lowercasefilter 对中文无效(中文无大小写),不影响功能但不要期望它过滤中文- 如有全角/半角数字混入,需加 char_filter 做归一化
3. 搜索结果排序偏差
match 查询按 BM25 打分,n-gram 字段的评分会出现偏差:短车牌号匹配到的 token 比例更高,得分可能偏高。解决方案:
- 使用
function_score校正 - 结果取回后在应用层做
contains验证 + 自定义排序
4. 查询片段长度边界
| 片段长度 | 行为 | 处理建议 |
|---|---|---|
| 1 位(如"京") | 若 min_gram=2 则无法命中 | 应用层拦截,提示"至少输入2位" |
| 2-8 位 | 正常走 n-gram 倒排索引 | 标准 match 查询 |
| >8 位(如完整号牌) | 超出 max_gram,分词后拆为多个 token | 直接用 plateNumber 字段 term 精确查询 |
5. 数据写入优化
- 使用
bulkAPI 批量写入,每批 500-1000 条 - 写入前禁用 refresh:
"index.refresh_interval": "-1" - 写完后恢复:
"index.refresh_interval": "1s" - 百万级车牌初始导入建议 4-8 个分片
6. 禁止使用 wildcard 查询
⛔ 绝对禁止 在生产环境使用
wildcard查询(如*234*)。wildcard 本质是全表逐行正则匹配,百万级数据下会导致 CPU 飙升、查询超时,甚至触发集群熔断。
总结
三步落地:
- 定义 n-gram 分词器 --- tokenizer 设置 min_gram=2, max_gram=8,搭配 lowercase filter
- Mapping 双字段 --- keyword 精确匹配 + text (n-gram) 片段搜索,两字段写入同一个值
- 查询用 match 走倒排索引 --- 片段搜索用
match,精确/前缀查询用 keyword 字段,禁止 wildcard
n-gram 分词器是解决车牌号片段搜索的标准方案。 核心优势:任意位置片段均可通过倒排索引 O(1) 命中,查询性能稳定。
关键指标预估(百万级车牌)
| 指标 | 预估值 | 说明 |
|---|---|---|
| 索引体积 | 3-5 GB | 原始数据的 3-5 倍 |
| 倒排表条目 | 数百万级 | 去重后的独立 token 数 |
| 片段查询延迟 | < 10ms | 走倒排索引,P99 |
| 精确查询延迟 | < 2ms | keyword 字段 term 查询 |
| 初始导入时间 | 5-10 min | bulk + 关闭 refresh |