IK分詞器原理與源碼分析


原文:http://3dobe.com/archives/44/

引言

做搜索技術的不可能不接觸分詞器。個人認為為什么搜索引擎無法被數據庫所替代的原因主要有兩點,一個是在數據量比較大的時候,搜索引擎的查詢速度快,第二點在於,搜索引擎能做到比數據庫更理解用戶。第一點好理解,每當數據庫的單個表大了,就是一件頭疼的事,還有在較大數據量級的情況下,你讓數據庫去做模糊查詢,那也是一件比較吃力的事(當然前綴匹配會好得多),設計上就應當避免。關於第二點,搜索引擎如何理解用戶,肯定不是簡單的靠匹配,這里面可以加入很多的處理,甚至加入各種自然語言處理的高級技術,而比較通用且基本的方法就是靠分詞器來完成,而且這是一種比較簡單而且高效的處理方法。

分詞技術是搜索技術里面的一塊基石。很多人用過,如果你只是為了簡單快速地搭一個搜索引擎,你確實不用了解太深。但一旦涉及效果問題,分詞器上就可以做很多文章。例如, 在實我們際用作電商領域的搜索的工作中,類目預判的實現就極須依賴分詞,至少需要做到可以對分詞器動態加規則。再一個簡單的例子,如果你的優化方法就是對不同的詞分權重,提高一些重點詞的權重的話,你就需要依賴並理解分詞器。本文將根據ik分配器的原碼對其實現做一定分析。其中的重點,主要3點,1、詞典樹的構建,即將現在的詞典加載到一個內存結構中去, 2、詞的匹配查找,也就相當生成對一個句話中詞的切分方式,3、歧義判斷,即對不同切分方式的判定,哪種應是更合理的。 代碼原網址為:[https://code.google.com/p/ik-analyzer/](https://code.google.com/p/ik-analyzer/) 已上傳github,可訪問:[https://github.com/quentinxxz/Search/tree/master/IKAnalyzer2012FF_hf1_source/](https://github.com/quentinxxz/Search/tree/master/IKAnalyzer2012FF_hf1_source/)

詞典

做后台數據相關操作,一切工作的源頭都是數據來源了。IK分詞器為我們詞供了三類詞表分別是:1、主詞表 main2012.dic 2、量詞表quantifier.dic 3、停用詞stopword.dic。
Dictionary為字典管理類中,分別加載了這個詞典到內存結構中。具體的字典代碼,位於org.wltea.analyzer.dic.DictSegment。 這個類實現了一個分詞器的一個核心數據結構,即Tire Tree。

Tire Tree(字典樹)是一種結構相當簡單的樹型結構,用於構建詞典,通過前綴字符逐一比較對方式,快速查找詞,所以有時也稱為前綴樹。具體的例子如下。
tireTree.jpg

圖1
從左來看,abc,abcd,abd,b,bcd…..這些詞就是存在樹中的單詞。當然中文字符也可以一樣處理,但中文字符的數目遠多於26個,不應該以位置代表字符(英文的話,可以每節點包完一個長度為26的數組),如此的話,這棵tire tree會變得相當擴散,並占用內存,因而有一個tire Tree的變種,三叉字典樹(Ternary Tree),保證占用較小的內存。Ternary Tree不在ik分詞器中使用,所以不在此詳述,請參考文章http://www.cnblogs.com/rush/archive/2012/12/30/2839996.html
IK中采用的是一種比方簡單的實現。先看一下,DictSegment類的成員:

class DictSegment implements Comparable<DictSegment>{ 
<span class="comment"><span class="hljs-comment">//公用字典表,存儲漢字  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">static</span> <span class="keyword"><span class="hljs-keyword">final</span> Map&lt;Character , Character&gt; charMap = <span class="keyword"><span class="hljs-keyword">new</span> HashMap&lt;Character , Character&gt;(<span class="number"><span class="hljs-number">16</span> , <span class="number"><span class="hljs-number">0.95f</span>);  
<span class="comment"><span class="hljs-comment">//數組大小上限  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">static</span> <span class="keyword"><span class="hljs-keyword">final</span> <span class="keyword"><span class="hljs-keyword">int</span> ARRAY_LENGTH_LIMIT = <span class="number"><span class="hljs-number">3</span>;  

  
<span class="comment"><span class="hljs-comment">//Map存儲結構  </span>
<span class="keyword"><span class="hljs-keyword">private</span> Map&lt;Character , DictSegment&gt; childrenMap;  
<span class="comment"><span class="hljs-comment">//數組方式存儲結構  </span>
<span class="keyword"><span class="hljs-keyword">private</span> DictSegment[] childrenArray;  


<span class="comment"><span class="hljs-comment">//當前節點上存儲的字符  </span>
<span class="keyword"><span class="hljs-keyword">private</span> Character nodeChar;  
<span class="comment"><span class="hljs-comment">//當前節點存儲的Segment數目  </span>
<span class="comment"><span class="hljs-comment">//storeSize &lt;=ARRAY_LENGTH_LIMIT ,使用數組存儲, storeSize &gt;ARRAY_LENGTH_LIMIT ,則使用Map存儲  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">int</span> storeSize = <span class="number"><span class="hljs-number">0</span>;  
<span class="comment"><span class="hljs-comment">//當前DictSegment狀態 ,默認 0 , 1表示從根節點到當前節點的路徑表示一個詞  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">int</span> nodeState = <span class="number"><span class="hljs-number">0</span>;    
……  

這里有兩種方式去存儲,根據ARRAY_LENGTH_LIMIT作為閾值來決定,如果當子節點數,不太於閾值時,采用數組的方式childrenArray來存儲,當子節點數大於閾值時,采用Map的方式childrenMap來存儲,childrenMap是采用HashMap實現的。這樣做好處在於,節省內存空間。因為HashMap的方式的方式,肯定是需要預先分配內存的,就可能會存在浪費的現象,但如果全都采用數組去存組(后續采用二分的方式查找),你就無法獲得O(1)的算法復雜度。所以這里采用了兩者方式,當子節點數很少時,用數組存儲,當子結點數較多時候,則全部遷至hashMap中去。在構建過程中,會將每個詞一步步地加入到字典樹中,這是一個遞歸的過程:

/** * 加載填充詞典片段 * @param charArray * @param begin * @param length * @param enabled */ private synchronized void fillSegment(char[] charArray , int begin , int length , int enabled){ 
 ……       
<span class="comment"><span class="hljs-comment">//搜索當前節點的存儲,查詢對應keyChar的keyChar,如果沒有則創建  </span>
DictSegment ds = lookforSegment(keyChar , enabled);  
<span class="keyword"><span class="hljs-keyword">if</span>(ds != <span class="keyword"><span class="hljs-keyword">null</span>){  
    <span class="comment"><span class="hljs-comment">//處理keyChar對應的segment  </span>
    <span class="keyword"><span class="hljs-keyword">if</span>(length &gt; <span class="number"><span class="hljs-number">1</span>){  
        <span class="comment"><span class="hljs-comment">//詞元還沒有完全加入詞典樹  </span>
        ds.fillSegment(charArray, begin + <span class="number"><span class="hljs-number">1</span>, length - <span class="number"><span class="hljs-number">1</span> , enabled);  
    }<span class="keyword"><span class="hljs-keyword">else</span> <span class="keyword"><span class="hljs-keyword">if</span> (length == <span class="number"><span class="hljs-number">1</span>){  
        <span class="comment"><span class="hljs-comment">//已經是詞元的最后一個char,設置當前節點狀態為enabled,  </span>
        <span class="comment"><span class="hljs-comment">//enabled=1表明一個完整的詞,enabled=0表示從詞典中屏蔽當前詞  </span>
        ds.nodeState = enabled;  
    }  
}  

}

其中lookforSegment,就會在所在子樹的子節點中查找,如果是少於ARRAY_LENGTH_LIMIT閾值,則是為數組存儲,采用二分查找;如果大於ARRAY_LENGTH_LIMIT閾值,則為HashMap存儲,直接查找。

詞語切分

IK分詞器,基本可分為兩種模式,一種為smart模式,一種為非smart模式。例如原文:
張三說的確實在理
smart模式的下分詞結果為:
張三 | 說的 | 確實 | 在理
而非smart模式下的分詞結果為:
張三 | 三 | 說的 | 的確 | 的 | 確實 | 實在 | 在理
可見非smart模式所做的就是將能夠分出來的詞全部輸出;smart模式下,IK分詞器則會根據內在方法輸出一個認為最合理的分詞結果,這就涉及到了歧義判斷。

首來看一下最基本的一些元素結構類:

public class Lexeme implements Comparable<Lexeme>{ …… 
<span class="comment"><span class="hljs-comment">//詞元的起始位移  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">int</span> offset;  
<span class="comment"><span class="hljs-comment">//詞元的相對起始位置  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">int</span> begin;  
<span class="comment"><span class="hljs-comment">//詞元的長度  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">int</span> length;  
<span class="comment"><span class="hljs-comment">//詞元文本  </span>
<span class="keyword"><span class="hljs-keyword">private</span> String lexemeText;  
<span class="comment"><span class="hljs-comment">//詞元類型  </span>
<span class="keyword"><span class="hljs-keyword">private</span> <span class="keyword"><span class="hljs-keyword">int</span> lexemeType;  
 ……  

這里的Lexeme(詞元),就可以理解為是一個詞語或個單詞。其中的begin,是指其在輸入文本中的位置。注意,它是實現Comparable的,起始位置靠前的優先,長度較長的優先,這可以用來決定一個詞在一條分詞結果的詞元鏈中的位置,可以用於得到上面例子中分詞結果中的各個詞的順序。

/* * 詞元在排序集合中的比較算法 * @see java.lang.Comparable#compareTo(java.lang.Object) */ public int compareTo(Lexeme other) { //起始位置優先  if(this.begin < other.getBegin()){ return -1; }else if(this.begin == other.getBegin()){ //詞元長度優先  if(this.length > other.getLength()){ return -1; }else if(this.length == other.getLength()){ return 0; }else {//this.length < other.getLength()  return 1; } 
}<span class="keyword"><span class="hljs-keyword">else</span>{<span class="comment"><span class="hljs-comment">//this.begin &gt; other.getBegin()  </span>
 <span class="keyword"><span class="hljs-keyword">return</span> <span class="number"><span class="hljs-number">1</span>;  
}  

}

還有一個重要的結構就是詞元鏈,聲明如下

/** * Lexeme鏈(路徑) */ class LexemePath extends QuickSortSet implements Comparable<LexemePath> 

一條LexmePath,你就可以認為是上述分詞的一種結果,根據前后順序組成一個鏈式結構。可以看到它實現了QuickSortSet,所以它本身在加入詞元的時候,就在內部完成排序,形成了一個有序的鏈,而排序規則就是上面Lexeme的compareTo方法所實現的。你也會注意到,LexemePath也是實現Comparable接口的,這就是用於后面的歧義分析用的,下一節介紹。
另一個重要的結構是AnalyzeContext,這里面就主要存儲了輸入信息 的文本,切分出來的lemexePah ,分詞結果等一些相關的上下文信息。
IK中默認用到三個子分詞器,分別是LetterSegmenter(字母分詞器),CN_QuantifierSegment(量詞分詞器),CJKSegmenter(中日韓分詞器)。分詞是會先后經過這三個分詞器,我們這里重點根據CJKSegment分析。其核心是一個analyzer方法。

public void analyze(AnalyzeContext context) { ……. 
    <span class="comment"><span class="hljs-comment">//優先處理tmpHits中的hit  </span>
    <span class="keyword"><span class="hljs-keyword">if</span>(!<span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.isEmpty()){  
        <span class="comment"><span class="hljs-comment">//處理詞段隊列  </span>
        Hit[] tmpArray = <span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.toArray(<span class="keyword"><span class="hljs-keyword">new</span> Hit[<span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.size()]);  
        <span class="keyword"><span class="hljs-keyword">for</span>(Hit hit : tmpArray){  
            hit = Dictionary.getSingleton().matchWithHit(context.getSegmentBuff(), context.getCursor() , hit);  
            <span class="keyword"><span class="hljs-keyword">if</span>(hit.isMatch()){  
                <span class="comment"><span class="hljs-comment">//輸出當前的詞  </span>
                Lexeme newLexeme = <span class="keyword"><span class="hljs-keyword">new</span> Lexeme(context.getBufferOffset() , hit.getBegin() , context.getCursor() - hit.getBegin() + <span class="number"><span class="hljs-number">1</span> , Lexeme.TYPE_CNWORD);  
                context.addLexeme(newLexeme);  
                  
                <span class="keyword"><span class="hljs-keyword">if</span>(!hit.isPrefix()){<span class="comment"><span class="hljs-comment">//不是詞前綴,hit不需要繼續匹配,移除  </span>
                    <span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.remove(hit);  
                }  
                  
            }<span class="keyword"><span class="hljs-keyword">else</span> <span class="keyword"><span class="hljs-keyword">if</span>(hit.isUnmatch()){  
                <span class="comment"><span class="hljs-comment">//hit不是詞,移除  </span>
                <span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.remove(hit);  
            }                     
        }  
    }             
      
    <span class="comment"><span class="hljs-comment">//*********************************  </span>
    <span class="comment"><span class="hljs-comment">//再對當前指針位置的字符進行單字匹配  </span>
    Hit singleCharHit = Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(), context.getCursor(), <span class="number"><span class="hljs-number">1</span>);  
    <span class="keyword"><span class="hljs-keyword">if</span>(singleCharHit.isMatch()){<span class="comment"><span class="hljs-comment">//首字成詞  </span>
        <span class="comment"><span class="hljs-comment">//輸出當前的詞  </span>
        Lexeme newLexeme = <span class="keyword"><span class="hljs-keyword">new</span> Lexeme(context.getBufferOffset() , context.getCursor() , <span class="number"><span class="hljs-number">1</span> , Lexeme.TYPE_CNWORD);  
        context.addLexeme(newLexeme);  

        <span class="comment"><span class="hljs-comment">//同時也是詞前綴  </span>
        <span class="keyword"><span class="hljs-keyword">if</span>(singleCharHit.isPrefix()){  
            <span class="comment"><span class="hljs-comment">//前綴匹配則放入hit列表  </span>
            <span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.add(singleCharHit);  
        }  
    }<span class="keyword"><span class="hljs-keyword">else</span> <span class="keyword"><span class="hljs-keyword">if</span>(singleCharHit.isPrefix()){<span class="comment"><span class="hljs-comment">//首字為詞前綴  </span>
        <span class="comment"><span class="hljs-comment">//前綴匹配則放入hit列表  </span>
        <span class="keyword"><span class="hljs-keyword">this</span>.tmpHits.add(singleCharHit);  
    }  

……
}

從下半截代碼看起,這里的matchInMain就是用於匹配主題表內的詞的方法。這里的主詞表已經加載至一個字典樹之內,所以整個過程也就是一個從樹根層層往下走的一個層層遞歸的方式,但這里只處理單字,不會去遞歸。而匹配的結果一共三種UNMATCH(未匹配),MATCH(匹配), PREFIX(前綴匹配),Match指完全匹配已經到達葉子節點,而PREFIX是指當前對上所經過的匹配路徑存在,但未到達到葉子節點。此外一個詞也可以既是MATCH也可以是PREFIX,例如圖1中的abc。前綴匹配的都被存入了tempHit中去。而完整匹配的都存入context中保存。
繼續看上半截代碼,前綴匹配的詞不應該就直接結束,因為有可能還能往后繼續匹配更長的詞,所以上半截代碼所做的就是對這些詞繼續匹配。matchWithHit,就是在當前的hit的結果下繼續做匹配。如果得到MATCH的結果,便可以在context中加入新的詞元。
通過這樣不段匹配,循環補充的方式,我們就可以得到所有的詞,至少能夠滿足非smart模式下的需求。

歧義判斷

IKArbitrator(歧義分析裁決器)是處理歧義的主要類。
如果覺着我這說不清,也可以參考的博客:http://fay19880111-yeah-net.iteye.com/blog/1523740

在上一節中,我們提到LexemePath是實現compareble接口的。

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; } 
                }  
            }  
        }  
    }  
}  
<span class="keyword"><span class="hljs-keyword">return</span> <span class="number"><span class="hljs-number">0</span>;  

}
顯然作者在這里定死了一些排序的規則,依次比較有效文本長度、詞元個數、路徑跨度…..

