Elasticsearch模糊查询实践小记

Monday, March 17, 2025

TOC

#English_Version

引言

在实际项目中,我们经常需要处理用户输入的不规范查询,例如:

  • 拼写错误:用户输入 "Elesticsearch",但我们希望匹配 "Elasticsearch"
  • 前后缀变形:搜索 "runing" 也能找到 "running"
  • 拼音/简繁体转换:输入 "zhong guo" 仍能匹配 "中国"

这些场景都涉及模糊查询,但 Elasticsearch 并不像 SQL 那样简单地支持 LIKE '%keyword%'。如何在 ES 中实现灵活的模糊搜索?是否应该使用 fuzzymatch 还是 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查询的查询流程是:

  1. 将查询条件my service1拆分为[my, service1]
  2. 分别用[my, service1]和所有文档的key字段的分词(term)做匹配,其中docId=1有两个term[my, service1]能匹配上,而docId=2也能匹配上一个term[my]
  3. 默认情况下,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 clauseterm 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的比较

termtoken是两个密切相关但有所区别的概念。token由tokenizer(分词器) 和 token_filter(词元过滤器) 在分析器(analyzer)的处理流程中生成。在分词(生成token)之后,ES会将这些token存入倒排索引,并将其作为term存储。

对比项 Token(词元) Term(词项)
定义 分词后生成的文本最小单位 倒排索引中存储的实际单元
产生阶段 分词(analyzer)阶段 索引阶段
存储位置 临时生成,用于索引构建或查询时分析 永久存储在倒排索引中
是否处理大小写 可能会处理,如小写化 已经过滤器处理后存储
查询关系 查询文本通过分词器生成 tokens,再与索引中的 terms 匹配 查询时直接与存储的 terms 匹配
作用范围 索引和查询分析过程 查询匹配和倒排索引查询