IK分詞器 原理分析 源碼解析


IK分詞器在是一款 基於詞典和規則 的中文分詞器。本文講解的IK分詞器是獨立於elasticsearch、Lucene、solr,可以直接用在java代碼中的部分。關於如何開發es分詞插件,后續會有文章介紹。

IK分詞器的源碼:Google Code,直接下載請點擊這里

 

一、兩種分詞模式

IK提供兩種分詞模式:智能模式和細粒度模式(智能:對應es的IK插件的ik_smart,細粒度:對應es的IK插件的ik_max_word)。

先看兩種分詞模式的demo和效果


        
        
       
       
               
  1. import org.wltea.analyzer.core.IKSegmenter;
  2. import org.wltea.analyzer.core.Lexeme;
  3. import java.io.IOException;
  4. import java.io.StringReader;
  5. public class IKSegmenterTest {
  6. static String text = "IK Analyzer是一個結合詞典分詞和文法分詞的中文分詞開源工具包。它使用了全新的正向迭代最細粒度切分算法。";
  7. public static void main( String[] args) throws IOException {
  8. IKSegmenter segmenter = new IKSegmenter(new StringReader(text), false);
  9. Lexeme next;
  10. System.out. print( "非智能分詞結果:");
  11. while((next=segmenter.next())!=null){
  12. System.out. print(next.getLexemeText()+ " ");
  13. }
  14. System.out. println();
  15. System.out. println( "----------------------------分割線------------------------------");
  16. IKSegmenter smartSegmenter = new IKSegmenter(new StringReader(text), true);
  17. System.out. print( "智能分詞結果:");
  18. while((next=smartSegmenter.next())!=null) {
  19. System.out. print(next.getLexemeText() + " ");
  20. }
  21. }
  22. }

輸出如下:

非智能分詞結果:ik  analyzer  是  一個  一  個  結合  詞典  分詞  和文  文法  分詞  的  中文  分詞  開源  工具包  工具  包  它  使用  用了  全新  的  正向  迭代  最  細粒度  細粒  粒度  切分  切  分  算法 
----------------------------分割線------------------------------
智能分詞結果:ik  analyzer  是  一個  結合  詞典  分詞  和  文法  分詞  的  中文  分詞  開源  工具包  它  使  用了  全新  的  正向  迭代  最  細粒度  切分  算法

 

可以看到:細粒度分詞,包含每一種切分可能,而智能模式,只包含各種切分路徑中最可能的一種。

 

 

二、源碼概覽

根據文章開頭提供的鏈接下載源碼在idea中打開后,目錄結構如下

哦哦,csdn把我的圖片弄丟了。。。

 

我們只需要關注cfg,core,dic三個包。

把lucene,query,sample,solr四個包下的代碼注釋掉,這幾個包的代碼是封裝IKSegmenter,適配其他類庫分詞器的接口(lucene,solr),我們在此無需關注。

cfg包括IK的配置接口以及默認配置類,

core包括了IK的分詞器接口ISegmenter,分詞器核心類IKSegmenter,語義單元類Lexeme,上下文AnalyzeContext,以及子分詞器LetterSegementer(英文字符子分詞器),CN_QuantifierSegmenter(中文量詞子分詞器),CJKSegmenter(中日韓字符分詞器),

dic包括了詞典類Dictionary,詞典樹分段類DictSegmenter,用來記錄詞典匹配命中記錄的類Hit,以及主詞典main2012.dic和中文量詞詞典quantifier.dic

 

三、詞典

目前,IK分詞器自帶主詞典擁有27萬左右的漢語單詞量。此外,對於分詞組件應用場景所涉及的領域不同,需要各類專業詞庫的支持,為此IK提供了對擴展詞典的支持。同時,IK還提供了對用戶自定義的停止詞(過濾詞)的擴展支持。

1.詞典的初始化

在分詞器IKSegmenter首次實例化時,默認會根據DefaultConfig找到主詞典和中文量詞詞典路徑,同時DefaultConfig會根據classpath下配置文件IKAnalyzer.cfg.xml,找到擴展詞典和停止詞典路徑,用戶可以在該配置文件中配置自己的擴展詞典和停止詞典。

