首先摘抄一段關於IK的特性介紹:
采用了特有的“正向迭代最細粒度切分算法”,具有60萬字/秒的高速處理能力。
采用了多子處理器分析模式,支持:英文字母(IP地址、Email、URL)、數字(日期,常用中文數量詞,羅馬數字,科學計數法),中文詞匯(姓名、地名處理)等分詞處理。
優化的詞典存儲,更小的內存占用。支持用戶詞典擴展定義。
針對Lucene全文檢索優化的查詢分析器IKQueryParser,采用歧義分析算法優化查詢關鍵字的搜索排列組合,能極大的提高Lucene檢索的命中率。
Part1:詞典
從上述內容可知,IK是一個基於詞典的分詞器,首先我們需要了解IK包含哪些詞典?如果加載詞典?
IK包含哪些詞典?
主詞典
停用詞詞典
量詞詞典
如何加載詞典?
IK的詞典管理類為Dictionary,單例模式。主要將以文件形式(一行一詞)的詞典加載到內存。
以上每一類型的詞典都是一個DictSegment對象,DictSegment可以理解成樹形結構,每一個節點又是一個DictSegment對象。
節點的子節點采用數組(DictSegment[])或map(Map(Character, DictSegment))存儲,選用標准根據子節點的數量而定。
如果子節點的數量小於等於ARRAY_LENGTH_LIMIT,采用數組存儲;
如果子節點的數量大於ARRAY_LENGTH_LIMIT,采用Map存儲。
ARRAY_LENGTH_LIMIT默認為3。
這么做的好處是:
子節點多的節點在向下匹配時(find過程),用Map可以保證匹配效率。
子節點不多的節點在向下匹配時,在保證效率的前提下,用數組節約存儲空間。
數組匹配實現如下(二分查找):
int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment);
其中加載詞典的過程如下:
1)加載詞典文件
2)遍歷詞典文件每一行內容(一行一詞),將內容進行初處理交給DictSegment進行填充。
初處理:theWord.trim().toLowerCase().toCharArray()
3)DictSegment填充過程
private synchronized void fillSegment(char[] charArray, int begin, int length, int enabled) { //獲取字典表中的漢字對象 Character beginChar = new Character(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; } } }
/** * 查找本節點下對應的keyChar的segment * * @param keyChar * @param create =1如果沒有找到,則創建新的segment ; =0如果沒有找到,不創建,返回null * @return */ private DictSegment lookforSegment(Character keyChar, int create) { DictSegment ds = null; if (this.storeSize <= ARRAY_LENGTH_LIMIT) { //獲取數組容器,如果數組未創建則創建數組 DictSegment[] segmentArray = getChildrenArray(); //搜尋數組 DictSegment keySegment = new DictSegment(keyChar); int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment); if (position >= 0) { ds = segmentArray[position]; } //遍歷數組后沒有找到對應的segment if (ds == null && create == 1) { ds = keySegment; if (this.storeSize < ARRAY_LENGTH_LIMIT) { //數組容量未滿,使用數組存儲 segmentArray[this.storeSize] = ds; //segment數目+1 this.storeSize++; Arrays.sort(segmentArray, 0, this.storeSize); } else { //數組容量已滿,切換Map存儲 //獲取Map容器,如果Map未創建,則創建Map Map<Character, DictSegment> segmentMap = getChildrenMap(); //將數組中的segment遷移到Map中 migrate(segmentArray, segmentMap); //存儲新的segment segmentMap.put(keyChar, ds); //segment數目+1 , 必須在釋放數組前執行storeSize++ , 確保極端情況下,不會取到空的數組 this.storeSize++; //釋放當前的數組引用 this.childrenArray = null; } } } else { //獲取Map容器,如果Map未創建,則創建Map Map<Character, DictSegment> segmentMap = getChildrenMap(); //搜索Map ds = segmentMap.get(keyChar); if (ds == null && create == 1) { //構造新的segment ds = new DictSegment(keyChar); segmentMap.put(keyChar, ds); //當前節點存儲segment數目+1 this.storeSize++; } } return ds; }
(IK作者注釋太全面了,不再做贅述!)
舉個例子,例如“人民共和國”的存儲結構如下圖:
Part2:分詞
IK的分詞主類是IKSegmenter,他包括如下重要屬性:
Read:待分詞內容
Configuration:分詞器配置,主要控制是否智能分詞,非智能分詞能細粒度輸出所有可能的分詞結果,智能分詞能起到一定的消歧作用。
AnalyzerContext:分詞器上下文,這是個難點。其中包含了字符串緩沖區、字符串類型數組、緩沖區位置指針、子分詞器鎖、原始分詞結果集合等。
List<ISegment>:分詞處理器列表,目前IK有三種類型的分詞處理器,如下:
- CJKSegmenter:中文-日韓文子分詞器
- CN_QuantifierSegmenter:中文數量詞子分詞器
- LetterSegmenter:英文字符及阿拉伯數字子分詞器
IKArbitrator:分詞歧義裁決器
在IKSegment中主要的方法是next(),如下:
/** * 分詞,獲取下一個詞元 * @return Lexeme 詞元對象 * @throws IOException */ public synchronized Lexeme next() throws IOException { if (this.context.hasNextResult()) { //存在尚未輸出的分詞結果 return this.context.getNextLexeme(); } else { /* * 從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, this.cfg.useSmart()); //處理未切分CJK字符 context.processUnkownCJKChar(); //記錄本次分詞的緩沖區位移 context.markBufferOffset(); //輸出詞元 if (this.context.hasNextResult()) { return this.context.getNextLexeme(); } return null; } }
這個過程主要做3件事:
1)將輸入讀入緩沖區(AnalyzerContext.fillBuffer());
2)移動緩沖區指針,同時對指針所指字符進行處理(進行字符規格化-全角轉半角、大寫轉小寫處理)以及類型判斷(識別字符類型),將所指字符交由子分詞器進行處理;
3)字符緩沖區接近讀完時停止移動緩沖區指針,對當前分詞器上下文(AnalyzerContext)中的原始分詞結果進行歧義消除、處理一些殘余字符,為下一次讀入緩沖區做准備。最后輸出詞條。
在這個過程中,一些中間狀態都記錄在分詞器上下文當中,可以理解IK作者當時的設計思路。
在上面next()方法當中,最主要的步驟是調用各個子分詞器的analyze()方法,這里重點介紹CJKSegmenter,如下:
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); } }
這里需要注意tmpHits,在匹配的過程中屬於前綴匹配的臨時放入tmpHits,hit中記錄詞典匹配過程中當前匹配到的詞典分支節點,可以繼續匹配。
在遍歷tmpHits的過程中,如果不是前綴詞(全匹配)、或者不匹配則從tmpHits中移除。遇到遇到CHAR_USELESS字符、或者緩沖隊列已經讀完,則清空tmpHits。
是否匹配由DictSegment的match()方法決定。
(時時刻刻想想那棵字典樹!)
什么時候上下文會收集臨時詞條呢?
1)首字成詞的情況(如果首字還是前綴詞,同時加入tmpHits,待后繼處理)
2)在遍歷tmpHits的過程中如果“全匹配”,也會加入臨時詞條。
下面再了解下match()方法,如下:
/** * 匹配詞段 * @param charArray * @param begin * @param length * @param searchHit * @return Hit */ Hit match(char[] charArray, int begin, int length, Hit searchHit) { if (searchHit == null) { //如果hit為空,新建 searchHit = new Hit(); //設置hit的其實文本位置 searchHit.setBegin(begin); } else { //否則要將HIT狀態重置 searchHit.setUnmatch(); } //設置hit的當前處理位置 searchHit.setEnd(begin); Character keyChar = new Character(charArray[begin]); DictSegment ds = null; //引用實例變量為本地變量,避免查詢時遇到更新的同步問題 DictSegment[] segmentArray = this.childrenArray; Map<Character, DictSegment> segmentMap = this.childrenMap; //STEP1 在節點中查找keyChar對應的DictSegment if (segmentArray != null) { //在數組中查找 DictSegment keySegment = new DictSegment(keyChar); int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment); if (position >= 0) { ds = segmentArray[position]; } } else if (segmentMap != null) { //在map中查找 ds = segmentMap.get(keyChar); } //STEP2 找到DictSegment,判斷詞的匹配狀態,是否繼續遞歸,還是返回結果 if (ds != null) { if (length > 1) { //詞未匹配完,繼續往下搜索 return ds.match(charArray, begin + 1, length - 1, searchHit); } else if (length == 1) { //搜索最后一個char if (ds.nodeState == 1) { //添加HIT狀態為完全匹配 searchHit.setMatch(); } if (ds.hasNextNode()) { //添加HIT狀態為前綴匹配 searchHit.setPrefix(); //記錄當前位置的DictSegment searchHit.setMatchedDictSegment(ds); } return searchHit; } } //STEP3 沒有找到DictSegment, 將HIT設置為不匹配 return searchHit; }
注意hit幾個狀態的判斷:
//Hit不匹配
private static final int UNMATCH = 0x00000000;
//Hit完全匹配
private static final int MATCH = 0x00000001;
//Hit前綴匹配
private static final int PREFIX = 0x00000010;
在進入match方法時,hit都會被重置為unMatch,然后根據Character獲取子節點集合的節點。
如果節點為NULL,hit狀態就是unMatch。
如果節點存在,且nodeState為1,hit狀態就是match,
同時還要判斷節點的子節點數量是否大於0,如果大於0,hit狀態還是prefix。
(時時刻刻想想那棵字典樹!)
對一次buffer處理完后,需要對上下文中的臨時分詞結果進行消歧處理(具體下文再分析)、詞條輸出。
在詞條輸出的過程中,需要判斷每一個詞條是否match停用詞表,如果match則拋棄該詞條。
Part3:消歧
稍等!