Ik分词流程分为两部分:
注意:图中省略了对AnalyzeContext字符数组的再填充,实际流程于此稍有差别
分词流程是一个迭代的过程,这里的图表述可能不够清楚,下面一步一步介绍:
这里按照在【分词流程】中描述的步骤一一描述。
这个类要提前介绍下,一个Analyzer只有一个AnalyzeContext,对应一个输入流。换句话说Analyzer不是线程安全的,在处理下一个输入流之前必须显式地调用rest()方法。
AnalyzeContext职责:
AnalyzeContext内的变量:
class AnalyzeContext {
//默认缓冲区大小
private static final int BUFF_SIZE = 4096;
//缓冲区耗尽的临界值
private static final int BUFF_EXHAUST_CRITICAL = 100;
//字符窜读取缓冲
private char[] segmentBuff;
//字符类型数组
private int[] charTypes;
//记录Reader内已分析的字串总长度
//在分多段分析词元时,该变量累计当前的segmentBuff相对于reader起始位置的位移
private int buffOffset;
//当前缓冲区位置指针
private int cursor;
//最近一次读入的,可处理的字串长度
private int available;
//子分词器锁
//该集合非空,说明有子分词器在占用segmentBuff
private Set<String> buffLocker;
//原始分词结果集合,未经歧义处理
private QuickSortSet orgLexemes;
//LexemePath位置索引表
private Map<Integer , LexemePath> pathMap;
//最终分词结果集
private LinkedList<Lexeme> results;
//分词器配置项
private Configuration cfg;
...
}
取字符是通过移动AnalyzeContext的指针实现的,会涉及到两个问题:字符的归一化和何时补充AnalyzeContext中的charBuffer。
AnalyzeContext提供moveCursor()方法向下移动一位,移动的同时会归一化当前字符、判断当前字符类型(无效、量词、字母、阿拉伯数字、汉子),具体实现可以参看CharacterUtil的代码。
首先必须明确补充charBuffer带来的副作用,补充charBuffer就是把当前buffer中未处理的字符移到数组前面并从字符流中读取新的数据补充后面的空闲位置。该动作将引发AnalyzeContext中offset、cursor位置变化,已经识别的词元(只保存了begin、offset和length)将丢失内容;正在识别的字符(当前为前缀)将被中断。
处理方式如下:
针对已经识别词元:
调用addLexeme()方法,输出到AnalyzeContext的orgSegments中。
针对正在识别词元:
阻止补充charBuffer的动作,也就是AnalyzeContext的协同功能:
识别是指查询当前字符串是前缀、完整词,或者都不是,在分词过程中AnalyzeContext每向前移动一个字符,都要识别当前字符。
识别的功能交由分词器实现,IKAnalyzer中包括三个分词器:
以下是ISegmenter.next()的代码(context.fillBuffer() 补充buffer;segmenter.analyze() 子分词器分词;context.moveCursor() 指针移动;context.needRefillBuffer() 判断可否补充<到达临界值且没有锁定>):
/**
* 分词,获取下一个词元
* @return Lexeme 词元对象
* @throws IOException
*/
public synchronized Lexeme next()throws IOException{
Lexeme l = null;
while((l = context.getNextLexeme()) == null ){
/*
* 从reader中读取数据,填充buffer
* 如果reader是分次读入buffer的,那么buffer要 进行移位处理
* 移位处理上次读入的但未处理的数据
*/
int available = context.fillBuffer(this.input);
if(available <= 0){
//reader已经读完
context.reset();
return null;
}else{
//初始化指针
context.initCursor();
do{
//遍历子分词器
for(ISegmenter segmenter : segmenters){
segmenter.analyze(context);
}
//字符缓冲区接近读完,需要读入新的字符
if(context.needRefillBuffer()){
break;
}
//向前移动指针
}while(context.moveCursor());
//重置子分词器,为下轮循环进行初始化
for(ISegmenter segmenter : segmenters){
segmenter.reset();
}
}
//对分词进行歧义处理
this.arbitrator.process(context, this.cfg.useSmart());
//将分词结果输出到结果集,并处理未切分的单个CJK字符
context.outputToResult();
//记录本次分词的缓冲区位移
context.markBufferOffset();
}
return l;
}
前两个分词器相对简单,这里只重点介绍第三个。
先来看一个复杂的分词用例“中华人民共和国”,这个词在分词过程中会产生多个完整词和前缀:
结论:一段字符串既可以是前缀又可以是完整词;任何一个字符都可能是新的前缀。
CJKSegmenter正是根据这个“结论”来的:
IKAnalyzer的分词依赖词库,简单讲CJKSegment的识别就是拿着拼好的字符串去词库里查询是否存在这个词,或者存在以这段字符开始的词,关键在于字典的组织。
IKAnalyzer中使用多叉树储存词库,识别逻辑共涉及三个类:
上图展现了一个词库示意图。
当字符流读到结尾时,分词结束。此时清理所有为前缀而不能构成完整词的数据,并进入歧义消除阶段。
这部分主要有三个类负责:QuickSortSet、LexemePath、Arbitrator,其次还涉及到Lexeme内的compareTo方法。
QuickSortSet用于AnalyzeContext中存储原始词元。
private QuickSortSet orgLexemes;
其中的数据有两个特点:
QuickSortSet中词元存储顺序由Lexeme的compareTo方法决定:
public int compareTo(Lexeme other) {
//起始位置优先
if(this.begin < other.getBegin()){
return -1;
}else if(this.begin == other.getBegin()){
//词元长度优先
if(this.length > other.getLength()){
return -1;
}else if(this.length == other.getLength()){
return 0;
}else {//this.length < other.getLength()
return 1;
}
}else{//this.begin > other.getBegin()
return 1;
}
}
所以在QuickSortSet中,元素的排布情况包括以下几种。
QuickSortSet的介绍暂时到此,在后面会继续涉及这部分。
Arbitrator处理歧义分两步进行:划分冲突范围和选择最优解。
可见在QuickSortSet中除了第四种情况是天然不存在歧义的,其它三种都需要处理。那么歧义处理的范围应该如何划定——范围越小,所消费的资源越少,处理难度越小。
所以在歧义处理过程中第一步要进行的就是划分歧义处理范围,IK的做法是按照QuickSortSet中的顺序遍历原始词元,每当找到一个与之前所有词元不相交的词元时此位置之前便是一个最小歧义处理范围。
歧义处理的方法是在最小歧义范围内对所有词元进行排列组合,选取不相交的组合;再根据一定的判断标准判定出最优的组合即为最终解。
简言之Arbitrator干的事情就是划分,排列,再划分,在排列…… 如何划分和排列需要使用LexemePath。
LexemePath继承自QuickSortSet,顾名思义它是一组词元(Lexeme)的路径。
先介绍LexemePath中关键的几个变量:
再来关注该类中三个方法:
boolean addCrossLexeme(),只添加交叉词元,如果该词元与路径中其它词元无交叉,返回false。
boolean addNotCrossLexeme(),只添加非交叉词元,如果该词元与路径中任意词元交叉,返回false。
int compareTo(),比较。这个要直接看源码:
public int compareTo(LexemePath o) {
//比较有效文本长度
if(this.payloadLength > o.payloadLength){
return -1;
}else if(this.payloadLength < o.payloadLength){
return 1;
}else{
//比较词元个数,越少越好
if(this.size() < o.size()){
return -1;
}else if (this.size() > o.size()){
return 1;
}else{
//路径跨度越大越好
if(this.getPathLength() > o.getPathLength()){
return -1;
}else if(this.getPathLength() < o.getPathLength()){
return 1;
}else {
//根据统计学结论,逆向切分概率高于正向切分,因此位置越靠后的优先
if(this.pathEnd > o.pathEnd){
return -1;
}else if(pathEnd < o.pathEnd){
return 1;
}else{
//词长越平均越好
if(this.getXWeight() > o.getXWeight()){
return -1;
}else if(this.getXWeight() < o.getXWeight()){
return 1;
}else {
//词元位置权重比较
if(this.getPWeight() > o.getPWeight()){
return -1;
}else if(this.getPWeight() < o.getPWeight()){
return 1;
}
}
}
}
}
}
return 0;
}
这就是选择最优解的选取原则: 有效文档长度越长越好 > 词元个数越少越好 > 路径跨越长度越长越好 > 位置越靠后越好 > 词长越平均越好(各词元长度乘机) > 词元位置权重(词元位置pos * 词元长度的累加和)越大越好。
最后,结合代码再次说明Arbitrator的工作方式:
LexemePath crossPath = new LexemePath();
while(orgLexeme != null){
if(!crossPath.addCrossLexeme(orgLexeme)){
//找到与crossPath不相交的下一个crossPath
if(crossPath.size() == 1 || !useSmart){
//crossPath没有歧义 或者 不做歧义处理
//直接输出当前crossPath
context.addLexemePath(crossPath);
}else{
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
//把orgLexeme加入新的crossPath中
crossPath = new LexemePath();
crossPath.addCrossLexeme(orgLexeme);
}
orgLexeme = orgLexemes.pollFirst();
}
private LexemePath judge(QuickSortSet.Cell lexemeCell , int fullTextLength){
//候选路径集合
TreeSet<LexemePath> pathOptions = new TreeSet<LexemePath>();
//候选结果路径
LexemePath option = new LexemePath();
//对crossPath进行一次遍历,同时返回本次遍历中有冲突的Lexeme栈
Stack<QuickSortSet.Cell> lexemeStack = this.forwardPath(lexemeCell , option);
//当前词元链并非最理想的,加入候选路径集合
pathOptions.add(option.copy());
//存在歧义词,处理
QuickSortSet.Cell c = null;
while(!lexemeStack.isEmpty()){
c = lexemeStack.pop();
//回滚词元链
this.backPath(c.getLexeme() , option);
//从歧义词位置开始,递归,生成可选方案
this.forwardPath(c , option);
pathOptions.add(option.copy());
}
//返回集合中的最优方案
return pathOptions.first();
}
judge()的代码分两部分看,第一部分while()循环之前。
/**
* 向前遍历,添加词元,构造一个无歧义词元组合
* @param LexemePath path
* @return
*/
private Stack<QuickSortSet.Cell> forwardPath(QuickSortSet.Cell lexemeCell , LexemePath option){
//发生冲突的Lexeme栈
Stack<QuickSortSet.Cell> conflictStack = new Stack<QuickSortSet.Cell>();
QuickSortSet.Cell c = lexemeCell;
//迭代遍历Lexeme链表
while(c != null && c.getLexeme() != null){
if(!option.addNotCrossLexeme(c.getLexeme())){
//词元交叉,添加失败则加入lexemeStack栈
conflictStack.push(c);
}
c = c.getNext();
}
return conflictStack;
}
调用forward(),构造了首个可能解并把它加入到有序TreeSet pathOptions中。与此同时,产生了与该解冲突的所有词元的堆栈(顺序是QuickSortSet的逆向)。
第二部分遍历冲突词元,试图从中间找出更多可能解。
private void backPath(Lexeme l , LexemePath option){
while(option.checkCross(l)){
option.removeTail();
}
}
参考如下图片,IKAnalyzer并没有进行全量的排列组合,这种处理方式可以解决【蓝色】表示的冲突问题,但是在【红色】表示的情境下,IK会错过最优解。其作者说明的IK正确率是95%,这种查找最优解的方式也许是性能和正确率之间权衡的结果?