前言
既然是文档中心,对于前台用户而言除了基本的文档阅览功能之外,最重要的功能莫过于根据关键词搜索文档了。那么这一点无论是对于英文还是中文,其本质其实都是全文搜索,只不过针对中文需要做一些额外处理。
Elasticsearch 简介
全文搜索属于最常见的需求,开源的 Elasticsearch 是目前全文搜索引擎的首选。它可以快速地存储、搜索和分析海量数据。维基百科、StackOverflow、Github 都采用它。Elasticsearch 底层基于开源库 Lucene
,但是你没法直接使用 Lucene,它只是一个全文搜索引擎的架构而不是完整的全文搜索引擎,必须自己写代码去调用它的接口。Elasticsearch 是 Lucene 的封装,提供了 REST API
的操作接口,开箱即用。
Elasticsearch 安装
使用 docker
方式安装:
docker pull elasticsearch
这样执行默认使用 latest tag,但是会报:Error response from daemon: manifest for elasticsearch:latest not found
。查阅 docker 官方镜像 Elasticsearch 说明得知,拉取镜像必须指定版本号 tag,不支持 latest tag。截止本文编写时,Elasticsearch 最新版本为7.6.0
,改为手动指定 tag 则成功拉取镜像:
docker pull elasticsearch:7.6.0
运行 Elasticsearch:
docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:7.6.0
Elasticsearch 基本概念
Node 与 Cluster
Elasticsearch 本质上是一个分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个 Elasticsearch 实例。单个 Elasticsearch 实例称为一个节点(node)
,一组节点构成一个集群(cluster)
。
Index
Elasticsearch 数据管理的顶层单位叫做Index(索引)
,它是单个数据库的同义词。每个 Index (即数据库)的名字必须是小写。
不像较老的 sphinx
或 coreseek
(基于 sphinx 的中文搜索工具)一样需要定时运行主索引+增量索引
来达到非实时的搜索效果,Elasticsearch 存储时会即时索引所有字段,经过处理后写入一个倒排索引(Inverted Index)
,搜索数据的时候将直接查找该索引,从而达到近乎实时的搜索效果。
查看当前节点所有 Index 的命令如下:
curl -X GET 'http://localhost:9200/_cat/indices?v'
Document
Index 里面单条的记录称为Document(文档)
。在 Elasticsearch 中,术语文档
有着特定的含义,它是指最顶层或者根对象,这个根对象被序列化成 JSON
并存储到Elasticsearch中,指定了唯一 ID。许多条 Document 构成了一个 Index。
Document 使用 JSON
格式表示,下面是一个例子:
{
"title": "UOS 账号注册",
"category":"新人指南",
"content": "UOS 账号注册内容"
}
同一个 Index 里面的 Document 不要求有相同的结构(schema)
,但是最好保持相同,这样有利于提高搜索效率。
Type
Elasticsearch 公开了一个称为 。Elasticsearch 官网提出的近期版本对 type 概念的演变情况如下:type (类型)
的特性,它允许在索引中对数据进行逻辑分组。不同 types 的文档可能有不同的字段,但最好能够非常相似
在5.X
版本中,一个 index 下可以创建多个 type;
在6.X
版本中,一个 index 下只能创建一个 type;
在7.X
版本中,已彻底移除 type,但实际上存在一个叫_doc
的默认唯一 type。
IK 中文分词插件
搜索需求
本文开头提到了文档搜索功能,这一功能实际上涉及两点需求,即中文分词(含关键词分词和文档标题及内容分词)及搜索结果中关键词高亮。为什么单独提出中文分词功能呢?简而言之,默认的分词器是按照英文单词之间自然的空格进行切分的,这显然不符合中文世界的场景。
IK 插件简介
为了满足这两点需求,就需要额外安装中文分词插件。我们在文档中心项目中选用的是 IK。IK 插件将 Lucene IK
分析器集成到 Elasticsearch 中,并支持自定义词典。其中分析器(Analyzer)
及标记器(Tokenizer)
都可以指定 ik_smart
和 ik_max_word
这两种方式。
插件安装
进入到 Elasticsearch 根目录下执行安装:
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.6.0/elasticsearch-analysis-ik-7.6.0.zip
安装后重启 Elasticsearch 即可。
基本使用
- 首先创建存储文档的索引,在这里我们使用 doc 作为索引名称:
curl -X PUT http://localhost:9200/doc
- 为需要分词的字段指定分词器:
curl -X POST http://localhost:9200/doc/_mapping -H 'Content-Type:application/json' -d '
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
}
}
}'
对于文档中心,我们需要为标题及内容字段指定分词器。其中 analyzer
是字段文本的分词器,search_analyzer
是搜索词的分词器。ik_max_word
和 ik_smart
两种分词器的区别如下:ik_max_word
:会将文本做最细粒度的拆分,例如「中华人民共和国国歌」会被拆分为「中华人民共和国、中华人民、中华、华人、人民共和国、人民、共和国、共和、国、国歌」,会穷尽各种可能的组合,适合 Term
查询;
ik_smart
:会将文本做最粗粒度的拆分,例如「中华人民共和国国歌」会被拆分为「中华人民共和国、国歌」,适合 Phrase
查询。
我们希望字段内容做最细粒度的拆分,以尽可能增加被搜索命中的几率,因此指定 ik_max_word
分词器。而对于搜索词来说,应将文本做最粗粒度的拆分,以尽可能增加搜索结果的语义匹配度,因此指定 ik_smart
分词器。
- 存储一篇文档,举例如下:
curl -X POST 'localhost:9200/doc/_doc' -H 'Content-Type:application/json' -d '
{
"title": "UOS 账号注册",
"content": "UOS 官网账号和 UOS 开发者后台账号通用,如果您在开发者后台注册过账号, 您可以使用已经注册的开发者账号登录系统。"
}'
如果业务中没有自然 ID,这里可以不手动指定 ID,由 Elasticsearch 帮我们自动生成 ID。自动生成的 ID 是 URL-safe、 基于 Base64
编码且长度为20
个字符的 GUID
字符串。这些 GUID 字符串由可修改的 FlakeID 模式生成,这种模式允许多个节点并行生成唯一 ID ,且互相之间的冲突概率几乎为零。可以看到如上命令执行结果返回中有 _id 这一项,其值类似:H4Dib3ABkQHg1-LvUNAq 这样。
- 关键词搜索:
curl -X POST http://localhost:9200/doc/_doc/_search -H 'Content-Type:application/json' -d '
{
"query" : { "match" : { "content" : "注册账号" }},
"highlight" : {
"pre_tags" : ["<tag1>"],
"post_tags" : ["</tag1>"],
"fields" : {
"content" : {}
}
}
}
'
例如我们使用关键词“注册账号
”搜索文档,并指定<tag1>标签作为高亮关键词的包含标签,返回结果如下:
{
"took":56,
"timed_out":false,
"_shards":{
"total":1,
"successful":1,
"skipped":0,
"failed":0
},
"hits":{
"total":{"value":1,"relation":"eq"},
"max_score":0.4868466,
"hits":[{
"_index":"doc",
"_type":"_doc",
"_id":"IIAfcHABkQHg1-LvWtCp",
"_score":0.4868466,
"_source":{
"title": "UOS 账号注册",
"content": "UOS 官网账号和 UOS 开发者后台账号通用,如果您在开发者后台注册过账号, 您可以使用已经注册的开发者账号登录系统。"
},
"highlight":{
"content":[
"UOS 官网<tag1>账号</tag1>和 UOS 开发者后台<tag1>账号</tag1>通用,如果您在开发者后台<tag1>注册</tag1>过<tag1>账号</tag1>, 您可以使用已经<tag1>注册</tag1>的开发者<tag1>账号</tag1>登录系统。"
]
}
}]
}
}
其中 hits
数组中 highlight
键对应返回内容中<tag1>标签包含的即为匹配的搜索关键词(即俗称的关键词高亮
),可以看出账号注册
已被分词插件切分为了账号
和注册
。如果没有安装中文分词插件的话,那么则只能搜索到包含有账号注册
且连在一起的文档。
配置单字词典
默认没有配置单字词典的情况下,分词器是按照默认主配置中的中文词汇词组进行分词切分的。但某些业务场景中,这样就不能满足需求了。还是上面关键词搜索的例子,但我们希望搜索号
就能搜索到包含号
的文档:
curl -X POST http://localhost:9200/doc/_doc/_search -H 'Content-Type:application/json' -d '
{
"query" : { "match" : { "content" : "号" }},
"highlight" : {
"pre_tags" : ["<tag1>"],
"post_tags" : ["</tag1>"],
"fields" : {
"content" : {}
}
}
}
'
返回结果如下:
{
"took":1,
"timed_out":false,
"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},
"hits":{
"total":{"value":0,"relation":"eq"},
"max_score":null,
"hits":[]
}
}
可以看到并无文档匹配。说明当初那篇文档存储建立倒排索引并执行中文分词时账号
这个词是作为一个词组,并没有切分为账
和号
两个字。对于这一点,我们可以使用 analyze
接口进行分词测试的验证:
curl -X GET "http://localhost:9200/doc/_analyze" -H 'Content-Type: application/json' -d '
{
"text": "账号", "tokenizer": "ik_max_word"
}'
返回结果如下:
{
"tokens":[{
"token":"账号",
"start_offset":0,
"end_offset":2,
"type":"CN_WORD",
"position":0
}]
}
可以看出结果验证了我们的结论即:账号
这个词是作为一个词组,并没有切分为账
和号
两个字。
现在我们来配置单字词典,打开{conf}/analysis-ik/config/IKAnalyzer.cfg.xml
,找到<entry key="ext_dict"></entry>
这一行,在这里可以配置自己的扩展字典,我们在此加上需要的单字词典:<entry key="ext_dict">extra_single_word.dic</entry>
。其实 IK 插件中自带了单字词典,extra_single_word.dic
这个文件就在插件配置目录中,只不过默认没有启用。加好之后重新启动 Elasticsearch :docker restart elasticsearch
,再来执行之前的分词测试,这次返回结果如下:
{
"tokens":[
{
"token":"账号",
"start_offset":0,
"end_offset":2,
"type":"CN_WORD",
"position":0
},
{
"token":"账",
"start_offset":0,
"end_offset":1,
"type":"CN_WORD",
"position":1
},
{
"token":"号",
"start_offset":1,
"end_offset":2,
"type":"CN_WORD",
"position":2
}
]
}
可以看到这次的结果中账号
这个词已经被切分为了账
和号
两个单字,说明配置的单字词典已经生效了。
现在如果再执行之前搜索号
的操作是否就有匹配结果了呢?我们再次执行后却发现结果仍然一样,这是为什么呢?实际上由于我们是在配置单字词典前就存储索引了那篇文档的,而当时建立倒排索引并没有将文档内容中的账号
切分为账
和号
,因此搜索号
自然还是匹配不到了。我们可以重新加一篇文档如下:
curl -X POST 'localhost:9200/doc/_doc' -H 'Content-Type:application/json' -d '
{
"title": "UOS2 账号注册",
"content": "UOS2 官网账号和 UOS2 开发者后台账号通用,如果您在开发者后台注册过账号, 您可以使用已经注册的开发者账号登录系统。"
}'
再次执行搜索号
的操作,结果如下(这次只摘取 highlight
部分):
"highlight":{
"content":[
"UOS2 官网账<tag1>号</tag1>和 UOS 开发者后台账<tag1>号</tag1>通用,如果您在开发者后台注册过账<tag1>号</tag1>, 您可以使用已经注册的开发者账<tag1>号</tag1>登录系统。"
]
}
可以看到这次就有了匹配结果,并且号
也作为关键字被包裹在指定的高亮标签<tag1>
中。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。