IK中文分词器原理

Posted 亮亮-AC米兰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了IK中文分词器原理相关的知识,希望对你有一定的参考价值。

目录

1.字符类型分类

目前共有5种字符类型:

static int identifyCharType(charinput)
   if(input >= '0'&& input <= '9')
      return CHAR_ARABIC;//阿拉伯数字
     
   else if((input >= 'a'&& input <= 'z')
         || (input >= 'A' && input <='Z'))
      return CHAR_ENGLISH;//英文字符
     
   else
      Character.UnicodeBlock ub = Character.UnicodeBlock.of(input);
     
      if(ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS 
           
|| ub == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS 
           
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A)
         //目前已知的中文字符UTF-8集合
        
return CHAR_CHINESE;//中文字符
        
      else if(ub == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS//全角数字字符和日韩字符
            //
韩文字符集
           
|| ub == Character.UnicodeBlock.HANGUL_SYLLABLES
           
|| ub == Character.UnicodeBlock.HANGUL_JAMO
           
|| ub == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO
           
//日文字符集
           
|| ub == Character.UnicodeBlock.HIRAGANA//平假名
           
|| ub == Character.UnicodeBlock.KATAKANA//片假名
           
|| ub == Character.UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS)
         return CHAR_OTHER_CJK;//全角数字字符和日韩字符

        
     
  
    returnCHAR_USELESS;//其他的不做处理的字符

2.分词大致流程

public synchronized Lexeme next()throws IOException
   Lexeme l = null;
   while((l = context.getNextLexeme()) == null )
      /*
       * 从reader中读取数据,填充buffer
       * 如果reader是分次读入buffer的,那么buffer要  进行移位处理
       * 移位处理上次读入的但未处理的数据
       */
//从缓冲区读入数据,缓冲区默认大小为4k
      int available = context.fillBuffer(this.input);
      if(available <= 0)
         //reader已经读完
         context.reset();
         return null;
         
      else
         //初始化指针
//获取缓冲区的第一个字符的位置,值,类型 
         context.initCursor();
         do
                 //遍历子分词器
            /*
            * 处理英文字符及阿拉伯数字子分词器 LetterSegmenter
               * 处理中文数量词的子分词器 CN_QuantifierSegmenter
               * 处理中文词的子分词器 CJKSegmenter
            * */
//调用不同的子分词器进行分词处理
                 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();          
   
   return l;

 

主要分为三步:

1)  不断移动缓存区的指针,获取不同的字符的位置,值,类型

2)  调用不用的子分词器进行分词处理

3)  对最终的分词结果进行歧义处理

 

同时注意:IK分词器默认开启大写转小写的功能,即enable_lowercase=true

3.子分词器

IK分词器内置3种子分词器,分别是:

1)LetterSegmenter:处理英文字母和阿拉伯字母的子分词器

2)CN_QuantifierSegmenter:处理中文数量词的子分词器

3)CJKSegmenter:处理中文词的子分词器

每个具体的分词由以下结构体表示:

/**
 * IK词元对象 
 */
  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;
   
     /*词元的起始位移,指缓冲区的偏移量,因为不可能一次性把所有数据分析完,必须分段分析,因此     
*offset指的是每次读取缓存区的偏移量(和起始位置比较)*/
   private int offset;
//特定缓冲区内词元的起始位置
    private int begin;
    //词元的长度
    private int length;
    //词元文本
    private String lexemeText;
    //词元类型
    private int lexemeType;
  
  
    ……

例如以下这段话:

一次我们社导游带团到南京去,团里有位客人问:“南京市市长是不是叫江大桥”,我们导游的回答说:“不是啊”。客人很奇怪的说那为什么我路过南京的时候,路边有个牌子上写着“南京市长江大桥欢迎您”?

缓存区分2次读完,分别为:

1)一次我们社导游带团到南京去,团里有位客人问:“南京市市长是不是叫江大桥”,我们导游的回答说:“不是啊”。

2)客人很奇怪的说那为什么我路过南京的时候,路边有个牌子上写着“南京市长江大桥欢迎您”?

其中标红的南京和牌子的Lexeme分别如下:

//南京
Lexeme lexeme = new Lexeme(0, 10, 2, Lexeme.TYPE_CNWORD);
//牌子
Lexeme lexeme = new Lexeme(53, 25, 2, Lexeme.TYPE_ CNWORD);

 

3.1 LetterSegmenter

LetterSegmenter的分词流程:

