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匹配作用范围 索引和查询分析过程 查询匹配和倒排索引查询