找到個詞典路徑后,初始化Dictionary.java,Dictionary是單例的。在Dictionary的構造函數中加載詞典。Dictionary是IK的詞典管理類,真正的詞典數據是存放在DictSegment中,該類實現了一種樹結構,如下圖。

哦哦,csdn第二次把我的圖片弄丟了。。。

 

舉個例子,要對字符串“A股市場”進行分詞,首先拿到字符串的第一個字符'A',在上面的tree中可以匹配到A節點,然后拿到字符串第二個字符'股',首先從前一個節點A往下找,我們找到了股節點,股是一個終點節點。所以,“A股“是一個詞。


Dictionary加載主詞典,以,將主詞典保存到它的_MainDict字段中,加載完主詞典后,立即加載擴展詞典,擴展詞典同樣保存在_MainDict中。


        
        
       
       
               
  1. /*
  2. * 主詞典對象
  3. */
  4. private DictSegment _MainDict;
  5. /**
  6. * 加載主詞典及擴展詞典
  7. */
  8. private void loadMainDict(){
  9. //建立一個主詞典實例
  10. _MainDict = new DictSegment(( char) 0);
  11. //讀取主詞典文件
  12. InputStream is = this.getClass().getClassLoader().getResourceAsStream(cfg.getMainDictionary());
  13. if( is == null){
  14. throw new RuntimeException( "Main Dictionary not found!!!");
  15. }
  16. try {
  17. BufferedReader br = new BufferedReader( new InputStreamReader( is , "UTF-8"), 512);
  18. String theWord = null;
  19. do {
  20. theWord = br.readLine();
  21. if (theWord != null && ! "". equals(theWord.trim())) {
  22. _MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray()); //加載主詞典
  23. }
  24. } while (theWord != null);
  25. } catch (IOException ioe) {
  26. System.err.println( "Main Dictionary loading exception.");
  27. ioe.printStackTrace();
  28. } finally{
  29. try {
  30. if( is != null){
  31. is.close();
  32. is = null;
  33. }
  34. } catch (IOException e) {
  35. e.printStackTrace();
  36. }
  37. }
  38. //加載擴展詞典
  39. this.loadExtDict();
  40. }

fillSegment方法是DictSegment加載單個詞的核心方法,charArray是詞的字符數組,先是從存儲節點搜索詞的第一個字符,如果不存在則創建一個節點用於存儲第一個字符,后面遞歸存儲,直到最后一個字符。


        
        
       
       
               
  1. /**
  2. * 加載填充詞典片段
  3. * @param charArray
  4. * @param begin
  5. * @param length
  6. * @param enabled
  7. */
  8. private synchronized void fillSegment(char[] charArray , int begin , int length , int enabled){
  9. //獲取字典表中的漢字對象
  10. Character beginChar = new Character(charArray[begin]);
  11. //搜索當前節點的存儲,查詢對應keyChar的keyChar,如果沒有則創建
  12. DictSegment ds = lookforSegment(keyChar , enabled);
  13. if(ds != null){
  14. //處理keyChar對應的segment
  15. if(length > 1){
  16. //詞元還沒有完全加入詞典樹
  17. ds.fillSegment(charArray, begin + 1, length - 1 , enabled);
  18. } else if (length == 1){
  19. //已經是詞元的最后一個char,設置當前節點狀態為enabled,
  20. //enabled=1表明一個完整的詞,enabled=0表示從詞典中屏蔽當前詞
  21. ds.nodeState = enabled;
  22. }
  23. }
  24. }

停止詞和數量詞同樣的加載方法。參考Dictionary中loadStopWordDict()和loadQuantifierDict()方法。

 

tips,熱詞更新:

