首先摘抄一段關於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:消歧
稍等!
