Elasticsearch 车牌号片段搜索:n-gram 倒排索引实战方案


当业务需要根据车牌号的任意片段(前缀、中间、后缀)模糊搜索完整号牌时,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 分词器默认按字符切割,中文字符能正确处理,但需注意:

  • lowercase filter 对中文无效(中文无大小写),不影响功能但不要期望它过滤中文
  • 如有全角/半角数字混入,需加 char_filter 做归一化

3. 搜索结果排序偏差

match 查询按 BM25 打分,n-gram 字段的评分会出现偏差:短车牌号匹配到的 token 比例更高,得分可能偏高。解决方案:

  1. 使用 function_score 校正
  2. 结果取回后在应用层做 contains 验证 + 自定义排序

4. 查询片段长度边界

片段长度 行为 处理建议
1 位(如"京") 若 min_gram=2 则无法命中 应用层拦截,提示"至少输入2位"
2-8 位 正常走 n-gram 倒排索引 标准 match 查询
>8 位(如完整号牌) 超出 max_gram,分词后拆为多个 token 直接用 plateNumber 字段 term 精确查询

5. 数据写入优化

  • 使用 bulk API 批量写入,每批 500-1000 条
  • 写入前禁用 refresh:"index.refresh_interval": "-1"
  • 写完后恢复:"index.refresh_interval": "1s"
  • 百万级车牌初始导入建议 4-8 个分片

6. 禁止使用 wildcard 查询

⛔ 绝对禁止 在生产环境使用 wildcard 查询(如 *234*)。wildcard 本质是全表逐行正则匹配,百万级数据下会导致 CPU 飙升、查询超时,甚至触发集群熔断。


总结

三步落地:

  1. 定义 n-gram 分词器 --- tokenizer 设置 min_gram=2, max_gram=8,搭配 lowercase filter
  2. Mapping 双字段 --- keyword 精确匹配 + text (n-gram) 片段搜索,两字段写入同一个值
  3. 查询用 match 走倒排索引 --- 片段搜索用 match,精确/前缀查询用 keyword 字段,禁止 wildcard

n-gram 分词器是解决车牌号片段搜索的标准方案。 核心优势:任意位置片段均可通过倒排索引 O(1) 命中,查询性能稳定。

关键指标预估(百万级车牌)

指标 预估值 说明
索引体积 3-5 GB 原始数据的 3-5 倍
倒排表条目 数百万级 去重后的独立 token 数
片段查询延迟 < 10ms 走倒排索引,P99
精确查询延迟 < 2ms keyword 字段 term 查询
初始导入时间 5-10 min bulk + 关闭 refresh
相关推荐
এ慕ོ冬℘゜1 小时前
ES Promise.all/ Promise.race
前端·javascript·elasticsearch
Sayai4 小时前
Elasticsearch 日志写入链路调优实战:ingest pipeline 补时间戳、时区偏移修复与按值类型路由(附 translog 实测)
大数据·运维·elasticsearch·搜索引擎·性能优化
总爱写点小BUG6 小时前
git-tidy:一个把「提交规范治理」做成闭环的 Git 桌面客户端
大数据·elasticsearch·搜索引擎
Elastic 中国社区官方博客21 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
BreezeJiang2 天前
写了 Dockerfile 却没生效:ES + Kibana 的 compose 配置里,藏着三个反直觉的点
elasticsearch
Elasticsearch2 天前
Elastic 计划利用 NVIDIA RTX GPU 将 Jina 模型推理速度提升至 8 倍
elasticsearch
Elastic 中国社区官方博客2 天前
告别繁琐控制台:Kabanos 如何将 MongoDB Compass 的易用性带入 Elasticsearch
大数据·数据库·mongodb·elasticsearch·搜索引擎·全文检索
Elastic 中国社区官方博客2 天前
控制告警如何触发、恢复和发送通知:Elastic 可观测性中的 Kibana 通用告警
大数据·数据库·安全·elasticsearch·搜索引擎·全文检索·es
深海里的鱼(・ω<)★2 天前
SMB备份导致的 Gitea 分支推送成功但界面不可见故障复盘
大数据·elasticsearch·gitea