public void analyze(AnalyzeContext context) 
   boolean bufferLockFlag = false;
   //处理英文字母
   bufferLockFlag = this.processEnglishLetter(context) || bufferLockFlag;
   //处理阿拉伯字母
   bufferLockFlag = this.processArabicLetter(context) || bufferLockFlag;
   //处理混合字母(这个要放最后处理,可以通过QuickSortSet排除重复)
   bufferLockFlag = this.processMixLetter(context) || bufferLockFlag;
   
   //判断是否锁定缓冲区
   if(bufferLockFlag)
      context.lockBuffer(SEGMENTER_NAME);
   else
      //对缓冲区解锁
      context.unlockBuffer(SEGMENTER_NAME);
   

 

LetterSegmenter主要处理3种情况:

1)  英文字母

2)  阿拉伯字母

3)  混合字母

以上处理流程都比较相似,拿英文字母处理流程说明:

/**
 * 处理纯英文字母输出
 * @param context
 * @return
 */
  private boolean processEnglishLetter(AnalyzeContext context)
   boolean needLock = false;
   
   if(this.englishStart == -1)//当前的分词器尚未开始处理英文字符 
//如果此字符为英文字符,则标记起始位置
      if(CharacterUtil.CHAR_ENGLISH == context.getCurrentCharType())
         //记录起始指针的位置,标明分词器进入处理状态
         this.englishStart = context.getCursor();
         this.englishEnd = this.englishStart;
      
   else //当前的分词器正在处理英文字符    
      if(CharacterUtil.CHAR_ENGLISH == context.getCurrentCharType())
         //记录当前指针位置为结束位置
//如果还是英文字符,则继续
         this.englishEnd =  context.getCursor();
      else
//遇到非英文字符,则输出
         //遇到非English字符,输出词元
         Lexeme newLexeme = new Lexeme(context.getBufferOffset() , this.englishStart , this.englishEnd - this.englishStart + 1 , Lexeme.TYPE_ENGLISH);
         //将结果放入原始分词结果集合,未经歧义处理
         context.addLexeme(newLexeme);
         this.englishStart = -1;
         this.englishEnd= -1;
      
   
   
   //判断缓冲区是否已经读完
//如果缓冲区读完,则也输出
   if(context.isBufferConsumed() && (this.englishStart != -1 && this.englishEnd != -1))
           //缓冲以读完,输出词元
           Lexeme newLexeme = new Lexeme(context.getBufferOffset() , this.englishStart , this.englishEnd - this.englishStart + 1 , Lexeme.TYPE_ENGLISH);
           context.addLexeme(newLexeme);
           this.englishStart = -1;
           this.englishEnd= -1;
     
   
   //判断是否锁定缓冲区
   if(this.englishStart == -1 && this.englishEnd == -1)
      //对缓冲区解锁
      needLock = false;
   else
      needLock = true;
   
   return needLock;         

其实就是比对起始字符,如果是英文字符,则标记起始位置,然后查找结束位置,查找结束位置主要分两种情况:1)遇到不是英文字符,则输出;2)缓冲区读完也输出

阿拉伯字母和混合字母的处理流程类似。

假如以下这段话:

“ABC123”

经过LetterSegmenter分词之后为:

1)  Abc(英文字母)

2)  123(阿拉巴字母)

3)  abc123(混合字母)


3.2 CN_QuantifierSegmenter

CN_QuantifierSegmenter主要是用来切分中文数词和中文量词,其中针对中文量词会建立一颗中文量词词典树,其加载的配置文件为:/usr/dahua/elasticsearch/plugins/analysis-ik/config/

quantifier.dic。IK中会根据不同的词类型建立不同的词典树,其形状如下图所示:

 

其中每个字由DictSegment,它存储了当前保存的字以及当前词链表是否组成一个关键词,其中private Character nodeChar保存了当前的字,private intnodeState = 0;        保存当前词链表的状态,如果为1,则从上至下可以组成一个词,否则只是前缀。

 

CN_QuantifierSegmenter的分词过程如下:

public void analyze(AnalyzeContext context) 
   //处理中文数词
   this.processCNumber(context);
   //处理中文量词
   this.processCount(context);
   
   //判断是否锁定缓冲区
   if(this.nStart == -1 && this.nEnd == -1    && countHits.isEmpty())
      //对缓冲区解锁
      context.unlockBuffer(SEGMENTER_NAME);
   else
      context.lockBuffer(SEGMENTER_NAME);
   

 

CN_QuantifierSegmenter主要处理2种情况:

1) 中文数词,其中中文数词为以下这些词:"一二两三四五六七八九十零壹贰叁肆伍陆柒捌玖拾百千万亿拾佰仟萬億兆卅廿",这些词直接在代码中指定,如下:

//中文数词
  private static String Chn_Num = "一二两三四五六七八九十零壹贰叁肆伍陆柒捌玖拾百千万亿拾佰仟萬億兆卅廿";

 

 

