ElasticSearch的自动补全功能(拼音分词器、自定义分词器、DSL实现自动补全查询、RestAPI实现自动补全查询)

发布于:2024-12-19 ⋅ 阅读:(13) ⋅ 点赞:(0)

视频教程:SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,系统详解springcloud微服务技术栈课程|黑马程序员Java微服务


阅读本文前可以先阅读以下文章:

1. 什么是自动补全

ElasticSearch 中的自动补全跟我们理解的自动补全不太一样,为了大家理解,我们来看一个案例

在这里插入图片描述

当我们在搜索框输入 sj 时,搜索框下方会显示以 sj 拼音首字母开头的词条(如手机、湿巾、数据线、史记、书架等),这个功能被称为自动补全

自动补全功能可以让用户尽可能地搜索到想要的东西,而不需要打出完整的内容

2. 拼音分词器

要想实现自动补全功能,我们需要先学习一下拼音分词器,因为自动补全功能是基于拼音分词器实现的

2.1 初识拼音分词器

拼音分词器的官网:analysis-pinyin

拼音分词器跟我们学过的 IK 分词器相似,都是 ElasticSearch 的一个插件

2.2 下载拼音分词器

下载地址:v7.17.18

在这里插入图片描述


本次演示使用的 ElasticSearch 版本为 7.17.18

其它 ElasticSearch 版本对应的拼音分词器的下载地址:Tags

2.3 安装拼音分词器

解压完成之后,将拼音分词器上传到 ElasticSearch 的 plugin 目录下(本次演示是通过 docker 安装 ElasticSearch 的)


先将拼音分词器上传到服务器,一般是当前用户的目录

cd ~

接着将拼音分词器复制到 ElasticSearch 的 plugin 的目录下

sudo cp elasticsearch-analysis-pinyin-7.17.18 -r /var/lib/docker/volumes/elasticsearch-plugins/_data

最后重启 ElasticSearch 容器

sudo docker restart elasticsearch

2.4 测试拼音分词器

我们在 Kibana 提供的控制台中测试拼音分词器是否生效


在浏览器打开 Kibana 提供的控制台

http://127.0.0.1:5601/app/dev_tools#/console

输入以下内容测试拼音分词器是否生效

POST /_analyze
{
  "text": [
    "练习时长两年半"
  ],
  "analyzer": "pinyin"
}

测试结果如下,主要包含两部分内容:

  1. 每个字的完整拼音
  2. 每个字的拼音首字母的合并

在这里插入图片描述

{
  "tokens" : [
    {
      "token" : "lian",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "lxsclnb",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "xi",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "shi",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "chang",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "liang",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 4
    },
    {
      "token" : "nian",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 5
    },
    {
      "token" : "ban",
      "start_offset" : 0,
      "end_offset" : 0,
      "type" : "word",
      "position" : 6
    }
  ]
}

3. 自定义分词器

3.1 拼音分词器存在的问题

拼音分词器还无法正常用于生产环境,因为拼音分词器存在一些问题

以 “练习时长两年半” 这句话为例,拼音分词器存在以下问题:

  1. “练习时长两年半” 这句话没有被分词,而是作为一个整体出现
  2. 把 “练习时长两年半” 这句话中的每一个字都形成了一个拼音(用处不大)
  3. 分词后的结果只剩下拼音,没有汉字

其实我们很少使用拼音搜索,大多数情况下我们都是使用中文去搜索的,分词后有拼音只是锦上添花,分词后的结果中汉字是必须保留的,所以我们需要对拼音分词器做一些配置,也就是自定义分词器

3.2 分词器(analyzer)的组成

ElasticSearch 中分词器(analyzer)的组成有三部分:

  • character filters:在 tokenizer 之前对文本进行处理,例如删除字符、替换字符
  • tokenizer:将文本按照一定的规则切割成词条(term),例如 keyword(不分词)、ik_smart 等
  • tokenizer filter:将 tokenizer 输出的词条做进一步处理,例如大小写转换、同义词处理、拼音处理等

