首先摘抄一段关于IK的特性介绍:
采用了特有的“正向迭代最细粒度切分算法”,具有60万字/秒的高速处理能力。
采用了多子处理器分析模式,支持:英文字母(IP地址、Email、URL)、数字(日期,常用中文数量词,罗马数字,科学计数法),中文词汇(姓名、地名处理)等分词处理。
优化的词典存储,更小的内存占用。支持用户词典扩展定义。
针对Lucene全文检索优化的查询分析器IKQueryParser,采用歧义分析算法优化查询关键字的搜索排列组合,能极大的提高Lucene检索的命中率。
Part1:词典
从上述内容可知,IK是一个基于词典的分词器,首先我们需要了解IK包含哪些词典?如果加载词典?
IK包含哪些词典?
主词典
停用词词典
量词词典
如何加载词典?
IK的词典管理类为Dictionary,单例模式。主要将以文件形式(一行一词)的词典加载到内存。
以上每一类型的词典都是一个DictSegment对象,DictSegment可以理解成树形结构,每一个节点又是一个DictSegment对象。
节点的子节点采用数组(DictSegment[])或map(Map(Character, DictSegment))存储,选用标准根据子节点的数量而定。
如果子节点的数量小于等于ARRAY_LENGTH_LIMIT,采用数组存储;
如果子节点的数量大于ARRAY_LENGTH_LIMIT,采用Map存储。
ARRAY_LENGTH_LIMIT默认为3。
这么做的好处是:
子节点多的节点在向下匹配时(find过程),用Map可以保证匹配效率。
子节点不多的节点在向下匹配时,在保证效率的前提下,用数组节约存储空间。
数组匹配实现如下(二分查找):
int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment);
其中加载词典的过程如下:
1)加载词典文件
2)遍历词典文件每一行内容(一行一词),将内容进行初处理交给DictSegment进行填充。
初处理:theWord.trim().toLowerCase().toCharArray()
3)DictSegment填充过程
private synchronized void fillSegment(char[] charArray, int begin, int length, int enabled) { //获取字典表中的汉字对象 Character beginChar = new Character(charArray[begin]); Character keyChar = charMap.get(beginChar); //字典中没有该字,则将其添加入字典 if (keyChar == null) { charMap.put(beginChar, beginChar); keyChar = beginChar; } //搜索当前节点的存储,查询对应keyChar的keyChar,如果没有则创建 DictSegment ds = lookforSegment(keyChar, enabled); if (ds != null) { //处理keyChar对应的segment if (length > 1) { //词元还没有完全加入词典树 ds.fillSegment(charArray, begin + 1, length - 1, enabled); } else if (length == 1) { //已经是词元的最后一个char,设置当前节点状态为enabled, //enabled=1表明一个完整的词,enabled=0表示从词典中屏蔽当前词 ds.nodeState = enabled; } } }
/** * 查找本节点下对应的keyChar的segment * * @param keyChar * @param create =1如果没有找到,则创建新的segment ; =0如果没有找到,不创建,返回null * @return */ private DictSegment lookforSegment(Character keyChar, int create) { DictSegment ds = null; if (this.storeSize <= ARRAY_LENGTH_LIMIT) { //获取数组容器,如果数组未创建则创建数组 DictSegment[] segmentArray = getChildrenArray(); //搜寻数组 DictSegment keySegment = new DictSegment(keyChar); int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment); if (position >= 0) { ds = segmentArray[position]; } //遍历数组后没有找到对应的segment if (ds == null && create == 1) { ds = keySegment; if (this.storeSize < ARRAY_LENGTH_LIMIT) { //数组容量未满,使用数组存储 segmentArray[this.storeSize] = ds; //segment数目+1 this.storeSize++; Arrays.sort(segmentArray, 0, this.storeSize); } else { //数组容量已满,切换Map存储 //获取Map容器,如果Map未创建,则创建Map Map<Character, DictSegment> segmentMap = getChildrenMap(); //将数组中的segment迁移到Map中 migrate(segmentArray, segmentMap); //存储新的segment segmentMap.put(keyChar, ds); //segment数目+1 , 必须在释放数组前执行storeSize++ , 确保极端情况下,不会取到空的数组 this.storeSize++; //释放当前的数组引用 this.childrenArray = null; } } } else { //获取Map容器,如果Map未创建,则创建Map Map<Character, DictSegment> segmentMap = getChildrenMap(); //搜索Map ds = segmentMap.get(keyChar); if (ds == null && create == 1) { //构造新的segment ds = new DictSegment(keyChar); segmentMap.put(keyChar, ds); //当前节点存储segment数目+1 this.storeSize++; } } return ds; }
(IK作者注释太全面了,不再做赘述!)
举个例子,例如“人民共和国”的存储结构如下图:
Part2:分词
IK的分词主类是IKSegmenter,他包括如下重要属性:
Read:待分词内容
Configuration:分词器配置,主要控制是否智能分词,非智能分词能细粒度输出所有可能的分词结果,智能分词能起到一定的消歧作用。
AnalyzerContext:分词器上下文,这是个难点。其中包含了字符串缓冲区、字符串类型数组、缓冲区位置指针、子分词器锁、原始分词结果集合等。
List<ISegment>:分词处理器列表,目前IK有三种类型的分词处理器,如下:
- CJKSegmenter:中文-日韩文子分词器
- CN_QuantifierSegmenter:中文数量词子分词器
- LetterSegmenter:英文字符及阿拉伯数字子分词器
IKArbitrator:分词歧义裁决器
在IKSegment中主要的方法是next(),如下:
/** * 分词,获取下一个词元 * @return Lexeme 词元对象 * @throws IOException */ public synchronized Lexeme next() throws IOException { if (this.context.hasNextResult()) { //存在尚未输出的分词结果 return this.context.getNextLexeme(); } else { /* * 从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.processUnkownCJKChar(); //记录本次分词的缓冲区位移 context.markBufferOffset(); //输出词元 if (this.context.hasNextResult()) { return this.context.getNextLexeme(); } return null; } }
这个过程主要做3件事:
1)将输入读入缓冲区(AnalyzerContext.fillBuffer());
2)移动缓冲区指针,同时对指针所指字符进行处理(进行字符规格化-全角转半角、大写转小写处理)以及类型判断(识别字符类型),将所指字符交由子分词器进行处理;
3)字符缓冲区接近读完时停止移动缓冲区指针,对当前分词器上下文(AnalyzerContext)中的原始分词结果进行歧义消除、处理一些残余字符,为下一次读入缓冲区做准备。最后输出词条。
在这个过程中,一些中间状态都记录在分词器上下文当中,可以理解IK作者当时的设计思路。
在上面next()方法当中,最主要的步骤是调用各个子分词器的analyze()方法,这里重点介绍CJKSegmenter,如下:
public void analyze(AnalyzeContext context) { if (CharacterUtil.CHAR_USELESS != context.getCurrentCharType()) { //优先处理tmpHits中的hit if (!this.tmpHits.isEmpty()) { //处理词段队列 Hit[] tmpArray = this.tmpHits.toArray(new Hit[this.tmpHits.size()]); for (Hit hit : tmpArray) { hit = Dictionary.getSingleton().matchWithHit(context.getSegmentBuff(), context.getCursor(), hit); if (hit.isMatch()) { //输出当前的词 Lexeme newLexeme = new Lexeme(context.getBufferOffset(), hit.getBegin(), context.getCursor() - hit.getBegin() + 1, Lexeme.TYPE_CNWORD); context.addLexeme(newLexeme); if (!hit.isPrefix()) {//不是词前缀,hit不需要继续匹配,移除 this.tmpHits.remove(hit); } } else if (hit.isUnmatch()) { //hit不是词,移除 this.tmpHits.remove(hit); } } } //********************************* //再对当前指针位置的字符进行单字匹配 Hit singleCharHit = Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(), context.getCursor(), 1); if (singleCharHit.isMatch()) {//首字成词 //输出当前的词 Lexeme newLexeme = new Lexeme(context.getBufferOffset(), context.getCursor(), 1, Lexeme.TYPE_CNWORD); context.addLexeme(newLexeme); //同时也是词前缀 if (singleCharHit.isPrefix()) { //前缀匹配则放入hit列表 this.tmpHits.add(singleCharHit); } } else if (singleCharHit.isPrefix()) {//首字为词前缀 //前缀匹配则放入hit列表 this.tmpHits.add(singleCharHit); } } else { //遇到CHAR_USELESS字符 //清空队列 this.tmpHits.clear(); } //判断缓冲区是否已经读完 if (context.isBufferConsumed()) { //清空队列 this.tmpHits.clear(); } //判断是否锁定缓冲区 if (this.tmpHits.size() == 0) { context.unlockBuffer(SEGMENTER_NAME); } else { context.lockBuffer(SEGMENTER_NAME); } }
这里需要注意tmpHits,在匹配的过程中属于前缀匹配的临时放入tmpHits,hit中记录词典匹配过程中当前匹配到的词典分支节点,可以继续匹配。
在遍历tmpHits的过程中,如果不是前缀词(全匹配)、或者不匹配则从tmpHits中移除。遇到遇到CHAR_USELESS字符、或者缓冲队列已经读完,则清空tmpHits。
是否匹配由DictSegment的match()方法决定。
(时时刻刻想想那棵字典树!)
什么时候上下文会收集临时词条呢?
1)首字成词的情况(如果首字还是前缀词,同时加入tmpHits,待后继处理)
2)在遍历tmpHits的过程中如果“全匹配”,也会加入临时词条。
下面再了解下match()方法,如下:
/** * 匹配词段 * @param charArray * @param begin * @param length * @param searchHit * @return Hit */ Hit match(char[] charArray, int begin, int length, Hit searchHit) { if (searchHit == null) { //如果hit为空,新建 searchHit = new Hit(); //设置hit的其实文本位置 searchHit.setBegin(begin); } else { //否则要将HIT状态重置 searchHit.setUnmatch(); } //设置hit的当前处理位置 searchHit.setEnd(begin); Character keyChar = new Character(charArray[begin]); DictSegment ds = null; //引用实例变量为本地变量,避免查询时遇到更新的同步问题 DictSegment[] segmentArray = this.childrenArray; Map<Character, DictSegment> segmentMap = this.childrenMap; //STEP1 在节点中查找keyChar对应的DictSegment if (segmentArray != null) { //在数组中查找 DictSegment keySegment = new DictSegment(keyChar); int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment); if (position >= 0) { ds = segmentArray[position]; } } else if (segmentMap != null) { //在map中查找 ds = segmentMap.get(keyChar); } //STEP2 找到DictSegment,判断词的匹配状态,是否继续递归,还是返回结果 if (ds != null) { if (length > 1) { //词未匹配完,继续往下搜索 return ds.match(charArray, begin + 1, length - 1, searchHit); } else if (length == 1) { //搜索最后一个char if (ds.nodeState == 1) { //添加HIT状态为完全匹配 searchHit.setMatch(); } if (ds.hasNextNode()) { //添加HIT状态为前缀匹配 searchHit.setPrefix(); //记录当前位置的DictSegment searchHit.setMatchedDictSegment(ds); } return searchHit; } } //STEP3 没有找到DictSegment, 将HIT设置为不匹配 return searchHit; }
注意hit几个状态的判断:
//Hit不匹配
private static final int UNMATCH = 0x00000000;
//Hit完全匹配
private static final int MATCH = 0x00000001;
//Hit前缀匹配
private static final int PREFIX = 0x00000010;
在进入match方法时,hit都会被重置为unMatch,然后根据Character获取子节点集合的节点。
如果节点为NULL,hit状态就是unMatch。
如果节点存在,且nodeState为1,hit状态就是match,
同时还要判断节点的子节点数量是否大于0,如果大于0,hit状态还是prefix。
(时时刻刻想想那棵字典树!)
对一次buffer处理完后,需要对上下文中的临时分词结果进行消歧处理(具体下文再分析)、词条输出。
在词条输出的过程中,需要判断每一个词条是否match停用词表,如果match则抛弃该词条。
Part3:消歧
稍等!