當詞典初始化完畢后,可以調用Dictionary的addWords(Collection<String> words)方法往主詞典_MainDict添加熱詞。


        
        
       
       
               
  1. /**
  2. * 批量加載新詞條
  3. * @param words Collection<String>詞條列表
  4. */
  5. public void addWords(Collection<String> words){
  6. if(words != null){
  7. for(String word : words){
  8. if (word != null) {
  9. //批量加載詞條到主內存詞典中
  10. singleton._MainDict.fillSegment(word.trim().toLowerCase().toCharArray());
  11. }
  12. }
  13. }
  14. }

 

四、基於詞典的切分

上面提到,主詞典加載在Dictionary的_MainDict字段(DictSegment類型)中,

創建IKSegmenter時,需要傳進來一個Reader實例,IK分詞時,采用流式處理方式。

在IKSegmenter的next()方法中,首先調用AnalyzeContext.fillBuffer(this.input)從Reader讀取8K數據到到segmentBuff的char數組中,然后調用子分詞器CJKSegmenter(中日韓文分詞器),CN_QuantifierSegmenter(中文數量詞分詞器),LetterSegmenter(英文分詞器)的analyze方法依次從頭處理segmentBuff中的每一個字符。

LetterSegmenter.analyze():英文分詞器邏輯很簡單,從segmentBuff中遇到第一個英文字符往后,直到碰到第一個非英文字符,這中間的所有字符則切分為一個英文單詞。

CN_QuantifierSegmenter.analyze():中文量詞分詞器處理邏輯也很簡單,在segmentBuff中遇到每一個中文數量詞,然后檢查該數量詞后一個字符是否未中文量詞(根據是否包含在中文量詞詞典中為判斷依據),如是,則分成一個詞,如否,則不是一個詞。


        
        
       
       
               
  1. /**
  2. * 分詞,獲取下一個詞元
  3. * @return Lexeme 詞元對象
  4. * @throws IOException
  5. */
  6. public synchronized Lexeme next()throws IOException{
  7. Lexeme l = null;
  8. while((l = context.getNextLexeme()) == null){
  9. /*
  10. * 從reader中讀取數據,填充buffer
  11. * 如果reader是分次讀入buffer的,那么buffer要進行移位處理
  12. * 移位處理上次讀入的但未處理的數據
  13. */
  14. int available = context.fillBuffer( this.input);
  15. if(available <= 0){
  16. //reader已經讀完
  17. context.reset();
  18. return null;
  19. } else{
  20. //初始化指針
  21. context.initCursor();
  22. do{
  23. //遍歷子分詞器
  24. for(ISegmenter segmenter : segmenters){
  25. segmenter.analyze(context);
  26. }
  27. //字符緩沖區接近讀完,需要讀入新的字符
  28. if(context.needRefillBuffer()){
  29. break;
  30. }
  31. //向前移動指針
  32. } while(context.moveCursor());
  33. //重置子分詞器,為下輪循環進行初始化
  34. for(ISegmenter segmenter : segmenters){
  35. segmenter.reset();
  36. }
  37. }
  38. //對分詞進行歧義處理
  39. this.arbitrator.process(context, this.cfg.useSmart());
  40. //處理未切分CJK字符
  41. context.outputToResult();
  42. //記錄本次分詞的緩沖區位移
  43. context.markBufferOffset();
  44. }
  45. return l;
  46. }

 

CJKSegmenter.analyze則比較復雜一些,拿到第一個字符,調用Dictionary.matchInMainDict()方法,實際就是調用_MainDict.match()方法,在主詞典的match方法中去匹配,首先判斷該字能否單獨成詞(即判斷_MainDict中該詞所在第一個層的節點狀態是否為1),如果能則加入上下文中保存起來。然后再判斷該詞是否可能為其他詞的前綴(即判斷_MainDict中該詞所在第一層節點是否還有子節點),如果是則保存在分詞器的臨時字段tmpHits中。

