IKAnalyzer 源码走读

首先摘抄一段关于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作者注释太全面了,不再做赘述!)  

 举个例子,例如“人民共和国”的存储结构如下图:

IKAnalyzer 源码走读

 


 

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:消歧

 稍等!

 

你可能感兴趣的:(IKAnalyzer)