写在前面
作者对历史比较感兴趣,想做一个历史人物百科之类的网站或者小程序。类似于百度百科。在做搜索提示的时候,想做一个高亮提示。类似下图:
百度这里做的高亮是灰色,不是很明显。
于是想到利用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>