再往后拿到segmentBuff中第二個字符,首先判斷該詞是否存在上一輪保存在temHits中的字符所在節點的子節點中,如果存在則判斷這兩個字符能否組成完整的詞(同樣,依據字符節點的狀態是否為1來判斷),如果成詞,保存到上下文中,並且繼續判斷是否可能為其他詞的前綴(還是判斷該字符節點是否還有子節點),如果有,繼續保存到tmpHits中,如果沒有,則拋棄。然后再講該字符重復與第一個字符一樣的操作即可。


        
        
       
       
               
  1. public void analyze(AnalyzeContext context) {
  2. if(CharacterUtil.CHAR_USELESS != context.getCurrentCharType()){
  3. //優先處理tmpHits中的hit
  4. if(! this.tmpHits.isEmpty()){
  5. //處理詞段隊列
  6. Hit[] tmpArray = this.tmpHits.toArray(new Hit[ this.tmpHits.size()]);
  7. for(Hit hit : tmpArray){
  8. hit = Dictionary.getSingleton().matchWithHit(context.getSegmentBuff(), context.getCursor() , hit);
  9. if(hit.isMatch()){
  10. //輸出當前的詞
  11. Lexeme newLexeme = new Lexeme(context.getBufferOffset() , hit.getBegin() , context.getCursor() - hit.getBegin() + 1 , Lexeme.TYPE_CNWORD);
  12. context.addLexeme(newLexeme);
  13. if(!hit.isPrefix()){ //不是詞前綴,hit不需要繼續匹配,移除
  14. this.tmpHits.remove(hit);
  15. }
  16. } else if(hit.isUnmatch()){
  17. //hit不是詞,移除
  18. this.tmpHits.remove(hit);
  19. }
  20. }
  21. }
  22. //*********************************
  23. //再對當前指針位置的字符進行單字匹配
  24. Hit singleCharHit = Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(), context.getCursor(), 1);
  25. if(singleCharHit.isMatch()){ //首字成詞
  26. //輸出當前的詞
  27. Lexeme newLexeme = new Lexeme(context.getBufferOffset() , context.getCursor() , 1 , Lexeme.TYPE_CNWORD);
  28. context.addLexeme(newLexeme);
  29. //同時也是詞前綴
  30. if(singleCharHit.isPrefix()){
  31. //前綴匹配則放入hit列表
  32. this.tmpHits.add(singleCharHit);
  33. }
  34. } else if(singleCharHit.isPrefix()){ //首字為詞前綴
  35. //前綴匹配則放入hit列表
  36. this.tmpHits.add(singleCharHit);
  37. }
  38. } else{
  39. //遇到CHAR_USELESS字符
  40. //清空隊列
  41. this.tmpHits.clear();
  42. }
  43. //判斷緩沖區是否已經讀完
  44. if(context.isBufferConsumed()){
  45. //清空隊列
  46. this.tmpHits.clear();
  47. }
  48. //判斷是否鎖定緩沖區
  49. if( this.tmpHits.size() == 0){
  50. context.unlockBuffer(SEGMENTER_NAME);
  51. } else{
  52. context.lockBuffer(SEGMENTER_NAME);
  53. }
  54. }

當子分詞器處理完segmentBuff中所有字符后,字符的所有成詞情況都已保存到上下文的orgLexemes字段中。

調用分詞歧義裁決器IKArbitrator,如果分詞器使用細粒度模式(useSmart=false),則裁決器不做不做歧義處理,將上下文orgLexemes字段中所有成詞情況全部保存到上下文pathMap中。

然后調用context.outputToResult()方法根據pathMap中的成詞情況,將最終分詞結果保存到上下文的result字段中。
  
  
 
 
         
至此,segmentBuff中所有字符的分詞結果全部保存在result中了,通過IKSegmenter.next()方法一個一個返回給調用者。
  
  
 
 
         

 

當next方法返回result所有分詞后,分詞器再從Reader中讀取下一個8K數據到segmentBuff中,重復上述所有步驟,直至Reader全部讀取完畢。

 

五、基於規則的歧義判斷

分詞裁決器IKArbitrator只有在Smart模式才會生效。

judge是IKArbitrator處理分詞歧義的方法。
  
  
 
 
         

