es分词器之ik


写在前面

作者对历史比较感兴趣,想做一个历史人物百科之类的网站或者小程序。类似于百度百科。在做搜索提示的时候,想做一个高亮提示。类似下图:

百度这里做的高亮是灰色,不是很明显。

于是想到利用es高亮搜索实现。

mappings

PUT http://0.0.0.0:9200/search-history

{ 
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  }, 
  "mappings": {
    "properties": {
      "name":{
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "type": "text"
      },
      "created_at":{
        "type": "date"
      },
      "updated_at":{
        "type": "date"
      },
      "count":{
        "type": "integer"
      }
    }
  }
}

在 es创建search-history索引和mappings,这里没有指定type,默认使用_doc。

这里用到了ik分词器,下面讨论一下es中分词器相关知识。

分词器

standard

没有办法对中文进行合理分词的,只是将每个中文字符一个一个的切割开来,比如说「中华人民共和国国歌」就会简单的拆分为 中、华、人、民、共、和、国、国、歌

请求
{
  "analyzer": "standard",
  "text": "中华人民共和国国歌"
}

结果
{
    "tokens": [
        {
            "token": "中",
            "start_offset": 0,
            "end_offset": 1,
            "type": "<IDEOGRAPHIC>",
            "position": 0
        },
        {
            "token": "华",
            "start_offset": 1,
            "end_offset": 2,
            "type": "<IDEOGRAPHIC>",
            "position": 1
        },
        {
            "token": "人",
            "start_offset": 2,
            "end_offset": 3,
            "type": "<IDEOGRAPHIC>",
            "position": 2
        },
        {
            "token": "民",
            "start_offset": 3,
            "end_offset": 4,
            "type": "<IDEOGRAPHIC>",
            "position": 3
        },
        {
            "token": "共",
            "start_offset": 4,
            "end_offset": 5,
            "type": "<IDEOGRAPHIC>",
            "position": 4
        },
        {
            "token": "和",
            "start_offset": 5,
            "end_offset": 6,
            "type": "<IDEOGRAPHIC>",
            "position": 5
        },
        {
            "token": "国",
            "start_offset": 6,
            "end_offset": 7,
            "type": "<IDEOGRAPHIC>",
            "position": 6
        },
        {
            "token": "国",
            "start_offset": 7,
            "end_offset": 8,
            "type": "<IDEOGRAPHIC>",
            "position": 7
        },
        {
            "token": "歌",
            "start_offset": 8,
            "end_offset": 9,
            "type": "<IDEOGRAPHIC>",
            "position": 8
        }
    ]
}

es内置分词器还有其他几个

分词器 简介
Standard Analyzer 默认分词器,按此切分,小写处理
Simple Analyzer 按照非字母切分,小写处理
Stop Analyzer 停用词过滤,小写处理
Whitespace Analyzer 按照空格切分,不转小写
Keyword Analyzer 不分词,直接输出
Patter Analyzer 正则表达式

这些内置的分词器对中文极不友好,因此我们需要安装专门用于中文分词的ik分词器

ik

ik是比较成熟和流行的中文分词器,对中文分词友好。

ik有两种分词器,下面分别介绍一下。

  • ik_max_word:会将文本做最细粒度的拆分
{
    "tokens": [
        {
            "token": "中华人民共和国",
            "start_offset": 0,
            "end_offset": 7,
            "type": "CN_WORD",
            "position": 0
        },
        {
            "token": "中华人民",
            "start_offset": 0,
            "end_offset": 4,
            "type": "CN_WORD",
            "position": 1
        },
        {
            "token": "中华",
            "start_offset": 0,
            "end_offset": 2,
            "type": "CN_WORD",
            "position": 2
        },
        {
            "token": "华人",
            "start_offset": 1,
            "end_offset": 3,
            "type": "CN_WORD",
            "position": 3
        },
        {
            "token": "人民共和国",
            "start_offset": 2,
            "end_offset": 7,
            "type": "CN_WORD",
            "position": 4
        },
        {
            "token": "人民",
            "start_offset": 2,
            "end_offset": 4,
            "type": "CN_WORD",
            "position": 5
        },
        {
            "token": "共和国",
            "start_offset": 4,
            "end_offset": 7,
            "type": "CN_WORD",
            "position": 6
        },
        {
            "token": "共和",
            "start_offset": 4,
            "end_offset": 6,
            "type": "CN_WORD",
            "position": 7
        },
        {
            "token": "国",
            "start_offset": 6,
            "end_offset": 7,
            "type": "CN_CHAR",
            "position": 8
        },
        {
            "token": "国歌",
            "start_offset": 7,
            "end_offset": 9,
            "type": "CN_WORD",
            "position": 9
        }
    ]
}

比如会将「中华人民共和国国歌」拆分为:中华人民共和国、中华人民、中华、华人、人民共和国、人民、共和国、共和、国、国歌,会穷尽各种可能的组合;

  • ik_smart 最粗粒度的拆分
    {
        "tokens": [
            {
                "token": "中华人民共和国",
                "start_offset": 0,
                "end_offset": 7,
                "type": "CN_WORD",
                "position": 0
            },
            {
                "token": "国歌",
                "start_offset": 7,
                "end_offset": 9,
                "type": "CN_WORD",
                "position": 1
            }
        ]
    }

比如会将「中华人民共和国国歌」拆分为:中华人民共和国、国歌。

安装ik

下载ik插件:elasticsearch-analysis-ik-7.8.0.zip