IKArbitrator有一個judge方法,對不同路徑做了比較。

private LexemePath judge(QuickSortSet.Cell lexemeCell , int fullTextLength){ //候選路徑集合  TreeSet<LexemePath> pathOptions = new TreeSet<LexemePath>(); //候選結果路徑  LexemePath option = new LexemePath(); 
<span class="comment"><span class="hljs-comment">//對crossPath進行一次遍歷,同時返回本次遍歷中有沖突的Lexeme棧  </span>
Stack&lt;QuickSortSet.Cell&gt; lexemeStack = <span class="keyword">this.forwardPath(lexemeCell , option);  
  
<span class="comment"><span class="hljs-comment">//當前詞元鏈並非最理想的,加入候選路徑集合  </span>
pathOptions.add(option.copy());  
  
<span class="comment"><span class="hljs-comment">//存在歧義詞,處理  </span>
QuickSortSet.Cell c = <span class="keyword"><span class="hljs-keyword">null</span>;  
<span class="keyword"><span class="hljs-keyword">while</span>(!lexemeStack.isEmpty()){  
    c = lexemeStack.pop();  
    <span class="comment"><span class="hljs-comment">//回滾詞元鏈  </span>
    <span class="keyword">this.backPath(c.getLexeme() , option);  
    <span class="comment"><span class="hljs-comment">//從歧義詞位置開始,遞歸,生成可選方案  </span>
    <span class="keyword">this.forwardPath(c , option);  
    pathOptions.add(option.copy());  
}  
  
<span class="comment"><span class="hljs-comment">//返回集合中的最優方案  </span>
<span class="keyword"><span class="hljs-keyword">return</span> pathOptions.first();  

}