在这里插入图片描述

3.3 如何自定义分词器

要想自定义分词器,一定要在创建索引库的时候去设置

我们可以在创建索引库时,通过 settings 来配置自定义的 analyzer(分词器)

自定义分词器时可以只设置分词器(analyzer)的某个部分

在这里插入图片描述

PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "pinyin"
        }
      }
    }
  }
}

tokenizer 我们使用 ik_max_word,先分词,分好词后再将词条交给拼音分词器处理,这样做可以解决拼音分词器没有分词的问题

但是拼音分词器还存在两个问题:分词后的每一个字都形成了一个拼音、分词后的结果只剩下拼音,没有汉字

3.4 拼音分词器的可选参数

我们需要对拼音分词器做进一步的定制

在拼音分词器的官网上,给出了很多的可选参数(Optional Parameters)

在这里插入图片描述

参数名称 含义
keep_first_letter 启用后,只保留每个汉字的第一个字母。例如,刘德华变为ldh。默认:true。
keep_separate_first_letter 启用后,保留每个汉字的第一个字母,并分别显示。例如,刘德华变为l,d,h。默认:false。注意:这可能会因词频增加查询的模糊度。
limit_first_letter_length 设置第一个字母结果的最大长度。默认:16。
keep_full_pinyin 启用后,保留每个汉字的完整拼音。例如,刘德华变为[liu,de,hua]。默认:true。
keep_joined_full_pinyin 启用后,将每个汉字的完整拼音连接起来。例如,刘德华变为[liudehua]。默认:false。
keep_none_chinese 保留结果中的非汉字字母或数字。默认:true。
keep_none_chinese_together 保留非汉字字母在一起。默认:true。例如,DJ音乐家变为DJ,yin,yue,jia。当设置为false时,DJ音乐家变为D,J,yin,yue,jia。注意:需要先启用keep_none_chinese。
keep_none_chinese_in_first_letter 在首字母中保留非汉字字母。例如,刘德华AT2016变为ldhat2016。默认:true。
keep_none_chinese_in_joined_full_pinyin 在连接的完整拼音中保留非汉字字母。例如,刘德华2016变为liudehua2016。默认:false。
none_chinese_pinyin_tokenize 如果非汉字字母是拼音,将其拆分为单独的拼音词。默认:true。例如,liudehuaalibaba13zhuanghan变为liu,de,hua,a,li,ba,ba,13,zhuang,han。注意:需要先启用keep_none_chinese和keep_none_chinese_together。
keep_original 启用后,保留原始输入。默认:false。
lowercase 将非汉字字母转换为小写。默认:true。
trim_whitespace 默认:true。
remove_duplicated_term 启用后,移除重复的词以节省索引空间。例如,de的变为de。默认:false。注意:可能与位置相关的查询受到影响。
ignore_pinyin_offset 在6.0版本之后,偏移量受到严格限制,不允许重叠的词。通过此参数,将允许重叠的词,忽略偏移量。请注意,所有与位置相关的查询或高亮将变得不正确。如果需要偏移量,请设置为false。默认:true。

3.5 配置自定义分词器的tokenizer和filter

在这里插入图片描述

PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  }
}

创建一个自定义的分词器my_analyzer,使用ik_max_word分词器进行中文分词,并通过pinyin过滤器将中文词条转换为拼音,保留了原始中文词条和连接起来的全拼,同时限制了首字母长度并移除重复的词条

3.6 如何使用自定义分词器

自定义分词器创建好了之后,该怎么使用呢

要使用自定义分词器,我们需要在定义索引库字段(Mapping)的时候使用

在这里插入图片描述

PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer"
      }
    }
  }
}

3.7 测试自定义分词器

3.7.1 直接测试

POST /test/_analyze
{
  "text": [
    "练习时长两年半"
  ],
  "analyzer": "my_analyzer"
}

测试结果

在这里插入图片描述

