概述
本系列主要从源码的角度介绍es-IK插件,本文主要从源码层面介绍ik分词的主线逻辑暂不涉及中文分词的逻辑。
IKSegmenter
IKTokenizer中实现分词功能的类,从这个类开始所有主要概念都是IK自身的了与es关系已不是太大。直接放源码:
1 | public final class IKSegmenter { |
从代码中可以看出来在ik整体的分词逻辑中有以下几个重要概念:
Lexeme,1个词元,是从输入中分出的1个词, 词元包括以下重要属性:
- offset,词元起始位移,是在某个segmentBuff中的起始位置
- begin,词元的相对起始位置( 词元在文本中的起始位置=offset + begin)
- length,词元长度
- lexemeText,词元文本
- lexemeType,词元类型
注意词元重写了equals,hashCode,compareTo方法,其中
compareTo规定,首字母位置越小且长度越长的词元越小
equals规定起始位置偏移、起始位置、终止位置相同即为相同词元
hashCode的计算方式与绝对的起止位置以及长度有关
ISegmenter,ik子分词器,分词是由多个子分词器共同完成,Ik子分词器有:
- LetterSegmenter,处理字母的自分词器
- CN_QuantifierSegmenter,处理中文数量词的子分词器
- CJKSegmenter,处理中文的子分词器
IKArbitrator,分词歧义裁决器,感觉是从多个分词结果中裁决出实际分词结果
AnalyzeContext,上下文,分一个句子或一篇文章时,普遍需要多次分词,而上下文是保存中间状态的,它是驱动分一个句子正常分词的关键。
从上面执行过程中可以看出每次分词的核心逻辑是遍历所以子分词器,然后再调用裁决器,接着就可以从上下文中获取下一个词元了。其中不管是子分词器还是裁决器,处理的对象都是上下文AnalyzeContext。每次遍历完一次自分词器后都会操作AnalyzeContext挪动指针。下面我们来看看这个AnalyzeContext
遍历一次子分词器主要操作的代码以及一次分词结束后执行的操作。
AnalyzeContext
从IKSegmenter的代码中我们可以看出,获取1个词元,上下文进行的主要操作是:
- context.fillBuffer(this.input)// 将contextBuffer中的数据填充满
- context.initCursor(); // 初始化指针
- 各个子插件对AnalyzeContext进行处理
- context.moveCursor() // 移动指针
其中2、3步循环执行知道当前上下文的buff中没有待处理字符串。下面我们 来看看相关源码
1 | class AnalyzeContext { |
首先是上线文中几个重要的变量:
- segmentBuff:char[],保存从es-input中读出的待处理的字符数据
- cursor:int , 遍历到当前segmentBuff第几位了
- available: int ,当前segmentBuff可处理的字符串长度
- charTypes:int[],与segmentBuff对应,记录每个字符的类型
- buffOfset:int , 对于要分词的文本来说,本次分词后处理到的位置
- orgLexemes: QuickSortSet,中间状态的分词结果,没有经过裁决处理,由子分词器生成,QuickSortSet是IK分词器专用的Lexem快速排序集合
- pathMap: Map<Integer, LexemePath>,LexemePath位置索引表,由起义裁决器使用orgLexemes生成
- results:LinkedList
,最终分出的词元的list,由outputToResult使用pathMap生成
几个方法:
- int fillBuffer(Reader reader) ,填充segmentBuff,保证每次分词时是未处理的4096个字符。
- initCursor和moveCursor类似,都是将下一个字符格式化(全半角、大小写)并判断字符类型,不同的是moveCursor如果发现无后续字符则会返回false。
- outputToResult ,主要是再次遍历本次处理的字符,将分词结果词元放到返回结果results中,主要处理的的CJK字符。源码后续分析
- markBufferOffset,更新buffOffset即处理到当前read中的位置。
知道了AnalyzeContext遍历的方式,下面我们看看再遍历过程中,主要执行的分词操作是怎样的,下面以简单的LetterSegmenter
字符串器为切入点,看看ik是如何处理英文字符和阿拉伯数字的。
LetterSegmenter
在分词遍历中,核心执行的逻辑是ISegmenter.analyze(AnalyzeContext)
,这里我们看看对于字符和数字ik插件是怎么做的。上源码:
1 | class LetterSegmenter implements ISegmenter { |
可以看出在字母数字分词器中,主要功能就是碰到连续的字母、数字或(字母和数字),就生成词元,放到AnalyzeContext的orgLexemes,原始分词结果的集合中,该集合还需要经过裁决器处理。这些词元中可能包含重复的词元,比如windos2000
会派生出3个词元,window
,2000
,windos2000
。 如果待分词文本长度大于4096,那么可能出现一个单词背切成2个词元的情况。
IKArbitrator
在介绍歧义裁决器前我们先再说下存储自分词器生成的原始词元的QuickSortSet,它是个双向链表,各个词元按照在原词中的位置排列,起始位置相同的词,长度越长越靠前。
再说下LexemePath,它继承与QuickSortSet,用来保存一组词元,这些词元是有重叠部分,也就是要判断歧义的。它又重写了compareTo比较算法,该算法在歧义裁决时有重要作用,该比较算法依次依照以下几条做判断(前一条相同才走下一条):
- 词元链的有效字符长度越长越靠前
- 词元越少越靠前
- 路径长度越长越靠前,大部分情况与1相同,但是1是去掉比如“的,了,着,把,个”等这种词的长度
- 结尾下标越大越靠前(统计学结论,逆向切分概率高于正向切分,因此位置越靠后的优先)
- 各个词元长度越平均越靠前
- 词元位置权重越大越靠前
下面我们看看当segmentBuff处理完后,分词歧义裁决器IKArbitrator
做了什么工作,源码如下:
1 | class IKArbitrator { |
IKArbitrator的核心逻辑也比较明显了,就是从orgLexemes找出有相互重叠的词元组成一个LexemePath,这个LexemePath通过歧义裁决器生成1个LexemePath,这个中的词元是相互不重合的。举个例子,比如”中华人民共和国中央人民政府今天成立”这句话的orgLexemes有18个,按排列顺序分别是:中华人民共和国; 中华人民; 中华; 华人; 人民共和国; 人民; 共和国; 共和; 国中; 中央人民政府; 中央; 人民政府; 人民; 民政; 政府; 今天; 天成; 成立; 其中第一个相互重叠的词元组LexemePath是:中华人民共和国; 中华人民; 中华; 华人; 人民共和国; 人民; 共和国; 共和; 国中; 中央人民政府; 中央; 人民政府; 人民; 民政; 政府; 经过歧义裁决器变为的LexemePath仅有2个词元分别是”中华人民共和国”和”中华人民共和国”。至于裁决的方法主要在judge()
方法中,大家可以自行debug,不过说实话生成中,一般会希望分出来的词越多越好,这样查询命中率会上升。
后续
新生产的LexemePath会放在pathMap中,紧接着在outputToResult
会将这次词转移到最终词元的results中,当再次调用IKSegmenter的next获取词元是就从results中吐出一个词,并为他配置上实际的词元内容LexemeText
。
注意之前Lexeme对象是没有词元文本LexemeText的,只有在要吐出一个词元时才会设置LexemeText。这点可以从AnalyzeContext::getNextLexeme()中看出来。并且如果是短的文本(小于4096个char),其实第一次调用IKSegmenter::next()时就已经全部分好并存储在上下文的results中了,后续只是装上文本返回return而已。
特别注意其实IK分词器是有个小坑的,就是m每4096个字符分词器会强行分词,这导致文本每4096个字符处分词会非常非常奇怪。
总结
IKSegmenter的核心分词逻辑就是变量词的每一个字符,遍历时所有子分词器都会独自做分词逻辑,最后如何设置开启了智能分词的逻辑那么会执行一个歧义决断器,将互有重叠的词元进行重新分词,从而保证分出的词相互不重叠,但实际生产过程中不建议开启这种方式。下一篇我们将介绍中文子分词器——CJKSegmenter,他的逻辑直接影响中文分词的效率与效果。