2)  中文量词。它从/usr/dahua/elasticsearch/plugins/analysis-ik/config/quantifier.dic加载行程量词词典树

 

处理中文数词的过程如下:

private void processCNumber(AnalyzeContext context)
   if(nStart == -1 && nEnd == -1)//初始状态
      if(CharacterUtil.CHAR_CHINESE == context.getCurrentCharType() 
            && ChnNumberChars.contains(context.getCurrentChar()))//是中文数词
         //记录数词的起始、结束位置
         nStart = context.getCursor();
         nEnd = context.getCursor();
      
   else//正在处理状态
      if(CharacterUtil.CHAR_CHINESE == context.getCurrentCharType() 
            && ChnNumberChars.contains(context.getCurrentChar()))//还是中文数词
         //记录数词的结束位置
         nEnd = context.getCursor();
      else
         //输出数词
         this.outputNumLexeme(context);
         //重置头尾指针
         nStart = -1;
         nEnd = -1;
      
   
   
   //缓冲区已经用完,还有尚未输出的数词
   if(context.isBufferConsumed() && (nStart != -1 && nEnd != -1))
      //输出数词
      outputNumLexeme(context);
      //重置头尾指针
      nStart = -1;
      nEnd = -1;
     

 

处理中文数词主要是把关键字在Chn_Num中扫描,如果命中,则说明是中文数词,一旦没有命中,则把之前命中的组成一个词输出。

 

处理中文量词的过程如下:

private void processCount(AnalyzeContext context)
   // 判断是否需要启动量词扫描
   if(!this.needCountScan(context))
      return;
   
   
   if(CharacterUtil.CHAR_CHINESE == context.getCurrentCharType())
      
      //优先处理countHits中的hit
      if(!this.countHits.isEmpty())//处理之前的前缀匹配情况
         //处理词段队列
         Hit[] tmpArray = this.countHits.toArray(new Hit[this.countHits.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_COUNT);
               context.addLexeme(newLexeme);
               
               if(!hit.isPrefix())//并且当前不是词前缀,hit不需要继续匹配,移除
                  this.countHits.remove(hit);
               
               
            else if(hit.isUnmatch())
               //hit不是词,移除
               this.countHits.remove(hit);
                          
         
                 
  
      //*********************************
      //对当前指针位置的字符进行单字匹配
      Hit singleCharHit = Dictionary.getSingleton().matchInQuantifierDict(context.getSegmentBuff(), context.getCursor(), 1);
      if(singleCharHit.isMatch())//首字成量词词
         //输出当前的词
         Lexeme newLexeme = new Lexeme(context.getBufferOffset() , context.getCursor() , 1 , Lexeme.TYPE_COUNT);
         context.addLexeme(newLexeme);
  
         //同时也是词前缀
         if(singleCharHit.isPrefix())
            //前缀匹配则放入hit列表
            this.countHits.add(singleCharHit);
         
      else if(singleCharHit.isPrefix())//首字为量词前缀
         //前缀匹配则放入hit列表
         this.countHits.add(singleCharHit);
      
      
      
   else
      //输入的不是中文字符
      //清空未成形的量词
      this.countHits.clear();
   
   
   //缓冲区数据已经读完,还有尚未输出的量词
   if(context.isBufferConsumed())
      //清空未成形的量词
      this.countHits.clear();
   

 

假如以下这段话:

"两三四立方公尺"

经过CN_QuantifierSegmenter分词之后为:

1)  两三四(中文数词)

2)  立方公尺(中文量词)

至于其它的例如:

两三,三四,三,四等是由CJKSegmenter分词而得的

3.3 CJKSegmenter

CJKSegmenter主要是用来切分普通的中文词,其加载过程如下:

private void loadMainDict() 
   // 建立一个主词典实例
   _MainDict = new DictSegment((char) 0);
  
   // 读取主词典文件
   Path file = PathUtils.get(getDictRoot(), Dictionary.PATH_DIC_MAIN);
  
   InputStream is = null;
   try 
      is = new FileInputStream(file.toFile());
    catch (FileNotFoundException e) 
      logger.error(e.getMessage(), e);
   
  
   try 
      BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"), 512);
      String theWord = null;
      do 
         theWord = br.readLine();
         if (theWord != null && !"".equals(theWord.trim())) 
            _MainDict.fillSegment(theWord.trim().toCharArray());
         
       while (theWord != null);
  
    catch (IOException e) 
      logger.error("ik-analyzer", e);
  
    finally 
      try 
         if (is != null) 
            is.close();
            is = null;
         
       catch (IOException e) 
         logger.error("ik-analyzer", e);
      
   
   // 加载扩展词典,在这里加载custom/mydict.dic等自定义词库
   this.loadExtDict();
   // 加载远程自定义词库
   this.loadRemoteExtDict();

