IKAnalyzer 源碼走讀


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

 稍等!

 


免責聲明!

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



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