zoukankan      html  css  js  c++  java
  • 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:消歧

     稍等!

     

  • 相关阅读:
    02 基本介绍
    01 概述 网络分层
    04 可扩展
    Java 注解2
    03 高可用
    重拾安卓_00_资源帖
    JavaUtil_09_通用工具类-01_Hutool
    java支付宝开发-01-沙箱环境接入
    java支付宝开发-00-资源帖
    svn_学习_01_TortoiseSVN使用教程
  • 原文地址:https://www.cnblogs.com/huangfox/p/3282003.html
Copyright © 2011-2022 走看看