注意:插件版本必须与es版本保持一致。作者使用docker安装es

docker run --name es02 -d \
  -p 9200:9200 \
  -p 9300:9300 \
  -e "discovery.type=single-node" \
  -v /var/docker/elastic:/usr/elastic \
  docker.elastic.co/elasticsearch/elasticsearch:7.8.0

在es的plugins目录下创建ik目录,将elasticsearch-analysis-ik-7.8.0.zip解压到ik目录中,然后重启es。elasticsearch-plugin list查看安装结果。

安装过程中遇到点麻烦,主要是插件版本和es版本不一致导致的。

还可以在线安装

./bin/elasticsearch-plugin  install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.8.0/elasticsearch-analysis-ik-7.8.0.zip

完全匹配问题

上面ik_max_word分词器将”中华人民共和国国歌”拆分成多个单词,导致搜索”中华人民共和国国歌”完整语句的时候,导致匹配不上,因为被拆分成了多个单词。

为了解决这个问题,需要在name字段上增加fields字段,type=keyword

{ 
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  }, 
  "mappings": {
    "properties": {
      "name":{
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "type": "text",
        "fields": {
            "keyword": {
                "type": "keyword",
                "ignore_above": 256
            }
        }
      },
      "created_at":{
        "type": "date"
      },
      "updated_at":{
        "type": "date"
      },
      "count":{
        "type": "integer"
      }
    }
  }
}

搜索的时候指定字段为name.keyword

{
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "name.keyword": {
              "value":"中华人民共和国国歌"
            }
          }
        }
      ]
    }
  }
}

通过以上方式则可以实现精准匹配

模糊匹配问题

nameQuery1 := elasticsearch7.NewWildcardQuery("name.keyword", "*"+keyword+"*")

模糊匹配使用name.keyword字段,因为这个字段上存储的是分词前的值,并且keyword前后需要加通配符”*”,实现模糊匹配。

为什么要模糊匹配

模糊匹配可以匹配上部分单词,例如”秦始皇“,分词之后还是”秦始皇”,当输入”秦”的时候,无法通过单词或者完全匹配来实现查询,使用模糊匹配就能匹配上

搜索完整代码

包括分词匹配、完全匹配、模糊匹配

// 高亮搜索
func (e *es) HighlightSearch(ctx context.Context, index, keyword string, size int, sort string, ascending bool) (any, error) {
	boolQ := elasticsearch7.NewBoolQuery()
	boolZ := elasticsearch7.NewBoolQuery()

	// 定义highlight
	highlight := elasticsearch7.NewHighlight()
	// 指定需要高亮的字段。这里写了2个字段:name和name.keyword,其中name用于分词匹配,name.keyword用于完全匹配
	highlight = highlight.Fields(elasticsearch7.NewHighlighterField("name"), elasticsearch7.NewHighlighterField("name.keyword"))
	// 指定高亮的返回逻辑 <span style='color: red;'>...msg...</span>
	highlight = highlight.PreTags(e.preTag).PostTags(e.postTag)

	// 分词匹配字段
	nameQuery := elasticsearch7.NewWildcardQuery("name", keyword)
	// 精确匹配字段
	nameKeywordQuery := elasticsearch7.NewTermsQuery("name.keyword", keyword)
	// 模糊匹配
	nameQuery1 := elasticsearch7.NewWildcardQuery("name.keyword", "*"+keyword+"*")
	boolZ.Filter(boolQ.Should(nameQuery, nameKeywordQuery, nameQuery1))

	res, err := e.client.Search(index).Highlight(highlight).Query(boolZ).Sort(sort, ascending).Size(size).Do(ctx)
	if err != nil {
		return nil, errors.Wrapf(err, "es HighlightSearch. keyword:%s", keyword)
	}

	// 高亮的输出和doc的输出不一样,这里要注意,我只输出了匹配到高亮的第一个词
	result := make([]any, 0)
	for _, highliter := range res.Hits.Hits {
		tmp := make(map[string]any)
		if err := json.Unmarshal(highliter.Source, &tmp); err != nil {
			return nil, errors.Wrapf(err, "es HighlightSearch Unmarshal. %v", highliter.Source)
		}
		tmp1 := make(map[string]any)
		// 这里的key可能是name和name.keyword,可以忽略key,只取value的第一个元素
		if values, ok := highliter.Highlight["name"]; ok {
			tmp1["matchValue"] = values[0]
			//result = append(result, )
		} else if values, ok = highliter.Highlight["name.keyword"]; ok {
			tmp1["matchValue"] = e.handle(values[0], keyword)
		}
		tmp1["id"] = highliter.Id

		tmp1["originValue"] = tmp["name"]
		result = append(result, tmp1)
	}
	return result, nil
}

func (e *es) handle(result, keyword string) string {
	splits := strings.Split(result, e.preTag)
	results := make([]string, 0)
	lKeyword := len(keyword)
	for i := range splits {
		s := splits[i]
		if len(s) == 0 {
			continue
		}
		match := s[:len(s)-len(e.postTag)]
		if len(match) == lKeyword {
			results = append(results, e.preTag+s)
		} else {
			idx := strings.Index(match, keyword)
			results = append(results, match[:idx]+e.preTag+keyword+e.postTag+match[idx+lKeyword:])
		}
	}
	return strings.Join(results, "")
}

效果

参考

[1] ik 分词器的安装与简单使用

[2] ES基本操作(2):IK分词器的安装(docker)与使用

br>


文章作者: Alex
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Alex !
  目录