IK分词器原理

2020-07-02  本文已影响0人  zhenxianyimeng

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
上一篇下一篇

猜你喜欢

热点阅读