要实现根据字母做补全,就必须对文档按照拼音分词。GitHub上有相关插件,地址:https://github.com/medcl/elasticsearch-analysis-pinyin,下载和ES对应的版本。
安装步骤:
docker volume ls
docker inspect volumeXXX
docker restar es
但上面的拼音分词器也有很明显的缺陷,那就是没有进行词条切割,且汉字没了。
ES的分词器由三部分组成:
character filters:在tokenizer之前对文本进行处理。例如删除字符、替换字符
tokenizer:将文本按照一定的规则切割成词条(term)。例如keyword,就是不分词;还有ik_smart
tokenizer filter:将tokenizer输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等
举个例子:
在创建索引库时, 可以通过settings来配置自定义的analyzer(分词器):
PUT /test
{
"settings": {
"analysis": {
"analyzer": { // 自定义分词器
"my_analyzer": { // 分词器名称
"tokenizer": "ik_max_word",
"filter": "pinyin"
}
}
}
}
}
# 不需要替换或者删除,就不加character filter了
再改下定义tokenizer filter时的各种属性:
PUT /test
{
"settings": {
"analysis": {
"analyzer": { // 自定义分词器
"my_analyzer": { // 分词器名称
"tokenizer": "ik_max_word",
"filter": "py"
}
},
"filter": { // 自定义tokenizer filter
"py": { // 过滤器名称
"type": "pinyin", // 过滤器类型,这里是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
}
}
}
}
}
测试下效果:
插入两条name同音文档的后,搜索:
但此时搜一下中文看看:
很明显有问题,别人问狮子,你连同音词虱子都返回了。
拼音分词器适合在创建倒排索引时使用,但不能在搜索的时候使用。
创建倒排索引时,如下图:
但当使用拼音分词器来搜索时:
要解决这个问题,可以使用两个分词器:
"analyzer": "my_analyzer", # 创建倒排索引时使用
"search_analyzer": "ik_smart" # 搜索时使用
PUT /test
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ik_max_word", "filter": "py"
}
},
"filter": {
"py": { ... }
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart" //!!!!
}
}
}
}
重新测试下搜索中文的场景:
ES提供Completion Suggester查询 来实现自动补全功能,该查询会匹配以用户输入内容开头的词条并返回。此时,文档中字段的类型也有特殊要求:
// 创建索引库
PUT test
{
"mappings": {
"properties": {
"title":{
"type": "completion" //注意字段类型为completion
}
}
}
}
// 示例数据
POST test/_doc
{
"title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
"title": ["SK-II", "PITERA"]
}
POST test/_doc
{
"title": ["Nintendo", "switch"]
}
completion suggester查询语法:
// 自动补全查询
GET /test/_search
{
"suggest": { //查询类型,用suggest
"title_suggest": { //给你的suggest查询起个名
"text": "s", // 用户输入的关键字
"completion": {
"field": "title", // 补全查询的字段
"skip_duplicates": true, // 跳过重复的
"size": 10 // 获取前10条结果
}
}
}
}
运行DSL:
看下之前的索引库的结构:
PUT /hotel
{
"settings": {
"analysis": {
"analyzer": {
"text_anlyzer": { //定义第一个分词器
"tokenizer": "ik_max_word", //切割用ik_max
"filter": "py" //转换用拼音
},
"completion_analyzer": { //定义第二个分词器,用于自动补全,不分词,直接转拼音
"tokenizer": "keyword", //分词用keyword,因为参与自动补全的是一个个词条,这些词条放在数组当中,本身就是个词条
"filter": "py"
}
},
"filter": { //定义上面的拼音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", //类型为completion
"analyzer": "completion_analyzer" //不分词,直接转拼音
}
}
}
}
上面实现了:
上面索引库更新后,上一节中的代码也要发生修改:
//HotelDoc类修改
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
//距离
private Object distance;
//是否充广告
private Boolean isAD;
//ES中的completion,后面存数组,这里可以对应成List
private List<String> suggestion;
public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
this.suggestion = Arrays.asList(this.brand,this.business);
}
}
注意上面的Array.asList方法,使suggestion字段内容包含brand、business
//运行从MySQL读数据,插入文档到ES的单元测试代码
@SpringBootTest
public class HotelDocumentTest {
@Resource
IHotelService iHotelService;
private RestHighLevelClient client;
@Test
void testInit(){
System.out.println(client);
}
@BeforeEach
void setUp(){
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://10.4.130.220:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
@Test
void testBulk() throws IOException {
List<Hotel> hotels = iHotelService.list();
BulkRequest request = new BulkRequest();
for(Hotel hotel : hotels){
HotelDoc hotelDoc = new HotelDoc(hotel);
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc),XContentType.JSON)
);
}
client.bulk(request,RequestOptions.DEFAULT);
}
}
查看下文档数据:
在suggestion字段发现有数据的商业区有多个:
修改下HotelDoc的有参构造,加判断逻辑,business字段有斜杠/时,分开后再放入suggestion
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
//距离
private Object distance;
//是否充广告
private Boolean isAD;
//ES中的completion,后面存数组,这里可以对应成List
private List<String> suggestion;
public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
if(this.business.contains("/")){
//此时business有多个值,需要分开后放入suggestion
String[] arr = this.business.split("/");
//添加元素
this.suggestion = new ArrayList<>();
Collections.addAll(this.suggestion,arr);
this.suggestion.add(this.brand);
}else{
this.suggestion = Arrays.asList(this.brand,this.business);
}
}
}
重新运行单元测试,插入文档数据,可以看到切割完成了:
执行自动补全查询的DSL,比如搜索h:
Java代码对比DSL来看,查询的实现是:
对响应结果的处理是:
在单元测试中使用一下先:
@Test
void testSuggest() throws IOException {
//1、准备Request
SearchRequest request = new SearchRequest("hotel");
//2、准备DSL
request.source()
.suggest(new SuggestBuilder().addSuggestion(
"mySuggestion",
SuggestBuilders.completionSuggestion("suggestion")
.prefix("h") //搜索的关键字,这里用prefix,即前置,给方法起名很灵性
.skipDuplicates(true)
.size(10)
));
//3、发起请求
SearchResponse response = client.search(request,RequestOptions.DEFAULT);
//4、解析结果
Suggest suggest = response.getSuggest();
//4.1 根据不全查询名称,获取查询结果
CompletionSuggestion suggestion = suggest.getSuggestion("mySuggestion");
//4.2 获取options
List<CompletionSuggestion.Entry.Option> options = suggestion.getOptions();
//4.3 遍历
for (CompletionSuggestion.Entry.Option option : options) {
String text = option.getText().toString();
System.out.println(text);
}
}
运行得到以h开头的所有结果:
看下前端页面,每当在输入框键入时,前端会发送ajax请求:
接下来完善这个接口,实现搜索框的自动补全:
import cn.itcast.hotel.domain.dto.RequestParams;
import cn.itcast.hotel.domain.vo.PageResult;
import cn.itcast.hotel.service.IHotelService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/hotel")
public class HotelSearchController {
@Resource
IHotelService hotelService;
@GetMapping("/suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix){
return hotelService.getSuggestion(prefix);
}
}
public interface IHotelService extends IService<Hotel> {
List<String> getSuggestion(String prefix);
}
@Override
public List<String> getSuggestion(String prefix) {
try {
SearchRequest request = new SearchRequest("hotel");
request.source().suggest(new SuggestBuilder().addSuggestion(
"mySuggestion",
SuggestBuilders.completionSuggestion("suggestion")
.prefix(prefix)
.skipDuplicates(true)
.size(15)
));
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
Suggest suggest = response.getSuggest();
CompletionSuggestion mySuggestion = suggest.getSuggestion("mySuggestion");
List<CompletionSuggestion.Entry.Option> options = mySuggestion.getOptions();
return options.stream()
.map(t -> t.getText().toString())
.collect(Collectors.toList());
} catch (IOException e) {
throw new RuntimeException();
}
}
@SpringBootApplication
public class HotelDemoApplication {
public static void main(String[] args) {
SpringApplication.run(HotelDemoApplication.class, args);
}
@Bean
public RestHighLevelClient client(){
return new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://10.4.130.220:9200")
));
}
}
重启服务,看下效果:
自动补全成功实现!!