裁決器從上下文orgLexemes讀取所有的成詞,判斷有交叉(有交叉即表示分詞有歧義)的成詞,然后,遍歷每一種不交叉的情況,用LexemePath對象表示,然后保存到自定義有序鏈表TreeSet中,最后first()取出鏈表第一個元素,即為最佳分詞結果。


        
        
       
       
               
  1. /**
  2. * 歧義識別
  3. * @param lexemeCell 歧義路徑鏈表頭
  4. * @param fullTextLength 歧義路徑文本長度
  5. * @param option 候選結果路徑
  6. * @return
  7. */
  8. private LexemePath judge(QuickSortSet.Cell lexemeCell , int fullTextLength){
  9. //候選路徑集合
  10. TreeSet<LexemePath> pathOptions = new TreeSet<LexemePath>();
  11. //候選結果路徑
  12. LexemePath option = new LexemePath();
  13. //對crossPath進行一次遍歷,同時返回本次遍歷中有沖突的Lexeme棧
  14. Stack<QuickSortSet.Cell> lexemeStack = this.forwardPath(lexemeCell , option);
  15. //當前詞元鏈並非最理想的,加入候選路徑集合
  16. pathOptions.add(option.copy());
  17. //存在歧義詞,處理
  18. QuickSortSet.Cell c = null;
  19. while(!lexemeStack.isEmpty()){
  20. c = lexemeStack.pop();
  21. //回滾詞元鏈
  22. this.backPath(c.getLexeme() , option);
  23. //從歧義詞位置開始,遞歸,生成可選方案
  24. this.forwardPath(c , option);
  25. pathOptions.add(option.copy());
  26. }
  27. //返回集合中的最優方案
  28. return pathOptions.first();
  29. }

然后我們看一下LexemePath對象的比較規則,即為IK的歧義判斷規則,LexemePath實現了Comparable接口。

從compareTo方法可以得出,IK歧義判斷規則如下,優先級從上到下一致降低:

1.分詞文本長度越長越好

2.分詞個數越少越好

3.分詞路徑跨度越大越好

4.分詞位置越靠后的優先

5.詞長越平均越好

6.詞元位置權重越大越好(這個我也沒明白,先這樣,后面有需要再弄明白具體細節)


        
        
       
       
               
  1. public int compareTo(LexemePath o) {
  2. //比較有效文本長度
  3. if( this.payloadLength > o.payloadLength){
  4. return -1;
  5. } else if( this.payloadLength < o.payloadLength){
  6. return 1;
  7. } else{
  8. //比較詞元個數,越少越好
  9. if( this.size() < o.size()){
  10. return -1;
  11. } else if ( this.size() > o.size()){
  12. return 1;
  13. } else{
  14. //路徑跨度越大越好
  15. if( this.getPathLength() > o.getPathLength()){
  16. return -1;
  17. } else if( this.getPathLength() < o.getPathLength()){
  18. return 1;
  19. } else {
  20. //根據統計學結論,逆向切分概率高於正向切分,因此位置越靠后的優先
  21. if( this.pathEnd > o.pathEnd){
  22. return -1;
  23. } else if(pathEnd < o.pathEnd){
  24. return 1;
  25. } else{
  26. //詞長越平均越好
  27. if( this.getXWeight() > o.getXWeight()){
  28. return -1;
  29. } else if( this.getXWeight() < o.getXWeight()){
  30. return 1;
  31. } else {
  32. //詞元位置權重比較
  33. if( this.getPWeight() > o.getPWeight()){
  34. return -1;
  35. } else if( this.getPWeight() < o.getPWeight()){
  36. return 1;
  37. }
  38. }
  39. }
  40. }
  41. }
  42. }
  43. return 0;
  44. }

六、總結

總的來說,IK分詞是一個基於詞典的分詞器,只有包含在詞典的詞才能被正確切分,IK解決分詞歧義只是根據幾條可能是最佳的分詞實踐規則,並沒有用到任何概率模型,也不具有新詞發現的功能。

原文地址:https://blog.csdn.net/jiandabang/article/details/83539783


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM