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

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

ik分词器 es ik分词器实现原理_存储结构

出现错误的原因就是没有指定词典的路径,因为构造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 是一种结构简单的树,也叫做前缀树,结构如下图所示

ik分词器 es ik分词器实现原理_存储结构_02

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

class DictSegment implements Comparable<DictSegment>{   //公用字典表,存储汉字   private static final Map charMap = new ConcurrentHashMap(16 , 0.95f);   //数组大小上限   private static final int ARRAY_LENGTH_LIMIT = 3;   //Map存储结构   private Map 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{   //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分词器虽然使用上比较简单,但是了解它内部的原理的思想很重要,能够帮助分析和定位问题。