其核心處理思想是從第一個詞元開始,遍歷各種路徑,然后加入至一個TreeSet中,實現了排序,取第一個即可。

其它說明

1、stopWord(停用詞),會在最后輸出結果的階段(AnalyzeContext. getNextLexeme)被移除,不會在分析的過程中移除,否則也會存在風險。
2、可以從LexemePath的compareTo方法中看出,Ik的排序方法特別粗略,如果比較發現path1的詞個數,比path2的個數少,就直接判定path1更優。其實這樣的規則,並未完整的參考各個分出來的詞的實際情況,我們可能想加入每個詞經統計出現的頻率等信息,做更全面的打分,這樣IK原有的比較方法就是不可行的。
關於如何修改的思路可以參考另一篇博客,其中介紹了一種通過最短路徑思路去處理的方法:http://www.hankcs.com/nlp/segment/n-shortest-path-to-the-java-implementation-and-application-segmentation.html

3、未匹配的單字,不論是否在smart模式下,最后都會輸出,其處理時機在最后輸出結果階段,具體代碼位於在AnalyzeContext. outputToResult方法中。

首發於iteye:http://quentinxxz.iteye.com/blog/2180215
本站鏈接:http://3dobe.com/archives/44/

原文地址:https://www.cnblogs.com/walter371/p/5197511.html


免責聲明!

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



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