TOC
引言
在实际项目中,我们经常需要处理用户输入的不规范查询,例如:
- 拼写错误:用户输入
"Elesticsearch"
,但我们希望匹配"Elasticsearch"
。 - 前后缀变形:搜索
"runing"
也能找到"running"
。 - 拼音/简繁体转换:输入
"zhong guo"
仍能匹配"中国"
。
这些场景都涉及模糊查询,但 Elasticsearch 并不像 SQL 那样简单地支持 LIKE '%keyword%'
。如何在 ES 中实现灵活的模糊搜索?是否应该使用 fuzzy
、match
还是 wildcard
?如何调整分词策略,提高查询的准确性?
本文将结合实际案例,深入简单探讨 Elasticsearch 的模糊查询方法,包括拼写纠错、Ngram、Edge Ngram、正则匹配等技术方案,帮助你在业务中更高效地实现模糊搜索。
原始数据
先往ES中写入一份简单的演示数据,两行记录:
POST all_key/_doc/1
{
"from": "platform-1",
"kind": "service",
"key": "my service1",
"value": "https://my.service.gogodjzhu.com",
"create_time": "2024-12-23 17:18:09",
"update_time": "2024-12-23 17:18:09"
}
POST all_key/_doc/2
{
"from": "platform-1",
"kind": "service",
"key": "my service2",
"value": "https://my.service.gogodjzhu.com",
"create_time": "2024-12-23 17:18:09",
"update_time": "2024-12-23 17:18:09"
}
由于ES的动态字段特性,会根据字段值推测相应的字段类型,自动创建响应的索引(类似于mysql中的表)。
严格上来说,ES中的索引(index)对应的是mysql的库;ES还有一个type才是跟mysql的表对应的概念。不过在7.x之后的版本淡化了type的定义,并限制一个index中只能有唯一的type,所以将index与mysql表对应,也没问题。
可以通过_mapping
接口查看索引当前的mapping定义:
GET all_key/_mapping
{
"all_key" : {
"mappings" : {
"_doc" : {
"properties" : {
"key" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
}
}
}
}
}
}
目前比较简单,所有的字段都被推测为text类型,并附加了一个嵌套字段类型为keyword。
其中text
类型是ES中最常见的类型,会以倒排索引方式存储数据,许多ES的高级特性都是基于这种类型来实现的。由于倒排索引的结构,因此text
类型字段已经失去了原始值,索引存储的都是切分词(term),指向文档ID。
而keyword
类型存储数据的方式是结构化的,可以支持过滤(filtering)、聚合(aggregation)和排序(sorting)操作。ignore_above: 256
参数则是将长度超过256的字符串裁剪保存。
NOTE:// 简单介绍倒排索引
你可能对倒排索引不太熟悉,这里用一个简单的类比来说明。
假设你有一本关于动物的书,想知道“猫”这个词在哪些页出现。
- 正向索引 类似于逐页翻阅整本书,遇到“猫”时记录页码。这种方式直观,但每次查询都要重新扫描整本书,效率低下。
- 倒排索引 则像书后的索引表,提前整理好每个词对应的页码。查找“猫”时,直接翻到索引表,立刻得出结果,大幅提高查询速度。
你可能会问,正向索引 不是也能建立索引吗?比如按照“猫、狗、鱼、鸟”进行分类,基于类别来检索? 问题在于,我们需要查找“猫”这个词在哪些页出现,而不是哪篇文章是关于“猫”的。由于“猫”可能出现在书中任何一页,基于分类的索引几乎无用,只能全文扫描。
这正是正向索引的局限——它依赖文档的元数据(分类、作者、发布时间等),无法直接检索文档的具体内容。而倒排索引则不同,它直接为文档内容本身建立索引,记录每个词与文档的关系(如出现次数、位置等),让搜索变得高效。
正则表达式/match检索
有了ES自动推测的索引类型,我们已经可以使用两种基础的检索方法。首先是正则搜索,
GET all_key/_search
{
"query": {
"regexp": {
"key": "my.*"
}
}
}
// 返回文档: [1, 2]
很简单对吧,返回key
命中正则表达式my.*
的文档[1, 2]
。接着修改表达式尝试匹配更长的字符串。
GET all_key/_search
{
"query": {
"regexp": {
"key": "my service.*"
}
}
}
// 返回文档: []
奇怪,竟然没有结果返回。这是因为key
字段的类型是text
在底层是以倒排索引的方式存储的,而regexp检索的对象是分词(term)而不是字段原文。比如my service1
已经被拆分为[my, service1]
两个term,都无法命中正则my service.*
,所以返回结果为空。
这时候保存了字段原文的keyword
字段才能使用正则检索。
GET all_key/_search
{
"query": {
"regexp": {
"key.keyword": "my service.*"
}
}
}
// 返回文档: [1, 2]
另外一种常见的检索方式是match
,他的查询语法如下:
GET all_key/_search
{
"query": {
"match": {
"key": "my service1"
}
}
}
// 返回文档: [1, 2]
竟然两个文档都命中了,docId=2的值不是my service2
么?为什么也被检索出来了?这是因为match
查询的查询流程是:
- 将查询条件
my service1
拆分为[my, service1]
- 分别用
[my, service1]
和所有文档的key字段的分词(term)做匹配,其中docId=1
有两个term[my, service1]
能匹配上,而docId=2
也能匹配上一个term[my]
。 - 默认情况下,
match
查询会返回命中任意term的文档。也可以通过operator=(or|and)
标记来控制是否所有查询term都命中才返回。甚至使用minimum_should_match
来精确控制最少需要命中的term数量。
GET all_key/_search
{
"query": {
"match": {
"key": {
"query": "my service1",
"operator": "and"
}
}
}
}
// 返回文档: [1]
case-senstive
再看一个例子,无论大小写字符串做match
检索的均能命中文档:
GET all_key/_search
{
"query": {
"match": {
"key": "My"
}
}
}
// 返回文档: [1, 2]
这是因为ES对text
类型字段做分词操作,是由分析器(analyzer)来实现的,默认使用的standared
分析器会将所有大写字符转化为小写。可以通过_analyze
验证分词效果:
POST _analyze
{
"analyzer": "standard",
"text": "My service1"
}
// 返回结果,可以发现`My service1`被切割为两个字符串处理为小写`[my, service1]`。
{
"tokens" : [
{
"token" : "my",
"start_offset" : 0,
"end_offset" : 2,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "service1",
"start_offset" : 3,
"end_offset" : 11,
"type" : "<ALPHANUM>",
"position" : 1
}
]
}
除了默认的
standared
分析器之外,还内置有其他几个常用的分析器:
simple
:使用简单的分词器,按非字母字符分割文本。"Text-analysis 123!"
->["text", "analysis"]
whitespace
:按空格分割文本,保留原始字符,不进行小写转换。"The Quick Brown Fox!"
->["The", "Quick", "Brown", "Fox!"]
stop
:类似于simple
分析器,但会移除停用词。"The Quick Brown Fox!"
->["quick", "brown", "fox"]
pattern
:基于正则表达式的分词器,允许自定义分割规则。"2025-01-10,Elasticsearch-Tokenizer"
->["2025", "01", "10", "Elasticsearch", "Tokenizer"]
(默认正则匹配模式为\W+
)ngram
:基于固定长度切分字符串,下文会具体介绍。
因此为了保留大小写信息,我们需要重建索引,并指定使用大小写敏感的分析器,比如whitespace
:
// 重建索引指定使用whitespace分析器
PUT all_key
{
"mappings": {
"_doc": {
"properties": {
"key": {
"type": "text",
"analyzer": "whitespace"
}
}
}
}
}
// 写入文档
POST all_key/_doc/1
{
"key": "my service1"
}
POST all_key/_doc/2
{
"key": "my service2"
}
// 执行检索
GET all_key/_search
{
"query": {
"match": {
"key": "My"
}
}
}
// 返回文档: [], 大小写敏感生效
值得注意的是,分析器对字符串进行处理不仅发生在文档写入的时候,也发生在查询阶段。在上面的例子中,默认都使用了mapping中定义的分析器。事实上也可以配置在不同阶段使用不同的分析器,后文我们会继续介绍。
fuzziness模糊匹配
回到检索服务的实际场景,还有一个常见的容错需求。当我们错误输入servce1
的时候,我们希望也能检索出最相关的文档来。fuzziness是满足需求的一个特性。
(继续进行之前,请重建索引使用standared
分词器)
GET all_key/_search
{
"query": {
"match": {
"key": {
"query": "servce1",
"fuzziness": 1
}
}
}
}
// 返回文档: [1]
fuzziness
的原理是在倒排查询之前,先计算查询term
和倒排索引词典
中所有词的编辑距离d
,取其中的满足d<=fuzziness
的所有term组成一组should clause
的term query
。
以这里的查询为例,查询term为servce1
,和倒排索引词典
中所有词的编辑距离为:my(7), service1(1), service2(2)
,只有service1
满足编辑距离小于1的条件,因此将上述查询改写为类似下面这样的查询:
// fuzziness改写后的查询
GET all_key/_search
{
"query": {
"bool": {
"should": [
{
"term": {
"key": {
"value": "service1"
}
}
}
]
}
}
}
// 返回文档: [1]
由于根据编辑距离生成term列表的结果可能非常大,所以ES(准确地说是Lucene)通过许多手段限制term列表无限扩张:
fuzziness
的可选值为[0,1,2],避免差异过大的词被选中prefix_length
控制最小匹配前缀长度——基于”大多数拼写错误不发生在字符串头部“的场景假设max_expansions
控制should
查询的子句数量,即控制候选term的数量。因为term过多,即便匹配上了准确率也不高,不如直接过滤掉一些
ngram/edge_ngram模糊匹配
与fuzzy类似,ngram也是模糊匹配场景下一个常见的手段。
**“gram”**的字面含义来源于希腊语 “γράμμα” (gramma),意思是“书写”或“字符”。在语言处理领域,gram表示构成文本的一组基本单位,通常是一个单词/词组。在ES中则相对简单一点,是指按照固定长度切割的一组字符串。n指的则是字符串的长度。
ES中提供的ngram是一个分词器(Tokenizer),可以通过_analyze
接口测试它的效果:
POST _analyze
{
"tokenizer": "ngram",
"text": "Hi U"
}
// 返回token列表: ['H', 'Hi', 'i', 'i ', ' ', ' U', 'U' ]
// 指定min/max gram来控制分词粒度
POST _analyze
{
"tokenizer": {
"type": "ngram",
"min_gram": 3,
"max_gram": 4
},
"text": "Hi U"
}
// 返回token列表: ['Hi ', 'Hi U', 'i U']
在ngram完成分词之后,term过滤和match检索流程是标准化的。下面是一个完整的例子:
// 重建索引
PUT all_key
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ngram"
}
}
}
},
"mappings": {
"_doc": {
"properties": {
"key": {
"type": "text",
"analyzer": "my_analyzer"
}
}
}
}
}
// 重新写入文档
POST all_key/_doc/1
{
"key": "my service1"
}
POST all_key/_doc/2
{
"key": "my service2"
}
// 测试
GET all_key/_search
{
"query": {
"match": {
"key": "my"
}
}
}
// 返回文档: [1, 2]
与ngram
类似的,还有另一个分词器edge_ngram,区别在于后者只会从边缘(edge)开始拆分n个gram,而且每次拆分的时候都从最边缘开始:
POST _analyze
{
"tokenizer": {
"type": "edge_ngram",
"min_gram": 5,
"max_gram": 8
},
"text": "hello world"
}
// 返回token列表['hello', 'hello ', 'hello w']
edge_ngram
的头部拆分特性导致其比较适合做完全前缀补全/匹配,当拼写错误发生在字符串中间的时候,便无法使用。
NOTE:// fuzzy和ngram的比较
两者都是模糊匹配场景中常用的检索方法,但是在思路上两者有一些差异。
fuzziness
是match查询的一个算法优化,核心是在所有term中过滤出最相似的(编辑距离最小的)term,不需要对原文档做专门的分词处理。缺点是性能跟数据量/查询条件有很大的关系,特别是大数据集上可能很慢。
ngram
是一个分词器,核心思路还是将字符串拆成倒排表的term,以空间换时间加速检索。会增加索引大小,但查询性能比较稳定。实际使用中都需要针对场景进行响应的参数调优,在准确率和召回率之间取得平衡。
Analyzer组件概述
前面介绍了使用ES进行数据模糊匹配的常见方案,涉及到了ES中的几个非常核心的概念:token、term、filter、tokenizer、analyzer。每一个-er
组件又包含着诸多具体的实现。很头疼吧?理解的核心是ES的核心倒排表,核心步骤文档拆分为分词则是由analyzer包揽的。
Analyzer定义了处理字符串的一系列流程,包含了三个组件:
- character filters:字符过滤器(或字符映射器),作用于文本输入的第一阶段,通常用于字符转换或清理,比如去除 HTML 标签、替换特殊字符等。
- tokenizers:分词器,负责把输入文本拆分成一个个词(Token),是整个分词过程的核心。不同分词器有不同的策略,如按空格拆分(Standard Tokenizer)、基于词典拆分(IK 分词器)等。
- filters:过滤器,作用于分词后的词项(Token),用于进一步处理,如转换大小写、去除停用词、词干提取等。
ES提供了默认的组件配置组合,即默认的Analyzer;用户也可以自己组合/配置他们,实现自己的逻辑。相信这时候你看到下面这个analyzer时,对他的功能已经尽皆。
POST _analyze
{
"char_filter": [ // 第一步
{
"type": "html_strip"
}
],
"tokenizer": "standard", // 第二步
"filter": [ // 第三步
"lowercase",
"stop"
],
"text": "<p>Elasticsearch is AWESOME!</p>"
}
// 返回分词列表:
//{
// "tokens": [
// {
// "token": "elasticsearch",
// "start_offset": 3,
// "end_offset": 16,
// "type": "<ALPHANUM>",
// "position": 0
// },
// {
// "token": "awesome",
// "start_offset": 20,
// "end_offset": 27,
// "type": "<ALPHANUM>",
// "position": 2
// }
// ]
//}
NOTE:// Token与Term的比较
term
和token
是两个密切相关但有所区别的概念。token由tokenizer
(分词器) 和token_filter
(词元过滤器) 在分析器(analyzer
)的处理流程中生成。在分词(生成token
)之后,ES会将这些token
存入倒排索引,并将其作为term
存储。
对比项 Token(词元) Term(词项) 定义 分词后生成的文本最小单位 倒排索引中存储的实际单元 产生阶段 分词( analyzer
)阶段索引阶段 存储位置 临时生成,用于索引构建或查询时分析 永久存储在倒排索引中 是否处理大小写 可能会处理,如小写化 已经过滤器处理后存储 查询关系 查询文本通过分词器生成 tokens
,再与索引中的terms
匹配查询时直接与存储的 terms
匹配作用范围 索引和查询分析过程 查询匹配和倒排索引查询