本文转载自微信公众号《程序员李小兵》,作者李小兵。转载本文请联系程序员李小兵公众号。本文重点分析ElasticSearch是如何在全文搜索前使用ik进行分词的,让大家对ElasticSearch的全文搜索和ik中文分词原理有一个全面深入的了解。全文搜索和精确匹配ElasticSearch支持文本类型数据的全文搜索和精确搜索,但是需要提前设置对应的类型:关键词类型,入库时不做分词处理,精确查询和词-支持分段匹配查询;文本类型,存储时会进行分词处理,同时支持精准查询和分词匹配查询。例如创建一个名为article的索引(Index),为其两个字段(Filed)配置映射(Mapping),将文章内容设置为文本类型,将文章标题设置为关键字类型。存储时,Elasticsearch会对文章内容字段进行分词,获取分词后的token并保存;对于文章标题,不做分词处理,直接保存原值。上图右半部分展示了关键字和文本类型的不同存储过程。左半部分展示了ElasticSearch对应的两种查询方式:term查询,即精准查询,不进行分词,直接根据输入词进行查询;matchquery,即分词匹配查询,首先对输入词进行分词,然后对分词后的token进行逐条查询。比如有两篇文章,一篇的标题和内容是“程序员”,另一篇的标题和内容是“程序”,那么这两篇文章在ElasticSearch中的倒排索引存储如下(假设一个特殊的分词器)。此时使用term和matchquery分别查询这两个字段,就会得到如图右侧的结果。从Analyzer的处理过程可以看出,关键字和文本类型、term和match查询方式的区别在于是否进行分词。在ElasticSearch中,这个分词过程统称为Textanalysis,也就是将一个字段从非结构化字符串(文本)转换为结构化字符串(关键字)的过程。文本分析不仅进行分词,还包括以下过程:使用字符过滤器(Characterfilters)对原文进行一些处理,如去除空白字符等;使用分词器(Tokenizer)对原文进行一些分词处理,得到一些token;使用记号过滤器(Tokenfilters)继续处理上一步得到的记号,例如改变记号(小写)、删除记号(删除量词)或添加词法(添加同义词)、合并同义词等处理文本的组件ElasticSearch中的分析称为Analyzer。相应的,Analyzer也由三部分组成,characterfilters、tokenizers和tokenfilters。Elasticsearch内置了3个字符过滤器、10个分词器和31个词法过滤器。另外,第三方实现的相应组件也可以通过插件机制获取。开发者可以根据自己的需要自定义Analyzer的组件。"analyzer":{"my_analyzer":{"type":"custom","char_filter":["html_strip"],"tokenizer":"standard","filter":["lowercase",]}}如上配置,my_analyzer分析器的功能大致如下:字符过滤器为html_strip,会去除与HTML标签相关的字符;tokenizer是ElasticSearch的默认标准tokenizer标准;tokenfilter是一个lowercase小写处理器,它把英文单词小写化。一般来说,Analyzer中最重要的是分词器。分词结果的好坏将直接影响搜索的准确性和满意度。ElasticSearch默认的分词器并不是中文分词的最佳选择。目前业界主要使用ik进行中文分词。ik分词原理ik是目前主流的ElasticSearch开源中文分词组件。内置基础中文词库和分词算法,帮助开发者快速构建中文分词和搜索功能。它还提供扩展的同义词词典和远程词典,方便开发者在互联网上扩展生词或流行语。ik提供了三个内置词典,分别是:main.dic:主词典,收录程序员、编程等日常常用词;quantifier.dic:量词字典,包括每天的量词,如米、公顷、小时等;stopword.dic:停用词,主要指英语中的停用词,如a、such、that等;另外,开发者可以通过配置扩展词库词典和远程词典来扩展上述词典。ik在启动ElasticSearch时,会读取默认词典和扩展词典加载到内存中,使用词典树tiretree(也叫前缀树)数据结构进行存储,方便后续分词。字典树的典型结构如上图所示。每个节点都是一个词。从根节点到叶子节点,将路径上传递的字符连接起来,形成该节点对应的词。所以上图中的文字包括:程序员、程门立雪、编织、编码和工作。1、loadingdictionaryik的Dictionary单例对象在初始化时会调用相应的load函数读取字典文件,构造出由DictSegment组成的三棵字典树,分别是MainDict、QuantifierDict和StopWords。下面看一下它的主词典的加载构建过程。loadMainDict函数比较简单。它会先创建一个DictSegment对象作为字典树的根节点,然后加载默认主字典、扩展主字典和远程主字典来填充字典树。复制代码在loadDictFile函数执行过程中,会从字典文件中逐行读取单词,交给DictSegment的fillSegment函数处理。fillSegment是构建字典树的核心函数。具体实现如下。处理逻辑大致有以下步骤:1、根据索引,获取词中的一个词;2.检查该词是否存在于当前节点的子节点中。如果没有,则将其添加到charMap;3.调用lookforSegment函数在字典树中寻找代表该词的节点,如果没有则插入一个新节点;4.递归调用fillSegment函数处理下一个单词。ik的初始化过程大致是这样的。更详细的逻辑可以直接去看源码。中间有中文注释,比较容易看。2、分词逻辑在ik中实现了ElasticSearch相关的抽象类,提供自己的分词逻辑实现:IKAnalyzer继承Analyzer,提供中文分词分析器;IKTokenizer继承了Tokenizer提供一个分词器,用于中文分词。它的incrementToken是ElasticSearch调用ik进行分词的入口函数。incrementToken函数会调用IKSegmenter的next方法获取分词结果,是ik分词的核心方法。如上图所示,IKSegmenter中一共有三个分词器。在进行分词时,会遍历单词中的所有单词,然后通过三个分词器对单词进行顺序处理:LetterSegmenter,英文分词器比较简单,就是将连续的英文字符用于分词;CN_QuantifierSegmenter,中文量词分词??器,判断当前字符是否为数词和量词,将相连的数词和量词分割为一个词;核心分词器CJKSegmenter,根据上面的词典树进行分词。这里只解释一下CJKSegmenter的实现。它的analyze函数大致分为两种逻辑:根据词在字典树中查询,如果是词则生成词;如果是单词前缀,则将其放入临时命中列表;然后根据单词和前面处理时保存的临时命中列表数据去字典树中查询。如果命中,则生成一个词。具体代码逻辑如上所示。为了方便大家理解,我们举个例子。比如输入的词是编码工作:先进行编码处理;因为当前tmpHits为空,直接判断单词;直接用encoding查询上图的字典树(详见matchInMainDict函数),发现可以命中,而且这个词不是词尾,所以会编译,其位置在输入词生成一个Hit对象,并将其存储在tmpHits中。然后处理码字;由于tmpHits不为空,取对应的Hit对象和codeword到字典树中查询(详见matchWithHit函数),发现wordcode命中,故将此word作为输出词单元1之一。存储在AnalyzeContext中;但是因为code已经是叶子节点,没有子节点,说明不是其他词的前缀,所以删除对应的Hit对象;然后用单字码在字典树中查询,看这个词是不是一个词,或者形成词的前缀。以此类推,所有的词都被处理了。3.消歧和结果输出通过以上步骤,有时会生成很多分词结果集。例如,程序员爱编程会分为五个结果:程序员、程序、会员、爱和编程。这也是ik的ik_max_word模式的输出。但在某些场景下,开发者只想要programmer、love、programming三个分词结果。这种情况下就需要用到ik的ik_smart模式,也就是去歧义。ik使用IKArbitrator来消歧,主要是使用组合遍历。从上一阶段的分词结果中取出不相交的分词集。所谓相交,就是它们在文中的位置是否重合。比如programmer、program、member的三个分词结果是相交的,而love和programming是不相交的。所以在处理分歧时,程序员、程序、员工会被视为一个集合,爱情是一个集合,代码是一个集合,分别处理,根据优先级最高的分词结果集合规则将从集合中选择。具体规则如下:有效文字长度越长者优先;字数越少越好;路径跨度越大越好;越靠后的位置越靠后,因为根据统计结论,反向分割的概率比正向分割高;字长越平均越好;元位置的权重优先。根据以上规则,在第一组中,Programmer明显比Programmer和Member更符合规则,所以消歧的结果是输出Programmer,而不是Programmer和Member。最后,对于输入的词,有些位置可能不在输出结果中,所以会直接以单个词的形式作为词输出(详见AnalyzeContext的outputToResult函数)。比如程序员是专业的,单词不会被切分出来,但是最终输出结果的时候应该作为一个单词输出。后记ElasticSearch与ik的结合是目前主流的中文搜索技术方案。了解其搜索和分词的基本流程和原理,有助于开发者更快地构建中文搜索功能,或者根据自己的需求自定义搜索分词策略。
