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作者注释太全面了,不再做赘述!)  

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

 


 

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

 稍等!

 

原文地址:https://www.cnblogs.com/huangfox/p/3282003.html