2

前言

既然是文档中心,对于前台用户而言除了基本的文档阅览功能之外,最重要的功能莫过于根据关键词搜索文档了。那么这一点无论是对于英文还是中文,其本质其实都是全文搜索,只不过针对中文需要做一些额外处理。

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 (即数据库)的名字必须是小写。

不像较老的 sphinxcoreseek(基于 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 公开了一个称为 type (类型)的特性,它允许在索引中对数据进行逻辑分组。不同 types 的文档可能有不同的字段,但最好能够非常相似。Elasticsearch 官网提出的近期版本对 type 概念的演变情况如下:

5.X 版本中,一个 index 下可以创建多个 type;
6.X 版本中,一个 index 下只能创建一个 type;
7.X 版本中,已彻底移除 type,但实际上存在一个叫 _doc 的默认唯一 type。

IK 中文分词插件

搜索需求

本文开头提到了文档搜索功能,这一功能实际上涉及两点需求,即中文分词(含关键词分词和文档标题及内容分词)及搜索结果中关键词高亮。为什么单独提出中文分词功能呢?简而言之,默认的分词器是按照英文单词之间自然的空格进行切分的,这显然不符合中文世界的场景。

IK 插件简介

为了满足这两点需求,就需要额外安装中文分词插件。我们在文档中心项目中选用的是 IK。IK 插件将 Lucene IK 分析器集成到 Elasticsearch 中,并支持自定义词典。其中分析器(Analyzer)标记器(Tokenizer)都可以指定 ik_smartik_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_wordik_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>中。


NoTryNoSuccess
130 声望2 粉丝