前言

目前很多公司都在使用ElasticSearch作为全文搜索,全文搜索的应用场景是通过分词来模糊匹配找到与用户输入相近的内容,类似百度这类的搜索引擎,但是在TOB业务中用户往往更习惯使用类似Mysql数据中Like来精准搜索,当数据量非常大时使用Mysql的Like查询效率会变的非常低,使用ElasticSearch来解决此类查询是很常见的解决方案,不同于Mysql,使用ElasticSearch来做精准查询时由于分词不精准,可能会导致部分文档无法召回的情况,本文针对这种情况提供了一种能完美替代数据库Like查询的方案,希望这个方案能帮助你解决在使用ElasticSearch精准搜索时遇到的类似的问题。

精准搜索遇到的问题

我们使用常用IK分词器中的ik_max_word来对标题字段进行分词,然后添加一条数据:人大四次会议开幕

PUT /test_search
{
    "mappings": {
        "properties": {
            "title": {
                "type": "text",
                "analyzer": "ik_max_word"
            }
        }
    }
}

POST /test_search/_doc/1
{
  "title":"人大四次会议开幕"
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

然后使用match_phrase来精准搜索,类似数据库查询语句where title like '%人大四次%,从直观的感受来看这条数据是100%能搜索出来的,然而结果却出乎所料,是无法搜索出文档的。对于用户来说是无法理解的,明明“人大四次会议开幕”标题中完整包含了“人大四次”这四个字,为什么却搜索不出来。当我们遇到这种问题时是无法从技术上给用户解释通的,越解释只会让他们觉得你们公司的技术非常的差,这么简单的问题都解决不了,我们唯一要做的就是赶紧想办法优化我们的代码,让文档能正确的搜索出来。

如何让ElasticSearch完美实现数据库的Like查询_搜索引擎

原因分析

首先我们要清楚match_phrase搜索的底层原理:

match_phrase查询首先将查询字符串解析成一个词项列表(分词),然后对这些词项进行搜索,但只保留那些包含全部搜索词项,且位置与搜索词项相同的文档

从Elasticsearch官网的定义来看,match_phrase搜索分为三步:

  1. 使用analyzer对搜索的关键词进行分词,默认使用Mapping字段中定义的分词器
  2. 匹配包含所有分词后词项的文档
  3. 文档中每个词项的相对位置和查询关键词分词后的词项相对位置要一样

有了上面match_phrase的搜索原理我们来一步一步分析一下,为什么人大四次搜索不到人大四次会议开幕

第一步:文档和搜索关键词使用ik_max_word分词:

GET _analyze
{
  "analyzer": "ik_max_word",
  "text": "人大四次"
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

“人大四次”的分词结果:

{
  "tokens" : [
    {
      "token" : "人大四次",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "人大",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "大四",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "四次",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 3
    },
    {
      "token" : "四",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "TYPE_CNUM",
      "position" : 4
    },
    {
      "token" : "次",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "COUNT",
      "position" : 5
    }
  ]
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.

”人大四次会议开幕“的分词结果:

{
  "tokens" : [
    {
      "token" : "人大四次",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "人大",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "大四",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "四次会议",
      "start_offset" : 2,
      "end_offset" : 6,
      "type" : "CN_WORD",
      "position" : 3
    },
    {
      "token" : "四次",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 4
    },
    {
      "token" : "四",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "TYPE_CNUM",
      "position" : 5
    },
    {
      "token" : "次",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "COUNT",
      "position" : 6
    },
    {
      "token" : "会议",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "CN_WORD",
      "position" : 7
    },
    {
      "token" : "开幕",
      "start_offset" : 6,
      "end_offset" : 8,
      "type" : "CN_WORD",
      "position" : 8
    }
  ]
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.

第二步:比较文档中是否包含搜索关键词分词后的所有词项(分词结果中的token)

人大四次总共分了6个词项:人大四次、人大、大四、四次、四、次

人大四次会议开幕总共分了9个词项:人大四次、人大、大四、四次会议、四次、四、次、会议、开幕

文档分词后的9个词项中包含了搜索关键词分词后的全部6个词项,这步也是没有问题了。

第三步:比较每个词项的相对位置是否一样

如何让ElasticSearch完美实现数据库的Like查询_搜索_02

从这张对比图来看,文档中大四四次之间多了一个四次会议,这就和搜索词项的相对位置不一样了,导致文档无法被搜索出来了。从这个问题来看,使用match_phrase无法召回文档的原因:是因为搜索关键词在文档中前后语境不一样,导致分词后每个词项的位置可能不一样,从而使match_phrase这种对位置敏感的匹配算法无法召回对应的文档

当然match_phrase不会这么弱,它也是可以使用另一个参数slop来解决这种词项差几个位置就无法召回的情况。

slop定义:查询关键词中的token要经过几次移动才能与文档匹配,这个移动的次数就是slop

在上面的问题中当我们设置slop=1时就能搜索出来

如何让ElasticSearch完美实现数据库的Like查询_子串_03

这样做确实可以解决分词顺序不一样导致部门文档无法召回的情况,但是也会引入噪声,让搜索不是正真的Like查询,如:会议开幕关键词可以把会议1开幕搜索出来:

如何让ElasticSearch完美实现数据库的Like查询_子串_04

  • 会议开幕分词为:会议、开幕
  • 会议1开幕分词为:会议、1、开幕

我们只要把会议开幕中的开幕向后移动一位就能和会议1开幕匹配上了,也就说”会议开幕“和”会议1开幕“的slop=1,所以添加参数slop=1就能让文档被召回了。不过这并不是我们想要的,因为已经不是数据库中的Like查询了,所以我们要另寻它法。

解决方法

standard分词器是Elasticsearch默认分词器,适用于大多数英文文本。它会根据空格、标点符号等将文本拆分成词项,并进行小写转换和标点符号过滤。以下为使用standard分词的结果:

  • 人大四次会议开幕:人、大、四、次、会、议、开、幕
  • 人大四次:人、大、四、次

就是这样简单粗暴的分词反而不会受到前后语境的影响,不会改变分词后每个token的位置,这样使用match_phrase也就能搜索出来了。我们在title中再添加一个字段titleStandard,使用standard分词。

PUT /test_search
{
    "mappings": {
        "properties": {
            "title": {
                "type": "text",
                "analyzer": "ik_max_word",
                "fields": {
                    "titleStandard": {
                        "type": "text",
                        "analyzer": "standard"
                    }
                }
            }
        }
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

然后指定title.titleStandard字段使用match_phrase这样就正常召回文档了。

如何让ElasticSearch完美实现数据库的Like查询_后端_05

测试Like替代率

因为我们是要完美实现数据库的Like查询,所以我们定义一个Like替代率

Like替代率:成功搜索到子串/全部连续子串

比如我们文档标题为浙江杭州,总连续子串为10个

浙
浙江
浙江杭
浙江杭州
江
江杭
江杭州
杭
杭州
州
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

如果是使用Mysql,这些子串都是可以搜索出浙江杭州标题的,所以Mysql的Like替代率=100%。如果Elasticsearch的Like替代率=100%那么我们就可以说Elasticsearch完美实现的数据库的Like查询,接下来我们就来测试一下各方案的Like替代率

以下为一个简单的查询代码,query()方法使用match_phrase查询指定字段

public static List<ESDoc> query(String field, String keyword) throws IOException {
    SearchRequest searchRequest = new SearchRequest();
    searchRequest.indices("test_search");
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    BoolQueryBuilder query = new BoolQueryBuilder();
    MatchPhraseQueryBuilder matchPhraseQuery = QueryBuilders.matchPhraseQuery(field, keyword).slop(0).boost(1);
    query.must(matchPhraseQuery);
    searchSourceBuilder.query(query);
    searchRequest.source(searchSourceBuilder);
    SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
    SearchHits hits = response.getHits();
    Iterator<SearchHit> iterator = hits.iterator();
    List<ESDoc> esDocs = new ArrayList<>();
    while (iterator.hasNext()) {
        SearchHit hit = iterator.next();
        Map<String, Object> sourceAsMap = hit.getSourceAsMap();
        if (sourceAsMap != null) {
            String title = sourceAsMap.get("title").toString();
            ESDoc esDoc = new ESDoc();
            esDoc.setTitle(title);
            esDocs.add(esDoc);
        }
    }
    return esDocs;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

main()方法中将标题所有连续子串依次查询,然后统计出Like替代率

public static void main(String[] args) throws IOException {
    Map<String, Boolean> res = new LinkedHashMap<>();
    String query = "人大四次会议开幕";
    for (int i = 0; i < query.length(); i++) {
        for (int j = i + 1; j <= query.length(); j++) {
            String searchWord = query.substring(i, j);
            List<ESDoc> searchRes = query("title", searchWord);
            if (searchRes.size() > 1) {
                res.put(searchWord, true);
            } else {
                res.put(searchWord, false);
            }
        }
    }
    double total = res.size();
    long successCount = res.values().stream().filter(x -> x).count();
    System.out.println("全部连续子串::"+res.size());
    System.out.println("成功搜索到子串:"+successCount);
    System.out.println("Like替代率:" + successCount * 100.0 / total + "%");
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

使用ik_max_word分词query("title", searchWord)运行结果:

全部连续子串::36
成功搜索到子串:11
Like替代率:30.5%
  • 1.
  • 2.
  • 3.

无法搜索到的子串

人
人大四
人大四次
人大四次会
人大四次会议
人大四次会议开
人大四次会议开幕
大
大四
大四次
大四次会
大四次会议
大四次会议开
大四次会议开幕
四次会
四次会议开
次会
次会议开
会
会议开
议
议开
议开幕
开
幕
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

standard分词query("title.titleStandard", searchWord)运行结果:

全部连续子串::36
成功搜索到子串:36
Like替代率:100.0%
  • 1.
  • 2.
  • 3.

从上面的Case来看使用Standard分词的ElasticSearch好像真的可以100%替代数据库Like查询了。不过当我们换一个Case情况就不一样了。

POST /test_search/_doc/1
{
"title":"2024《人大四次会议》在new york开幕"
}
  • 1.
  • 2.
  • 3.
  • 4.

这时的测试结果:

总连续子串::275
成功搜索到子串:117
Like替代率:42.5%
  • 1.
  • 2.
  • 3.

无法召回的部分子串:

20
202
24《人大
24《人大四
24《人大四次
2024《人大四次会议》在n
2024《人大四次会议》在ne
会议》在new yo
会议》在new yor
议》在n
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

原因是因为2024、new、york这三个被当成了一个整体,导致只要是带有这三个的子串的子串都无法召回文档,这就导致Like替代率只有42.5%。

终极解决方案

我们可以使用自定义分词器,主要实现两个功能:

  1. 过滤非(字母、数字、汉字)的字符,去除特殊字符的影响
  2. 使用ngram按字符拆成一个一个字符
PUT /test_search
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "my_tokenizer",
          "char_filter": ["my_char_filter"]
        }
      },
      "char_filter": {
        "my_char_filter": {
          "type": "pattern_replace",
          "pattern": "[^\\p{L}\\p{N}]+",
          "replacement": ""
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "ngram",
          "min_gram": 1,
          "max_gram": 1
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "my_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      }
    }
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.

这时2024《人大四次会议》在new york开幕拆词为:2、0、2、4、人、大、四、次、会、议、在、n、e、w、y、o、r、k、开、幕

再次测试结果:

总连续子串::275
成功搜索到子串:272
Like替代率:98.9%
  • 1.
  • 2.
  • 3.

无法召回的三个子串为:

《
》
空格
  • 1.
  • 2.
  • 3.

当然如果我们不过滤特殊字符串Like替代率就是100%,不过从实际用户搜索来看过滤特殊字符串会带来更好的用户体验,实际搜索中不会有人去拿这样的特殊字符串去搜索想要的内容,在这里Like替代率去除这三个特殊字符串的影响就是100%。

可以得出结论:本方案可以让ElasticSearch完美的实现Mysql数据库的Like查询,我为此结论负责。

总结

本文深入分析了ElasticSearch使用match_phrase实现类似数据库Like精准查询对有些词无法召回的原因,并给出了使用standard分词器可以解决大部分查询场景,最后给出使用自定义分词器完美实现数据库Like查询的技术方案,同时本方案还超越了Like查询,过滤特殊字符串的影响可以给用户带来更好的搜索体验,这种方案有以下优点:

  1. 完美实现数据库Like查询,能做和Like查询一样的召回率,可100%替代数据库Like查询
  2. 超越了Like查询,可以忽略特殊字符串,让搜索结果更符合用户实际使用习惯
  3. 实现非常简单,如果之前是使用match_phrase查询,只需要修改mapping字段分词器类型,无需修改查询代码
  4. 提高模糊搜索召回率,可以和match查询一起使用,大大提高模糊搜索的召回率

当然本方案也是有缺点的:

  1. 额外添加字段,因为搜索不可能都是精准搜索,大部门还是需要再添加一个ik_max_word做模糊搜索,所以这里为了精准索引专门增加了一个字段
  2. 索引膨胀,单字分词会将每个字符都作为独立的token处理,这会导致索引中包含大量的短token,进而导致索引文件变大。更大的索引不仅占用更多的磁盘空间,还可能影响查询性能,因为需要加载和处理更多的数据
  3. 查询性能下降,尽管使用了单字分词可以提升某些查询的召回率,但对于大规模数据集来说,过多的小粒度tokens可能导致查询执行时间增长,特别是在进行短语匹配或近似匹配时,因为系统需要检查更多可能的组合