{
  "tokens" : [
    {
      "token" : "练习",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "lianxi",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "lx",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "时长",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "shichang",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "sc",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "两年",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "liangnian",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "ln",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "两",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "COUNT",
      "position" : 3
    },
    {
      "token" : "liang",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "COUNT",
      "position" : 3
    },
    {
      "token" : "l",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "COUNT",
      "position" : 3
    },
    {
      "token" : "年半",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "CN_WORD",
      "position" : 4
    },
    {
      "token" : "nianban",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "CN_WORD",
      "position" : 4
    },
    {
      "token" : "nb",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "CN_WORD",
      "position" : 4
    }
  ]
}

3.7.2 插入文档测试

测试数据如下(狮子和虱子的拼音是一样的

POST /test/_doc/1
{
  "id": 1,
  "name": "狮子"
}
POST /test/_doc/2
{
  "id": 2,
  "name": "虱子"
}

我们先通过拼音 shizi 来搜索

GET /test/_search
{
  "query": {
    "match": {
      "name": "shizi"
    }
  }
}

成功搜索出狮子和虱子

在这里插入图片描述

但如果我们搜索的内容是掉入狮子笼怎么办呢

GET /test/_search
{
  "query": {
    "match": {
      "name": "掉入狮子笼怎么办"
    }
  }
}

在这里插入图片描述

从搜索结果中我们可以发现,我们明明搜索的是狮子,怎么虱子也搜索出来了?

这说明我们自定义的分词器有问题,在用拼音搜索时确实没问题,但是在用中文搜索时却搜出了同音词

3.8 使用自定义分词器要注意的事项

拼音分词器适合在创建倒排索引的时候使用,但不能在搜索的时候使用


创建倒排索引时

在这里插入图片描述

在这里插入图片描述

用户搜索狮子,搜索结果中居然出现了虱子


所以,我们在创建倒排索引时使用的分词器要和搜索时使用的分词器分开

怎么分开呢,在创建倒排索引时使用 my_analyzer 分词器,搜索时使用 ik_smart 分词器

在这里插入图片描述

PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

我们删除 test 索引库之后,重写创建 test 索引库进行测试

DELETE /test
GET /test/_search
{
  "query": {
    "match": {
      "name": "掉入狮子笼怎么办"
    }
  }
}

测试结果如下(可以看到,搜索结果中没有虱子了)

在这里插入图片描述

4. DSL实现自动补全查询

ElasticSearch 提供了 Completion suggester 查询来实现自动补全功能,这个查询会匹配以用户输入内容开头的词条并
返回

4.1 字段的类型的约束

为了提高补全查询的效率,对于文档中字段的类型有一些约束:

  1. 参与补全查询的字段必须是 completion 类型
  2. 字段的内容一般是用来补全的多个词条形成的数组

在这里插入图片描述

4.2 查询语法

在这里插入图片描述

索引库

PUT test2
{
  "mappings": {
    "properties": {
      "title":{
        "type": "completion"
      }
    }
  }
}

测试数据

POST test2/_doc
{
  "title": ["Sony", "WH-1000XM3"]
}

POST test2/_doc
{
  "title": ["SK-II", "PITERA"]
}

POST test2/_doc
{
  "title": ["Nintendo", "switch"]
}

执行查询操作

POST /test2/_search
{
  "suggest": {
    "title_suggest": {
      "text": "s",
      "completion": {
        "field": "title",
        "skip_duplicates": true,
        "size": 10
      }
    }
  }
}

查询结果(查询结果中包含了文档的原始信息)

在这里插入图片描述

5. 自动补全案例

我们来做一个关于酒店数据的自动补全案例

5.1 准备工作

5.1.1 创建hotel索引库

PUT /hotel
{
  "settings": {
    "analysis": {
      "analyzer": {
        "text_anlyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        },
        "completion_analyzer": {
          "tokenizer": "keyword",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name": {
        "type": "text",
        "analyzer": "text_anlyzer",
        "search_analyzer": "ik_smart",
        "copy_to": "all"
      },
      "address": {
        "type": "keyword",
        "index": false
      },
      "price": {
        "type": "integer"
      },
      "score": {
        "type": "integer"
      },
      "brand": {
        "type": "keyword",
        "copy_to": "all"
      },
      "city": {
        "type": "keyword"
      },
      "starName": {
        "type": "keyword"
      },
      "business": {
        "type": "keyword",
        "copy_to": "all"
      },
      "location": {
        "type": "geo_point"
      },
      "pic": {
        "type": "keyword",
        "index": false
      },
      "all": {
        "type": "text",
        "analyzer": "text_anlyzer",
        "search_analyzer": "ik_smart"
      },
      "suggestion": {
        "type": "completion",
        "analyzer": "completion_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

5.1.2 导入测试工程

测试工程的 Gitee 地址:hotel-demo

5.1.3 导入酒店数据到数据库中

SQL 脚本在测试工程的 doc 目录下

在这里插入图片描述

5.1.4 将数据库中的数据导入到ElasticSearch

导入数据前,更改与连接 ElasticSearch 相关的信息(如果 ElasticSearch 没有设置密码,可以去除 setHttpClientConfigCallback 代码)

在这里插入图片描述

运行 HotelDocumentTest 测试类中的 testBulkRequest 方法,将数据库中的数据导入到 ElasticSearch


在 Kibana 提供的控制台检查数据是否导入成功

GET /hotel/_search
{
  "query": {
    "match_all": {}
  }
}

在这里插入图片描述

5.2 测试自动补全功能

在 Kibana 提供的控制台测试自动补全功能

GET /hotel/_search
{
  "suggest": {
    "suggestions": {
      "text": "s",
      "completion": {
        "field": "suggestion",
        "skip_duplicates": true,
        "size": 10
      }
    }
  }
}

测试结果

在这里插入图片描述

6. RestAPI实现自动补全查询

构建请求参数的 API

在这里插入图片描述

结果解析

在这里插入图片描述

import cn.itcast.hotel.service.IHotelService;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.search.suggest.Suggest;
import org.elasticsearch.search.suggest.SuggestBuilder;
import org.elasticsearch.search.suggest.SuggestBuilders;
import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;
import java.util.List;

@SpringBootTest
class HotelSuggestionTest {

    private RestHighLevelClient restHighLevelClient;

    @Autowired
    private IHotelService hotelService;

    @Test
    void testSuggestion() throws IOException {
        // 1.准备SearchRequest
        SearchRequest searchRequest = new SearchRequest("hotel");
        // 2.准备DSL
        searchRequest.source().suggest(new SuggestBuilder().addSuggestion(
                "suggestions",
                SuggestBuilders.completionSuggestion("suggestion")
                        .prefix("h")
                        .skipDuplicates(true)
                        .size(10)
        ));

        // 3.发送请求
        SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);

        // 4.解析结果
        // 4.1.获取suggest对象
        Suggest suggest = searchResponse.getSuggest();
        // 4.2.根据名称获取suggestion对象
        CompletionSuggestion suggestion = suggest.getSuggestion("suggestions");
        // 4.3.获取options
        List<CompletionSuggestion.Entry.Option> options = suggestion.getOptions();
        // 4.4.遍历
        for (CompletionSuggestion.Entry.Option option : options) {
            System.out.println("option.getText().string() = " + option.getText().string());
        }
    }

    @BeforeEach
    void setUp() {
        // 用户名和密码
        String username = "elastic";
        String password = "tF8RGg2vd0FAzgkK";

        final BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password));

        RestClientBuilder restClientBuilder = RestClient
                .builder(new HttpHost("127.0.0.1", 9200, "http"))
                .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
        restHighLevelClient = new RestHighLevelClient(restClientBuilder);
    }

    @AfterEach
    void tearDown() throws IOException {
        restHighLevelClient.close();
    }

}

7. 综合案例:实现搜索框自动补全

测试工程已实现搜索框自动补全,启动测试工程后,在浏览器中查看搜索框的自动补全效果

http://localhost:8089/

在这里插入图片描述


前端源代码的 Gitee 地址:auto-complete