主要加载3个部分的词库,分别是:

1)/usr/dahua/elasticsearch/plugins/analysis-ik/config/main.dic

2)/usr/dahua/elasticsearch/plugins/analysis-ik/config/custom/mydict.dic;

/usr/dahua/elasticsearch/plugins/analysis-ik/config/custom/single_word_full.dic;

/usr/dahua/elasticsearch/plugins/analysis-ik/config/custom/sougou.dic

3)远程扩展字典,默认没有配置

加载完成之后都会生成一颗词典树

其分词过程和CN_QuantifierSegmenter处理中文量词的过程相似:

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);
  
         //?如何将词添加进QuickSortSet,以及如何处理歧义词
         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);
   

4.歧义词处理策略

当通过以上三个分词器将短语分词之后,会产生许多短的词语,即IK词元对象 Lexeme,此时需要根据不同的策略输出最终的分词结果,目前IK支持两种策略:

1)  ik_max_word:尽可能多的词语

2)  ik_smart:尽可能少的词语,且词语之间不会出现交叉,即Lexeme的offset,begin和length不会出现相互之间重叠

其处理过程如下:

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中
  
            //对当前的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();
   
  
   //1)useSmart = true: 当crossPath = 1  直接输出,否则进行歧义处理
   //2)useSmart = false: 不做歧义处理,不使用智能输出,直接输出
   //处理最后的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);
   

         While循环会不断遍历初始词元集合,通过crossPath.addCrossLexeme(orgLexeme)不断来寻找每个潜在的交叉集合,比方说:

“中华人民共和国 我爱我的祖国”

经过之前3个子分词器分词之后会变为以下几个关键字:

中华人民共和国,中华人民,中华,中,华人,华,人民共和国,人民,人,民,共和国,共和,共,和,国,我爱,我,爱我,爱,我的,我,祖国,祖,国

则找到的第一个交叉集合为:

中华人民共和国,中华人民,中华,中,华人,华,人民共和国,人民,人,民,共和国,共和,共,和,国

第二个交叉集合为:

我爱,我,爱我,爱,我的,我

第三个交叉集合为:

祖国,祖,国

 

4.1 ik_max_word

 当采用ik_max_word时,即useSmart为false,此时由于!useSmart为true,则不会做任何处理,会把所有的分词结果都作为最后的分词结果输出

4.2 ik_smart

当useSmart为true的时候会针对每个交叉集合进行歧义处理,输出最优结果:

private LexemePath judge(QuickSortSet.Cell lexemeCell ,int fullTextLength)
   //候选路径集合
  
TreeSet<LexemePath> pathOptions = newTreeSet<LexemePath>();
   //候选结果路径
  
LexemePath option = new LexemePath();
  
   //对crossPath进行一次遍历,同时返回本次遍历中有冲突的Lexeme栈
   //Stack 后进先出
   // 第一次遍历,输出交叉集合中起始位置开始的第一个非交叉集合

Stack<QuickSortSet.Cell> lexemeStack = this.forwardPath(lexemeCell , option);
  
   //当前词元链并非最理想的,加入候选路径集合

//将其添加进候选列表中
  
pathOptions.add(option.copy());

   //存在歧义词,处理
  
QuickSortSet.Cell c = null;
   while(!lexemeStack.isEmpty())//枚举不同情况下的非交叉集合,并且添加进候选列表
      c = lexemeStack.pop();
      //回滚词元链
     
this.backPath(c.getLexeme() , option);
      //从歧义词位置开始,递归,生成可选方案
     
this.forwardPath(c , option);
      pathOptions.add(option.copy());
  
  
   //返回集合中的最优方案
  
return pathOptions.first();

其中judge的输入参数为:每个交叉集合的第一个词元(lexemeCell),交叉集合的长度(fullTextLength),最优集合的输出是由一颗有序的Set集合树决策,即TreeSet<LexemePath> pathOptions,其中根据LexemePath内部的compareTo方法进行排序,取第一个元素作为交叉集合的最优词元组。我们先来看下LexemePath的组成:

class LexemePathextends QuickSortSet implementsComparable<LexemePath>
   //起始位置
  
private int pathBegin;
   //结束
  
private int 中文分词的常见项目

ES中文分词该怎么玩?它的分词原理?ik分词器如何使用?

IK中文分词器原理

IK中文分词器原理

ik分词器 能不能自己增加关键字 怎么操作

IK分词器原理与源码分析

(c)2006-2024 SYSTEM All Rights Reserved IT常识