美文网首页
IK分词器原理

IK分词器原理

作者: zhenxianyimeng | 来源:发表于2020-07-02 20:01 被阅读0次

    1.前言

    在使用ES进行中文搜索时,分词的效果直接影响搜索的结果。对于没有能力自研分词,或者一般的使用场景,都会使用ik分词器作为分词插件。ik分词器的基本使用可以参考: Elasticsearch中ik分词器的使用。ik分词器的主要逻辑包括三部分:
    1)词典:词典的好坏直接影响分词结果的好坏,本文将介绍词典的构建和存储结构
    2)词的匹配:有了词典之后,就可以对输入的字符串逐字句和词典进行匹配
    3)消除歧义:通过词典匹配出来的切分方式会有多种,消除歧义就是从中寻找最合理的一种方式

    2前期准备

    在研究ik的原理之前,需要把ik的源码clone到本地,https://github.com/medcl/elasticsearch-analysis-ik
    clone到本地之后checkout 6.8.1版本,然后写一个方法调用ik分词器,这样就可以进行debug了。

    public class TestIK {
    
        public static void main(String[] args) throws IOException {
            testIkSegment();
        }
    
        public static void testIkSegment() throws IOException {
            String t = "得饶人处且饶人";
            Settings settings =  Settings.builder()
                    .put("use_smart", false)
                    .put("enable_lowercase", false)
                    .put("enable_remote_dict", false)
                    .build();
            Configuration configuration=new Configuration(null,settings).setUseSmart(false);
            IKSegmenter segmenter = new IKSegmenter(new StringReader(t), configuration);
            Lexeme next;
            while ((next = segmenter.next())!=null){
                System.out.println(next.getLexemeText());
            }
        }
    }
    

    启动的时候会出现如下错误:


    启动错误

    出现错误的原因就是没有指定词典的路径,因为构造Environment对象相对比较复杂,因此直接采用文件路径的方法代替,修改org.wltea.analyzer.dic.Dictionary构造器中代码,让代码能快速运行

    //     this.conf_dir = cfg.getEnvironment().configFile().resolve(AnalysisIkPlugin.PLUGIN_NAME);
          this.conf_dir = Paths.get("/Users/zjb/code/mine/commit/ik/elasticsearch-analysis-ik/config");
    

    3.词典

    从上面的准备工作能看出来,词典的配置其实是非常重要的,ik为我们提供了三种常见的词典分别是:
    1)main.dic:主词典,一些常用词
    2)quantifier.dic:常用的量词
    3)stopword.dic:停用词
    这些词典用户都可以自行扩展,只需要配置IKAnalyzer.cfg.xml文件即可
    词典是怎么加载的呢,常用的字典树 tire tree 是一种结构简单的树,也叫做前缀树,结构如下图所示


    前缀树

    从根节点触发,把一个词挂在这颗树上。其中词的开头,都是根节点的孩子节点,每个字都是前一个字的孩子节点,红色的节点表示词的结尾。上图中间表示,前门是一个词,门是结尾;前门巨虎也是一个词,虎结尾。
    由于main.dic的词非常多,而且用户又可以自定义扩展,这样会导致这个树会非常庞大,如果存粹用map存储,会比较浪费空间,因此ik采用了一种折中的方式。结构参考DictSegment.java代码:

    class DictSegment implements Comparable<DictSegment>{
       
       //公用字典表,存储汉字
       private static final Map<Character , Character> charMap = new ConcurrentHashMap<Character , Character>(16 , 0.95f);
       //数组大小上限
       private static final int ARRAY_LENGTH_LIMIT = 3;
    
       
       //Map存储结构
       private Map<Character , DictSegment> childrenMap;
       //数组方式存储结构
       private DictSegment[] childrenArray;
       
       
       //当前节点上存储的字符
       private Character nodeChar;
       //当前节点存储的Segment数目
       //storeSize <=ARRAY_LENGTH_LIMIT ,使用数组存储, storeSize >ARRAY_LENGTH_LIMIT ,则使用Map存储
       private int storeSize = 0;
       //当前DictSegment状态 ,默认 0 , 1表示从根节点到当前节点的路径表示一个词
       private int nodeState = 0; 
    }
    

    ik的代码注释还算比较全,而且都是中文的,也比较好理解。主要的思想,就是根据子节点的数量对存储结构进行调整,如果子节点的数量小于等于3,则采用数组存储,如果子节点的数量大于3,采用map存储。其中的nodeState就是用来标记当前节点(即当前的字符是否是词的结尾)。

    有了这个结构之后,我们看一下词是怎么从文件加载到内存的,词典的加载是在Configuration构造器内进行的,最终会调用DictSegment#fillSegment方法:

    private synchronized void fillSegment(char[] charArray , int begin , int length , int enabled){
       //获取字典表中的汉字对象
       Character beginChar = Character.valueOf(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;
          }
       }
    
    }
    

    这就是个递归把词塞入词典的过程,通过lookforSegment方法查找当前map是存在字,如果没有的话,就创建一个DictSegment。然后判断当前处理的这个词是否处理完了,没处理完就递归处理,处理完了就标记这个字是词的结尾。

    4.切词

    有了词典和输入语句之后,就可以进行切词了。ik的切词方式主要有两种,一种为smart模式,一种为ik_max_word即非smart模式。以宝剑锋从磨砺出为例:

    非smart模式分词结果:宝剑锋从磨砺出、宝剑锋、宝剑、从、锋、从、磨砺、出
    smart模式下的分词结果:宝剑锋从磨砺出

    从非smart的分词结果中可以看出,对于一个语句可以有很多种切分方式,非smart就是把没种可能的分词结果都给出来了。而smart模式,就是需要在这几种分词模式中,寻找一种认为最合理的分词方式。

    从处理角度说,设置了smart模式,就是在进行词切分后,在进行一次分词的选择,即通常说的消除歧义。

    ik默认实现了三种分词器,分别为CJKSegmenter(中文-日韩文子分词器)、CN_QuantifierSegmenter(中文数量词分词器)、LetterSegmenter(英文字符及阿拉伯数字子分词器)。

    分词的主要逻辑如下所示,采用类似懒加载的形式,第一次调用 segmenter.next()拿分词结果的时候,才进行分词。

    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, configuration.isUseSmart());
       //将分词结果输出到结果集,并处理未切分的单个CJK字符
       context.outputToResult();
       //记录本次分词的缓冲区位移
       context.markBufferOffset();          
    }
    

    主要的分词逻辑在do循环里,其中segmenters为三个分词器。context内存储当前输入语句,每次循环指针移动一个,即每次去一个字符,然后遍历三个分词器,用每个分词器对当前这个词进行处理。

    首先是LetterSegmenter,英文分词器比较简单,就是把连续的英文字符,或者连续的数据进行分词。

    然后是CN_QuantifierSegmenter,量词分词器。主要是判断当前的字符是否是数词和量词,会把连起来的数词和量词分成一个词。

    最主要的是CJKSegmenter,该分词器就是基于词典匹配的。在介绍主要逻辑之前,需要介绍一个类Lexeme,该类表示一个分词结果,即一个词元

    public class Lexeme implements Comparable<Lexeme>{
       //lexemeType常量
       //未知
       public static final int TYPE_UNKNOWN = 0;
       //英文
       public static final int TYPE_ENGLISH = 1;
       //数字
       public static final int TYPE_ARABIC = 2;
       //英文数字混合
       public static final int TYPE_LETTER = 3;
       //中文词元
       public static final int TYPE_CNWORD = 4;
       //中文单字
       public static final int TYPE_CNCHAR = 64;
       //日韩文字
       public static final int TYPE_OTHER_CJK = 8;
       //中文数词
       public static final int TYPE_CNUM = 16;
       //中文量词
       public static final int TYPE_COUNT = 32;
       //中文数量词
       public static final int TYPE_CQUAN = 48;
       
       //词元的起始位移
       private int offset;
        //词元的相对起始位置
        private int begin;
        //词元的长度
        private int length;
        //词元文本
        private String lexemeText;
        //词元类型
        private int lexemeType;
    }
    

    该类在切词过程中比较主要的域是begin和length,begin表示该词元在输入语句的起始位置,length表示该词元的长度。

    分词的主要逻辑在CJKSegmenter#analyze方法内

    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);
       }
    }
    

    我们先看下半部分代码,大概思路就是取当前这次循环中的字符,然后判断是否match。

    1)如果match表示:该字符能匹配到词典中的词尾(即3小节描述的红色点)则说明当前这个字符可以作为词的结尾,那么加上前面缓存的bufferoffset就能推出这个词首位未知,然后新建一个Lexeme,放入context内。
    2)如果为prefix,则表示当前这个字符不是词的结尾,但是是词的前缀,则放入tmpHits内,tmpHits表示之前的遍历过程中,可以作为词前缀的字符。
    3)根本不在词典中,则清空一下临时变量。

    有了timpHits,我们再来看上半部分代码:
    首先遍历timpHits内的所有字符。
    1)如果当前字符和tipHits内的prefix结合能够match的,则新建词元存入context。
    2)如果加上当前字符,反而不是prefix了,则从timpsHits中移除

    经过一系列的处理,最终会在contenxt内得到一个orgLexemes的QuickSortSet,Lexemes本身是实现了Comparable接口的,并且按照begin的值从小到达排序,如果begin一样,则按照lenght从大到小排序。也就是位置靠前,并且长度较长的词元会排到前排。

    以宝剑锋从磨砺出为例,最终得到四个词元 0-7,0-3,0-2,4-6。即宝剑锋从磨砺出、宝剑锋、宝剑、磨砺这四个词元。到这里还只是一种切分方式,和最后的结果:宝剑锋从磨砺出、宝剑锋、宝剑、从、锋、从、磨砺、出。还有些差距。

    5.输出结果

    从切词到最后的输出,中间其实还有一步,就是其一处理,当设置useSmart为true的时候,会对上面的四个词元进行去除歧义,最后只剩一条词元0-7。这部分逻辑后面再讲,假设没有设置useSmart为true的话,现在还是四条词元,然后准备输出结果,看这中间做了什么事。主要逻辑在AnalyzeContext#outputToResult方法中。

    void outputToResult(){
       int index = 0;
       for( ; index <= this.cursor ;){
          //跳过非CJK字符
          if(CharacterUtil.CHAR_USELESS == this.charTypes[index]){
             index++;
             continue;
          }
          //从pathMap找出对应index位置的LexemePath
          LexemePath path = this.pathMap.get(index);
          if(path != null){
             //输出LexemePath中的lexeme到results集合
             Lexeme l = path.pollFirst();
             while(l != null){
                this.results.add(l);
                //字典中无单字,但是词元冲突了,切分出相交词元的前一个词元中的单字
                int innerIndex = index + 1;
                for (; innerIndex < index + l.getLength(); innerIndex++) {
                   Lexeme innerL = path.peekFirst();
                   if (innerL != null && innerIndex == innerL.getBegin()) {
                      this.outputSingleCJK(innerIndex - 1);
                   }
                }
                
                //将index移至lexeme后
                index = l.getBegin() + l.getLength();              
                l = path.pollFirst();
                if(l != null){
                   //输出path内部,词元间遗漏的单字
                   for(;index < l.getBegin();index++){
                      this.outputSingleCJK(index);
                   }
                }
             }
          }else{//pathMap中找不到index对应的LexemePath
             //单字输出
             this.outputSingleCJK(index);
             index++;
          }
       }
       //清空当前的Map
       this.pathMap.clear();
    }
    

    该部分的主要逻辑,就是对于那些没有被分词分到的位置,用单字输出的方式输出词元。细心的读者应该已经发现,最后的结果:宝剑锋从磨砺出、宝剑锋、宝剑、从、锋、从、磨砺、出。有两个从字,并且这个输入语句里只有一个从。这个其实是特定的ik版本才有的bug,6.8.1很不幸,存在这个bug。关于这个bug,后面写文章再具体分析。主要有 //字典中无单字,但是词元冲突了,切分出相交词元的前一个词元中的单字 这段注释下大代码引起,也是一个ik的pr,不过最新版本已经备注掉了。

    6.消除歧义

    我们现在再回过头来看,设置useSmart会发生什么。主要逻辑在IKArbitrator#process 方法中

    void process(AnalyzeContext context , boolean useSmart){
       QuickSortSet orgLexemes = context.getOrgLexemes();
       Lexeme orgLexeme = orgLexemes.pollFirst();
       
       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();
       }
       
       
       //处理最后的path
       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);
       }
    }
    

    当useSmart为true时,进行歧义处理,如果为false,则不处理,直接输出。
    将词元转换成词元路径 LexemePath,LexemePath实现了Comparable接口,LexemePath内部的词元不想交,并且根据排序规则进行排序,规则如下

    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;
    }
    

    根据这个规则,对词元链路进行排序,选择排序第一的词元链路,即最后消除歧义的那个分词方式。

    7.总结

    IK分词器虽然使用上比较简单,但是了解它内部的原理的思想很重要,能够帮助分析和定位问题。

    更多精彩内容,请关注公众号


    公众号二维码.jpg

    相关文章

      网友评论

          本文标题:IK分词器原理

          本文链接:https://www.haomeiwen.com/subject/kfcdqktx.html