1.前言
在使用ES進(jìn)行中文搜索時(shí)漾橙,分詞的效果直接影響搜索的結(jié)果。對(duì)于沒有能力自研分詞燃乍,或者一般的使用場(chǎng)景唆樊,都會(huì)使用ik分詞器作為分詞插件。ik分詞器的基本使用可以參考: Elasticsearch中ik分詞器的使用刻蟹。ik分詞器的主要邏輯包括三部分:
1)詞典:詞典的好壞直接影響分詞結(jié)果的好壞逗旁,本文將介紹詞典的構(gòu)建和存儲(chǔ)結(jié)構(gòu)
2)詞的匹配:有了詞典之后,就可以對(duì)輸入的字符串逐字句和詞典進(jìn)行匹配
3)消除歧義:通過詞典匹配出來(lái)的切分方式會(huì)有多種舆瘪,消除歧義就是從中尋找最合理的一種方式
2前期準(zhǔn)備
在研究ik的原理之前片效,需要把ik的源碼clone到本地,https://github.com/medcl/elasticsearch-analysis-ik英古。
clone到本地之后checkout 6.8.1版本淀衣,然后寫一個(gè)方法調(diào)用ik分詞器,這樣就可以進(jìn)行debug了召调。
public class TestIK {
public static void main(String[] args) throws IOException {
testIkSegment();
}
public static void testIkSegment() throws IOException {
String t = "得饒人處且饒人";
Settings settings = Settings.builder()
.put("use_smart", false)
.put("enable_lowercase", false)
.put("enable_remote_dict", false)
.build();
Configuration configuration=new Configuration(null,settings).setUseSmart(false);
IKSegmenter segmenter = new IKSegmenter(new StringReader(t), configuration);
Lexeme next;
while ((next = segmenter.next())!=null){
System.out.println(next.getLexemeText());
}
}
}
啟動(dòng)的時(shí)候會(huì)出現(xiàn)如下錯(cuò)誤:
出現(xiàn)錯(cuò)誤的原因就是沒有指定詞典的路徑膨桥,因?yàn)闃?gòu)造Environment對(duì)象相對(duì)比較復(fù)雜蛮浑,因此直接采用文件路徑的方法代替,修改org.wltea.analyzer.dic.Dictionary構(gòu)造器中代碼只嚣,讓代碼能快速運(yùn)行
// this.conf_dir = cfg.getEnvironment().configFile().resolve(AnalysisIkPlugin.PLUGIN_NAME);
this.conf_dir = Paths.get("/Users/zjb/code/mine/commit/ik/elasticsearch-analysis-ik/config");
3.詞典
從上面的準(zhǔn)備工作能看出來(lái)沮稚,詞典的配置其實(shí)是非常重要的,ik為我們提供了三種常見的詞典分別是:
1)main.dic:主詞典册舞,一些常用詞
2)quantifier.dic:常用的量詞
3)stopword.dic:停用詞
這些詞典用戶都可以自行擴(kuò)展蕴掏,只需要配置IKAnalyzer.cfg.xml文件即可
詞典是怎么加載的呢,常用的字典樹 tire tree 是一種結(jié)構(gòu)簡(jiǎn)單的樹调鲸,也叫做前綴樹盛杰,結(jié)構(gòu)如下圖所示
從根節(jié)點(diǎn)觸發(fā),把一個(gè)詞掛在這顆樹上藐石。其中詞的開頭饶唤,都是根節(jié)點(diǎn)的孩子節(jié)點(diǎn),每個(gè)字都是前一個(gè)字的孩子節(jié)點(diǎn)贯钩,紅色的節(jié)點(diǎn)表示詞的結(jié)尾募狂。上圖中間表示,前門是一個(gè)詞角雷,門是結(jié)尾祸穷;前門巨虎也是一個(gè)詞,虎結(jié)尾勺三。
由于main.dic的詞非常多雷滚,而且用戶又可以自定義擴(kuò)展,這樣會(huì)導(dǎo)致這個(gè)樹會(huì)非常龐大吗坚,如果存粹用map存儲(chǔ)祈远,會(huì)比較浪費(fèi)空間,因此ik采用了一種折中的方式商源。結(jié)構(gòu)參考DictSegment.java代碼:
class DictSegment implements Comparable<DictSegment>{
//公用字典表车份,存儲(chǔ)漢字
private static final Map<Character , Character> charMap = new ConcurrentHashMap<Character , Character>(16 , 0.95f);
//數(shù)組大小上限
private static final int ARRAY_LENGTH_LIMIT = 3;
//Map存儲(chǔ)結(jié)構(gòu)
private Map<Character , DictSegment> childrenMap;
//數(shù)組方式存儲(chǔ)結(jié)構(gòu)
private DictSegment[] childrenArray;
//當(dāng)前節(jié)點(diǎn)上存儲(chǔ)的字符
private Character nodeChar;
//當(dāng)前節(jié)點(diǎn)存儲(chǔ)的Segment數(shù)目
//storeSize <=ARRAY_LENGTH_LIMIT ,使用數(shù)組存儲(chǔ)牡彻, storeSize >ARRAY_LENGTH_LIMIT ,則使用Map存儲(chǔ)
private int storeSize = 0;
//當(dāng)前DictSegment狀態(tài) ,默認(rèn) 0 , 1表示從根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑表示一個(gè)詞
private int nodeState = 0;
}
ik的代碼注釋還算比較全扫沼,而且都是中文的,也比較好理解庄吼。主要的思想缎除,就是根據(jù)子節(jié)點(diǎn)的數(shù)量對(duì)存儲(chǔ)結(jié)構(gòu)進(jìn)行調(diào)整,如果子節(jié)點(diǎn)的數(shù)量小于等于3总寻,則采用數(shù)組存儲(chǔ)器罐,如果子節(jié)點(diǎn)的數(shù)量大于3,采用map存儲(chǔ)渐行。其中的nodeState就是用來(lái)標(biāo)記當(dāng)前節(jié)點(diǎn)(即當(dāng)前的字符是否是詞的結(jié)尾)轰坊。
有了這個(gè)結(jié)構(gòu)之后铸董,我們看一下詞是怎么從文件加載到內(nèi)存的,詞典的加載是在Configuration構(gòu)造器內(nèi)進(jìn)行的衰倦,最終會(huì)調(diào)用DictSegment#fillSegment方法:
private synchronized void fillSegment(char[] charArray , int begin , int length , int enabled){
//獲取字典表中的漢字對(duì)象
Character beginChar = Character.valueOf(charArray[begin]);
Character keyChar = charMap.get(beginChar);
//字典中沒有該字袒炉,則將其添加入字典
if(keyChar == null){
charMap.put(beginChar, beginChar);
keyChar = beginChar;
}
//搜索當(dāng)前節(jié)點(diǎn)的存儲(chǔ)旁理,查詢對(duì)應(yīng)keyChar的keyChar樊零,如果沒有則創(chuàng)建
DictSegment ds = lookforSegment(keyChar , enabled);
if(ds != null){
//處理keyChar對(duì)應(yīng)的segment
if(length > 1){
//詞元還沒有完全加入詞典樹
ds.fillSegment(charArray, begin + 1, length - 1 , enabled);
}else if (length == 1){
//已經(jīng)是詞元的最后一個(gè)char,設(shè)置當(dāng)前節(jié)點(diǎn)狀態(tài)為enabled,
//enabled=1表明一個(gè)完整的詞孽文,enabled=0表示從詞典中屏蔽當(dāng)前詞
ds.nodeState = enabled;
}
}
}
這就是個(gè)遞歸把詞塞入詞典的過程驻襟,通過lookforSegment方法查找當(dāng)前map是存在字,如果沒有的話芋哭,就創(chuàng)建一個(gè)DictSegment沉衣。然后判斷當(dāng)前處理的這個(gè)詞是否處理完了,沒處理完就遞歸處理减牺,處理完了就標(biāo)記這個(gè)字是詞的結(jié)尾豌习。
4.切詞
有了詞典和輸入語(yǔ)句之后,就可以進(jìn)行切詞了拔疚。ik的切詞方式主要有兩種肥隆,一種為smart模式,一種為ik_max_word即非smart模式稚失。以寶劍鋒從磨礪出為例:
非smart模式分詞結(jié)果:寶劍鋒從磨礪出栋艳、寶劍鋒、寶劍句各、從吸占、鋒、從凿宾、磨礪矾屯、出
smart模式下的分詞結(jié)果:寶劍鋒從磨礪出
從非smart的分詞結(jié)果中可以看出,對(duì)于一個(gè)語(yǔ)句可以有很多種切分方式初厚,非smart就是把沒種可能的分詞結(jié)果都給出來(lái)了问拘。而smart模式,就是需要在這幾種分詞模式中惧所,尋找一種認(rèn)為最合理的分詞方式骤坐。
從處理角度說(shuō),設(shè)置了smart模式下愈,就是在進(jìn)行詞切分后纽绍,在進(jìn)行一次分詞的選擇,即通常說(shuō)的消除歧義势似。
ik默認(rèn)實(shí)現(xiàn)了三種分詞器拌夏,分別為CJKSegmenter(中文-日韓文子分詞器)僧著、CN_QuantifierSegmenter(中文數(shù)量詞分詞器)、LetterSegmenter(英文字符及阿拉伯?dāng)?shù)字子分詞器)障簿。
分詞的主要邏輯如下所示盹愚,采用類似懶加載的形式,第一次調(diào)用 segmenter.next()拿分詞結(jié)果的時(shí)候站故,才進(jìn)行分詞皆怕。
while((l = context.getNextLexeme()) == null ){
/*
* 從reader中讀取數(shù)據(jù),填充buffer
* 如果reader是分次讀入buffer的西篓,那么buffer要 進(jìn)行移位處理
* 移位處理上次讀入的但未處理的數(shù)據(jù)
*/
int available = context.fillBuffer(this.input);
if(available <= 0){
//reader已經(jīng)讀完
context.reset();
return null;
}else{
//初始化指針
context.initCursor();
do{
//遍歷子分詞器
for(ISegmenter segmenter : segmenters){
segmenter.analyze(context);
}
//字符緩沖區(qū)接近讀完愈腾,需要讀入新的字符
if(context.needRefillBuffer()){
break;
}
//向前移動(dòng)指針
}while(context.moveCursor());
//重置子分詞器,為下輪循環(huán)進(jìn)行初始化
for(ISegmenter segmenter : segmenters){
segmenter.reset();
}
}
//對(duì)分詞進(jìn)行歧義處理
this.arbitrator.process(context, configuration.isUseSmart());
//將分詞結(jié)果輸出到結(jié)果集岂津,并處理未切分的單個(gè)CJK字符
context.outputToResult();
//記錄本次分詞的緩沖區(qū)位移
context.markBufferOffset();
}
主要的分詞邏輯在do循環(huán)里虱黄,其中segmenters為三個(gè)分詞器。context內(nèi)存儲(chǔ)當(dāng)前輸入語(yǔ)句吮成,每次循環(huán)指針移動(dòng)一個(gè)橱乱,即每次去一個(gè)字符,然后遍歷三個(gè)分詞器粱甫,用每個(gè)分詞器對(duì)當(dāng)前這個(gè)詞進(jìn)行處理泳叠。
首先是LetterSegmenter,英文分詞器比較簡(jiǎn)單魔种,就是把連續(xù)的英文字符析二,或者連續(xù)的數(shù)據(jù)進(jìn)行分詞。
然后是CN_QuantifierSegmenter节预,量詞分詞器叶摄。主要是判斷當(dāng)前的字符是否是數(shù)詞和量詞,會(huì)把連起來(lái)的數(shù)詞和量詞分成一個(gè)詞安拟。
最主要的是CJKSegmenter蛤吓,該分詞器就是基于詞典匹配的。在介紹主要邏輯之前糠赦,需要介紹一個(gè)類Lexeme会傲,該類表示一個(gè)分詞結(jié)果,即一個(gè)詞元
public class Lexeme implements Comparable<Lexeme>{
//lexemeType常量
//未知
public static final int TYPE_UNKNOWN = 0;
//英文
public static final int TYPE_ENGLISH = 1;
//數(shù)字
public static final int TYPE_ARABIC = 2;
//英文數(shù)字混合
public static final int TYPE_LETTER = 3;
//中文詞元
public static final int TYPE_CNWORD = 4;
//中文單字
public static final int TYPE_CNCHAR = 64;
//日韓文字
public static final int TYPE_OTHER_CJK = 8;
//中文數(shù)詞
public static final int TYPE_CNUM = 16;
//中文量詞
public static final int TYPE_COUNT = 32;
//中文數(shù)量詞
public static final int TYPE_CQUAN = 48;
//詞元的起始位移
private int offset;
//詞元的相對(duì)起始位置
private int begin;
//詞元的長(zhǎng)度
private int length;
//詞元文本
private String lexemeText;
//詞元類型
private int lexemeType;
}
該類在切詞過程中比較主要的域是begin和length拙泽,begin表示該詞元在輸入語(yǔ)句的起始位置淌山,length表示該詞元的長(zhǎng)度。
分詞的主要邏輯在CJKSegmenter#analyze方法內(nèi)
public void analyze(AnalyzeContext context) {
if(CharacterUtil.CHAR_USELESS != context.getCurrentCharType()){
//優(yōu)先處理tmpHits中的hit
if(!this.tmpHits.isEmpty()){
//處理詞段隊(duì)列
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()){
//輸出當(dāng)前的詞
Lexeme newLexeme = new Lexeme(context.getBufferOffset() , hit.getBegin() , context.getCursor() - hit.getBegin() + 1 , Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme);
if(!hit.isPrefix()){//不是詞前綴顾瞻,hit不需要繼續(xù)匹配泼疑,移除
this.tmpHits.remove(hit);
}
}else if(hit.isUnmatch()){
//hit不是詞,移除
this.tmpHits.remove(hit);
}
}
}
//********************************* 上半部分
//********************************* 下半部分
//再對(duì)當(dāng)前指針位置的字符進(jìn)行單字匹配
Hit singleCharHit = Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(), context.getCursor(), 1);
if(singleCharHit.isMatch()){//首字成詞
//輸出當(dāng)前的詞
Lexeme newLexeme = new Lexeme(context.getBufferOffset() , context.getCursor() , 1 , Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme);
//同時(shí)也是詞前綴
if(singleCharHit.isPrefix()){
//前綴匹配則放入hit列表
this.tmpHits.add(singleCharHit);
}
}else if(singleCharHit.isPrefix()){//首字為詞前綴
//前綴匹配則放入hit列表
this.tmpHits.add(singleCharHit);
}
}else{
//遇到CHAR_USELESS字符
//清空隊(duì)列
this.tmpHits.clear();
}
//判斷緩沖區(qū)是否已經(jīng)讀完
if(context.isBufferConsumed()){
//清空隊(duì)列
this.tmpHits.clear();
}
//判斷是否鎖定緩沖區(qū)
if(this.tmpHits.size() == 0){
context.unlockBuffer(SEGMENTER_NAME);
}else{
context.lockBuffer(SEGMENTER_NAME);
}
}
我們先看下半部分代碼荷荤,大概思路就是取當(dāng)前這次循環(huán)中的字符退渗,然后判斷是否match移稳。
1)如果match表示:該字符能匹配到詞典中的詞尾(即3小節(jié)描述的紅色點(diǎn))則說(shuō)明當(dāng)前這個(gè)字符可以作為詞的結(jié)尾,那么加上前面緩存的bufferoffset就能推出這個(gè)詞首位未知会油,然后新建一個(gè)Lexeme个粱,放入context內(nèi)。
2)如果為prefix翻翩,則表示當(dāng)前這個(gè)字符不是詞的結(jié)尾都许,但是是詞的前綴,則放入tmpHits內(nèi)体斩,tmpHits表示之前的遍歷過程中梭稚,可以作為詞前綴的字符颖低。
3)根本不在詞典中絮吵,則清空一下臨時(shí)變量。
有了timpHits忱屑,我們?cè)賮?lái)看上半部分代碼:
首先遍歷timpHits內(nèi)的所有字符蹬敲。
1)如果當(dāng)前字符和tipHits內(nèi)的prefix結(jié)合能夠match的,則新建詞元存入context莺戒。
2)如果加上當(dāng)前字符伴嗡,反而不是prefix了,則從timpsHits中移除
經(jīng)過一系列的處理从铲,最終會(huì)在contenxt內(nèi)得到一個(gè)orgLexemes的QuickSortSet瘪校,Lexemes本身是實(shí)現(xiàn)了Comparable接口的,并且按照begin的值從小到達(dá)排序名段,如果begin一樣阱扬,則按照l(shuí)enght從大到小排序。也就是位置靠前伸辟,并且長(zhǎng)度較長(zhǎng)的詞元會(huì)排到前排麻惶。
以寶劍鋒從磨礪出為例,最終得到四個(gè)詞元 0-7信夫,0-3窃蹋,0-2,4-6静稻。即寶劍鋒從磨礪出警没、寶劍鋒、寶劍振湾、磨礪這四個(gè)詞元杀迹。到這里還只是一種切分方式,和最后的結(jié)果:寶劍鋒從磨礪出恰梢、寶劍鋒佛南、寶劍梗掰、從、鋒嗅回、從及穗、磨礪、出绵载。還有些差距埂陆。
5.輸出結(jié)果
從切詞到最后的輸出,中間其實(shí)還有一步娃豹,就是其一處理焚虱,當(dāng)設(shè)置useSmart為true的時(shí)候,會(huì)對(duì)上面的四個(gè)詞元進(jìn)行去除歧義懂版,最后只剩一條詞元0-7鹃栽。這部分邏輯后面再講,假設(shè)沒有設(shè)置useSmart為true的話躯畴,現(xiàn)在還是四條詞元民鼓,然后準(zhǔn)備輸出結(jié)果,看這中間做了什么事蓬抄。主要邏輯在AnalyzeContext#outputToResult方法中丰嘉。
void outputToResult(){
int index = 0;
for( ; index <= this.cursor ;){
//跳過非CJK字符
if(CharacterUtil.CHAR_USELESS == this.charTypes[index]){
index++;
continue;
}
//從pathMap找出對(duì)應(yīng)index位置的LexemePath
LexemePath path = this.pathMap.get(index);
if(path != null){
//輸出LexemePath中的lexeme到results集合
Lexeme l = path.pollFirst();
while(l != null){
this.results.add(l);
//字典中無(wú)單字,但是詞元沖突了嚷缭,切分出相交詞元的前一個(gè)詞元中的單字
int innerIndex = index + 1;
for (; innerIndex < index + l.getLength(); innerIndex++) {
Lexeme innerL = path.peekFirst();
if (innerL != null && innerIndex == innerL.getBegin()) {
this.outputSingleCJK(innerIndex - 1);
}
}
//將index移至lexeme后
index = l.getBegin() + l.getLength();
l = path.pollFirst();
if(l != null){
//輸出path內(nèi)部饮亏,詞元間遺漏的單字
for(;index < l.getBegin();index++){
this.outputSingleCJK(index);
}
}
}
}else{//pathMap中找不到index對(duì)應(yīng)的LexemePath
//單字輸出
this.outputSingleCJK(index);
index++;
}
}
//清空當(dāng)前的Map
this.pathMap.clear();
}
該部分的主要邏輯,就是對(duì)于那些沒有被分詞分到的位置阅爽,用單字輸出的方式輸出詞元路幸。細(xì)心的讀者應(yīng)該已經(jīng)發(fā)現(xiàn),最后的結(jié)果:寶劍鋒從磨礪出优床、寶劍鋒劝赔、寶劍、從胆敞、鋒着帽、從、磨礪移层、出仍翰。有兩個(gè)從字,并且這個(gè)輸入語(yǔ)句里只有一個(gè)從观话。這個(gè)其實(shí)是特定的ik版本才有的bug予借,6.8.1很不幸,存在這個(gè)bug。關(guān)于這個(gè)bug灵迫,后面寫文章再具體分析秦叛。主要有 //字典中無(wú)單字,但是詞元沖突了瀑粥,切分出相交詞元的前一個(gè)詞元中的單字 這段注釋下大代碼引起挣跋,也是一個(gè)ik的pr,不過最新版本已經(jīng)備注掉了狞换。
6.消除歧義
我們現(xiàn)在再回過頭來(lái)看避咆,設(shè)置useSmart會(huì)發(fā)生什么。主要邏輯在IKArbitrator#process 方法中
void process(AnalyzeContext context , boolean useSmart){
QuickSortSet orgLexemes = context.getOrgLexemes();
Lexeme orgLexeme = orgLexemes.pollFirst();
LexemePath crossPath = new LexemePath();
while(orgLexeme != null){
if(!crossPath.addCrossLexeme(orgLexeme)){
//找到與crossPath不相交的下一個(gè)crossPath
if(crossPath.size() == 1 || !useSmart){
//crossPath沒有歧義 或者 不做歧義處理
//直接輸出當(dāng)前crossPath
context.addLexemePath(crossPath);
}else{
//對(duì)當(dāng)前的crossPath進(jìn)行歧義處理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//輸出歧義處理結(jié)果judgeResult
context.addLexemePath(judgeResult);
}
//把orgLexeme加入新的crossPath中
crossPath = new LexemePath();
crossPath.addCrossLexeme(orgLexeme);
}
orgLexeme = orgLexemes.pollFirst();
}
//處理最后的path
if(crossPath.size() == 1 || !useSmart){
//crossPath沒有歧義 或者 不做歧義處理
//直接輸出當(dāng)前crossPath
context.addLexemePath(crossPath);
}else{
//對(duì)當(dāng)前的crossPath進(jìn)行歧義處理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//輸出歧義處理結(jié)果judgeResult
context.addLexemePath(judgeResult);
}
}
當(dāng)useSmart為true時(shí)修噪,進(jìn)行歧義處理查库,如果為false,則不處理黄琼,直接輸出樊销。
將詞元轉(zhuǎn)換成詞元路徑 LexemePath,LexemePath實(shí)現(xiàn)了Comparable接口适荣,LexemePath內(nèi)部的詞元不想交现柠,并且根據(jù)排序規(guī)則進(jìn)行排序院领,規(guī)則如下
public int compareTo(LexemePath o) {
//比較有效文本長(zhǎng)度
if(this.payloadLength > o.payloadLength){
return -1;
}else if(this.payloadLength < o.payloadLength){
return 1;
}else{
//比較詞元個(gè)數(shù)弛矛,越少越好
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 {
//根據(jù)統(tǒng)計(jì)學(xué)結(jié)論,逆向切分概率高于正向切分比然,因此位置越靠后的優(yōu)先
if(this.pathEnd > o.pathEnd){
return -1;
}else if(pathEnd < o.pathEnd){
return 1;
}else{
//詞長(zhǎng)越平均越好
if(this.getXWeight() > o.getXWeight()){
return -1;
}else if(this.getXWeight() < o.getXWeight()){
return 1;
}else {
//詞元位置權(quán)重比較
if(this.getPWeight() > o.getPWeight()){
return -1;
}else if(this.getPWeight() < o.getPWeight()){
return 1;
}
}
}
}
}
}
return 0;
}
根據(jù)這個(gè)規(guī)則丈氓,對(duì)詞元鏈路進(jìn)行排序,選擇排序第一的詞元鏈路强法,即最后消除歧義的那個(gè)分詞方式万俗。
7.總結(jié)
IK分詞器雖然使用上比較簡(jiǎn)單,但是了解它內(nèi)部的原理的思想很重要饮怯,能夠幫助分析和定位問題闰歪。
更多精彩內(nèi)容,請(qǐng)關(guān)注公眾號(hào)