简介
IK分词全名为IK Analyzer
IK分词是一款国人作者林良益开发的相对简单的中文分词器,IKAnalyzer 是一个开源的,基于java语言开发的轻量级的中文分词工具包。从2006年12月推出1.0版开始,IKAnalyzer已经推出 了3个大版本。最初,它是以开源项目 Lucene为应用主体的,结合词典分词和文法分析算法的中文分词组件。新版本的IKAnalyzer3.0则发展为 面向Java的公用分词组件,独立于Lucene项目,同时提供了对Lucene的默认优化实现。
IK 这个名字是来源于暗黑破坏神2这款游戏,它是游戏中武器装备的名字。刚好我在做这个分词器的时候,我也在玩这款游戏,而且刚好打到这个装备,很开心。我们想想 Java 也是开发人员在开发的过程中正好在喝咖啡,所以就叫了 Java 这个名字。所以那时候我也在想,给这个分词器命名的话,我就把它命名为 Immortal King,中文名叫不朽之王,是那个装备的名称。这个名字就是这么来的。
分析结构
IKAnalzyerDemo.java
//构建IK分词器,使用smart分词模式
Analyzer analyzer = new IKAnalyzer(true);
//获取Lucene的TokenStream对象
TokenStream ts = null;
try {
ts= analyzer.tokenStream("myfield",newStringReader("这是一个中文分词的例子,你可以直接运行它!IKAnalyer can analysis english text too"));
//获取词元位置属性
OffsetAttribute offset = ts.addAttribute(OffsetAttribute.class);
//获取词元文本属性
CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
//获取词元文本属性
TypeAttribute type = ts.addAttribute(TypeAttribute.class);
//重置TokenStream(重置StringReader)
ts.reset();
//迭代获取分词结果
while (ts.incrementToken()) {
System.out.println(offset.startOffset()+" - "+ offset.endOffset() +" : " + term.toString() + " | " + type.type());
}
//关闭TokenStream(关闭StringReader)
ts.end(); // Performend-of-stream operations, e.g. set the final offset.
}catch(IOExceptione) {
e.printStackTrace();
}finally{
//释放TokenStream的所有资源
if(ts !=null){
try {
ts.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行结果:
Load extended dictionary:ext.dic
Load stopwords dictionary:stopword.dic
0 - 2 : 这是 | CN_WORD
2 - 4 : 一个 | CN_WORD
4 - 6 : 中文 | CN_WORD
6 - 8 : 分词 | CN_WORD
8 - 9 : 的 | CN_WORD
9 - 11 : 例子 | CN_WORD
12 - 13 : 你 | CN_WORD
13 - 15 : 可以 | CN_WORD
15 - 17 : 直接 | CN_WORD
17 - 19 : 运行 | CN_WORD
19 - 20 : 它 | CN_CHAR
21 - 30 : ikanalyer | ENGLISH
31 - 34 : can | ENGLISH
35 - 43 : analysis | ENGLISH
44 - 51 : english | ENGLISH
52 - 56 : text | ENGLISH
57 - 60 : too | ENGLISH
这里可以看出来大体的一个流程
- 构件一个分词器,策略是smart模式
- 加载词典(主词典、扩展词典、量词词典、停止词词典)
- 分词解析
- 输出词
下面介绍下最核心的分词部分
/**
* 分词,获取下一个词元
*
* @return Lexeme 词元对象
*/
public synchronized Lexeme next() throws IOException {
Lexeme l;
while ((l = context.getNextLexeme()) == null) {
/*
* 从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.outputToResult();
//记录本次分词的缓冲区位移
context.markBufferOffset();
}
return l;
}
intavailable = context.fillBuffer(this.input);
此处是用来读取待处理的文本信息
遍历分词器,进行分词处理,这里是最核心的流程之一,将待匹配文本生成分词候选集。
总共有三种分词器CJKSegmenter(中文、日韩分词器)、CN_QuantifierSegment(数量词分词器)、LetterSegment(字母数字分词器),每种分词器的分词方法是独立的,各自生成自己的分词结果,放到分词候选集里
for(ISegmenter segmenter : segmenters){
segmenter.analyze(context);
}
//对分词进行歧义处理
this.arbitrator.process(context,this.cfg.useSmart());
生成分词候选集之后,进行歧义处理,歧义处理方法区分智能和非智能,也就是在初始化IKSegment时传递的第二个参数IKSegmenter(Readerinput, boolean useSmart)。
其功能是根据分词候选集和歧义处理策略,生成最后的分词结果,具体策略后面介绍
//记录本次分词的缓冲区位移
context.markBufferOffset();
分词器
IK里的分词器主要是三个分词器:CJKSegmenter(中文分词),CN_QuantifierSegmenter(数量词分词),LetterSegmenter(字母分词)。这三个分词器都继承了ISegmenter接口,思路相差不大,其中采用的结构也比较容易理解,采用字典树(CJK使用)或其他简单数据结构(CN_QuantifierSegmenter和LetterSegmenter)匹配文本中的当前字符,将匹配到的字符加入到分词候选集
其中CJKSegmenter中的核心代码analyze方法的代码如下:
public void analyze(AnalyzeContext context) {
if(CharacterUtil.CHAR_USELESS != context.getCurrentCharType()){
//优先处理tmpHits中的hit
if(!this.tmpHits.isEmpty()){
//处理词段队列
Hit[] tmpArray = this.tmpHits.toArray(new Hit[0]);
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);
}
}
CharacterUtil.CHAR_USELESS!= context.getCurrentCharType()
context.getCurrentCharType()此方法是用来判断字符的类型,包括字符的中文、字母、数字等。判断是方法是通过移动文本的指针计算出来。
具体方法在AnalyzeContext.java中
/**
* 初始化buff指针,处理第一个字符
*/
void initCursor() {
this.cursor = 0;
this.segmentBuff[this.cursor] = CharacterUtil.regularize(this.segmentBuff[this.cursor]);
this.charTypes[this.cursor] = CharacterUtil.identifyCharType(this.segmentBuff[this.cursor]);
}
/**
* 指针+1
* 成功返回 true; 指针已经到了buff尾部,不能前进,返回false
* 并处理当前字符
*/
boolean moveCursor() {
if (this.cursor < this.available - 1) {
this.cursor++;
this.segmentBuff[this.cursor] = CharacterUtil.regularize(this.segmentBuff[this.cursor]);
this.charTypes[this.cursor] = CharacterUtil.identifyCharType(this.segmentBuff[this.cursor]);
return true;
} else {
return false;
}
}
通过移动指针然后通过CharacterUtil.identifyCharType方法来进行判断字符类型
中间有个全角转半角(全角和半角的区别在日常输入法中可以看到)的操作
/**
* 识别字符类型
* @param input 需要识别的字符
* @return int CharacterUtil定义的字符类型常量
*/
static int identifyCharType(char input){
if(input >= '0' && input <= '9'){
return CHAR_ARABIC;
}else if((input >= 'a' && input <= 'z')
|| (input >= 'A' && input <= 'Z')){
return CHAR_ENGLISH;
}else {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(input);
if(ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| ub == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A){
//目前已知的中文字符UTF-8集合
return CHAR_CHINESE;
}else if(ub == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS //全角数字字符和日韩字符
//韩文字符集
|| ub == Character.UnicodeBlock.HANGUL_SYLLABLES
|| ub == Character.UnicodeBlock.HANGUL_JAMO
|| ub == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO
//日文字符集
|| ub == Character.UnicodeBlock.HIRAGANA //平假名
|| ub == Character.UnicodeBlock.KATAKANA //片假名
|| ub == Character.UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS){
return CHAR_OTHER_CJK;
}
}
//其他的不做处理的字符
return CHAR_USELESS;
}
/**
* 进行字符规格化(全角转半角,大写转小写处理)
* 半角字符是从33开始到126结束
* 与半角字符对应的全角字符是从65281开始到65374结束
* 其中半角的空格是32.对应的全角空格是12288
* 半角和全角的关系很明显,除空格外的字符偏移量是65248(65281-33 = 65248)
* @param input 需要转换的字符
* @return char
*/
static char regularize(char input){
if (input == 12288) {//处理空格为空字符
input = (char) 32;
}else if (input > 65280 && input < 65375) {
input = (char) (input - 65248);
}else if (input >= 'A' && input <= 'Z') {
input += 32;
}
return input;
}
继续往下讲
CharacterUtil.CHAR_USELESS指的是其他类型,说白了就是没识别出来,那么就跳过此步。
//再对当前指针位置的字符进行单字匹配
Hit singleCharHit= Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(),context.getCursor(),1);
这里进行单字匹配,其中matchInMainDict就是将待匹配的字符在由main2.12.dic词典生成的词典树中进行匹配。比较的方式是很典型的字典树,字典树中的每个节点用DictSegmenter表示,每个节点的下一级节点分支使用Array或者Map来表示,dictSegmenter类表示如下:
//公用字典表,存储汉字
private static final Map<Character, Character> charMap = new HashMap<>(16, 0.95f);
//数组大小上限
private static final int ARRAY_LENGTH_LIMIT = 3;
//Map存储结构
private Map<Character, DictSegment> childrenMap;
//数组方式存储结构
private DictSegment[] childrenArray;
//当前节点上存储的字符
private Character nodeChar;
//当前节点存储的Segment数目
//storeSize <=ARRAY_LENGTH_LIMIT ,使用数组存储, storeSize >ARRAY_LENGTH_LIMIT ,则使用Map存储
private int storeSize = 0;
//当前DictSegment状态 ,默认 0 , 1表示从根节点到当前节点的路径表示一个词
private int nodeState = 0;
回到analyze继续讲解
if(singleCharHit.isMatch()){//首字成词
//输出当前的词
LexemenewLexeme = new Lexeme(context.getBufferOffset() , context.getCursor() , 1 , Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme);
//同时也是词前缀
if(singleCharHit.isPrefix()){
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
}
singleCharHit.isMatch()这句代码表示当前字符已经匹配,并且匹配到词典中某个单个字符的词的,简单点说,就是命中了词典中的某个单字词
addLexeme,将匹配到的词加入到分词候选集中
如果匹配到的词是其他词的前缀,后面还需继续匹配,将其加入到tmpHits列表中
很明显,tmpHits不为空,说明上面的代码匹配到了某个词的前缀,这里的功能就是将之前已经前缀匹配的字符取出,判断其和当前字符组合起来,是否还能继续匹配到词典中的词或词前缀,如果匹配到词尾,加入分词候选集。如果仍为前缀,下轮继续匹配注意这里匹配出的都是两字以上的词,单字的词已经在上面的代码中匹配了如果没法继续向下匹配了,从tmpHits中移除该字符
分词的过程总结来就是一个个字符匹配,匹配完一个字符后,指针向后移动,然后继续调用analyze的函数来继续处理字符。最后将所有匹配到的分词结果放到分词候选集中。
另外的两个切词中:
CN_QuantifierSegmenter的词主要来自于quantifier.dic这个内置的词典还有代码中嵌入的中文数词
private static String Chn_Num =“一二两三四五六七八九十零壹贰叁肆伍陆柒捌玖拾百千万亿拾佰仟萬億兆卅廿”;//Cnum
LetterSegmenter主要处理字母、阿拉伯数字还有字母跟阿拉伯数字数字的组合
public void analyze(AnalyzeContext context) {
boolean bufferLockFlag;
//处理英文字母
bufferLockFlag = this.processEnglishLetter(context);
//处理阿拉伯字母
bufferLockFlag = this.processArabicLetter(context) || bufferLockFlag;
//处理混合字母(这个要放最后处理,可以通过QuickSortSet排除重复)
bufferLockFlag = this.processMixLetter(context) || bufferLockFlag;
//判断是否锁定缓冲区
if (bufferLockFlag) {
context.lockBuffer(SEGMENTER_NAME);
} else {
//对缓冲区解锁
context.unlockBuffer(SEGMENTER_NAME);
}
}
处理的方式就是对连续的并且类型相同的字符继续处理,比如处理英文的时候会匹配出连续的字母串来切分为一个词。
智能分词(分词歧义处理)
分词的歧义处理是IK分词的一个重要的核心模块,主要使用组合遍历的方式进行处理。从子分词器中取出不相交的分词集合,例如分词结果为abcd(abcd代表词),abcd是按其在文本中出现的位置排序的,从前到后。假如a与b相交,b与c相交,c与d不相交,则将分词结果切成abc和d两个块分别处理
当在分词的时候使用的是智能分词,那么便从相交的块中选出最优的结果,这个由judge方法来进行处理
/**
* 分词歧义处理
*
* @param context 内容
* @param useSmart 是否细粒度分词
*/
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不相交的下一个crossPath
//非智能歧义处理,即使相交,也直接输出分词结果
if (crossPath.size() == 1 || !useSmart) {
//crossPath没有歧义 或者 不做歧义处理
//直接输出当前crossPath
context.addLexemePath(crossPath);
} else {
//出现一个不相交的分词,将之前相交的词开始歧义处理
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell);
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
//只要出现不相交的词,即进行歧义处理,选出当前最优结果,然后继续处理后面的词
//把orgLexeme加入新的crossPath中
crossPath = new LexemePath();
crossPath.addCrossLexeme(orgLexeme);
}
orgLexeme = orgLexemes.pollFirst();
}
//处理最后的path
if (crossPath.size() == 1 || !useSmart) {
//crossPath没有歧义 或者 不做歧义处理
//直接输出当前crossPath
context.addLexemePath(crossPath);
} else {
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell);
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
}
下面是judge方法的代码:
/**
* 歧义识别
*
* @param lexemeCell 歧义路径链表头
* @return LexemePath 候选结果路径
*/
private LexemePath judge(QuickSortSet.Cell lexemeCell) {
//候选路径集合
TreeSet<LexemePath> pathOptions = new TreeSet<>();
//候选结果路径
LexemePath option = new LexemePath();
//对crossPath进行一次遍历,同时返回本次遍历中有冲突的Lexeme栈
Stack<QuickSortSet.Cell> lexemeStack = this.forwardPath(lexemeCell, option);
//当前词元链并非最理想的,加入候选路径集合
pathOptions.add(option.copy());
//这种处理方式应该不是最优的,只是采用贪心的方法获取近似最优方案,并没有遍历所有的可能集合
//每次从一个歧义位置开始,贪心的获取一种分词方案
//存在歧义词,处理
QuickSortSet.Cell c;
while (!lexemeStack.isEmpty()) {
c = lexemeStack.pop();
//回滚词元链
this.backPath(c.getLexeme(), option);
//从歧义词位置开始,递归,生成可选方案
this.forwardPath(c, option);
pathOptions.add(option.copy());
}
//跳转到LexemePath.java中的compareTo()接口查看最优方案选择策略
//返回集合中的最优方案
return pathOptions.first();
}
//候选路径集合
TreeSet<LexemePath>pathOptions =new TreeSet<LexemePath>();
这个是用来保存候选的分词结果集,并对分词的结果集进行排序。
分词结果的排序方法(此方法在lexemePath.java文件中的compareto)是:比较有效文本长度,有效文本长度是指所有分词结果最靠后的一个词距离最靠前的一个词的长度(这里的靠前和靠后是指词在待匹配文本中的位置),词元个数,即分出来的词的个数,越少越好。路径跨度,指所有词的长度的加和。逆向切分、词元长度、位置权重
这个结构在IK分词的消歧中至关重要
Forwardpath方法是将相交的词块传入,来进行歧义处理。使用贪心方法选择不相交的分词结果存放到分词结果候选集option中。然后将存在歧义的词放到conflictstack中。
**
* 向前遍历,添加词元,构造一个无歧义词元组合
*/
private Stack<QuickSortSet.Cell> forwardPath(QuickSortSet.Cell lexemeCell, LexemePath option) {
//发生冲突的Lexeme栈
Stack<QuickSortSet.Cell> conflictStack = new Stack<>();
QuickSortSet.Cell c = lexemeCell;
//迭代遍历Lexeme链表
while (c != null && c.getLexeme() != null) {
if (!option.addNotCrossLexeme(c.getLexeme())) {
//词元交叉,添加失败则加入lexemeStack栈
conflictStack.push(c);
}
c = c.getNext();
}
return conflictStack;
}
当得到conflictstack后,就是对分词结果进行组合遍历处理,首先从conflictstack中选出一个歧义词c,从option结尾回滚option词元链,直到能放下词c,从词c的位置执行forwardPath,生成一个可选分词结果集, 可以看出,该方法没有遍历所有可能的集合,只是从当前替换歧义词的位置贪心的生成其中一种可选方案,只是一种近似最优的选取结果。分词的冲突不会太复杂,这样的选取结果可以接受
消除歧义的具体方法就是: IKAnalyzer是在细粒度切分后的结果中进行消歧,首先是扫描未经过歧义消除的词源,遇到有歧义的词就放到LexemePath数据结构中,当遇到没有歧义的词后就出来LexemePath这个数据结构,对其进行消歧,消歧后的LexemePath放到了 Map<Integer ,LexemePath>数据结构中,最后对这个数据结构进行合并把结果放到LinkedList数据结构中,至此完成分词工作
不过这个歧义词的消除比较简单,还是有些问题,
比如中华人民共和国,切分出来是:中华人民共和国 这种情况下搜索中华、共和国都不会有结果
共和国,切分出来是:共和国
共和国人 切分出来是:共和、国人 在这种情况下搜索共和国就出不来结果了
如果我写的不够清楚的话,可以对“中华人民共和国”这个文本进行调试,记住option每次结束的时候都是一个候选的分词集合,里面结果不冲突,还有就是pathOptions中不是一个结果集,是有多个候选结果集,每个候选结果中的词块都是不相交的。通过pathOptions中的排序方法compareTo(LexemePath o),对结果进行排序,排序的原则是:
1、 比较有效文本长度
2、 比较词元个数,越少越好
3、 路径跨度越大越好
4、 根据统计学结论,逆向切分概率高于正向切分,因此位置越靠后的优先
5、 词长越平均越好
6、 词元位置权重比较
最后处理完毕后使用pathOptions.first()方法输出最优的分词结果。
主要使用的就是贪心算法获取局部最优解,然后继续处理来获取最终的结果。
停用词处理
对于停用词以及未切分的词的处理方法:过滤掉CHAR_USELESS字符,包括标点以及无法识别的字符,pathMap中存储的是lexemePath集合,找出相邻的lexemePath,把它们之间未切分的字符逐字符输出。outputToResult()
//对分词进行歧义处理
this.arbitrator.process(context,this.cfg.useSmart());
//将分词结果输出到结果集,并处理未切分的单个CJK字符
context.outputToResult();
//记录本次分词的缓冲区位移
context.markBufferOffset();
/**
* 推送分词结果到结果集合
* 1.从buff头部遍历到this.cursor已处理位置
* 2.将map中存在的分词结果推入results
* 3.将map中不存在的CJDK字符以单字方式推入results
*/
void outputToResult(){
int index = 0;
for( ; index <=this.cursor ;){
//跳过非CJK字符
if(CharacterUtil.CHAR_USELESS ==this.charTypes[index]){
index++;
continue;
}
//从pathMap找出对应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);
//将index移至lexeme后
index= l.getBegin() + l.getLength();
l= path.pollFirst();
if(l !=null){
//输出path内部,词元间遗漏的单字
for(;index <l.getBegin();index++){
this.outputSingleCJK(index);
}
}
}
}else{//pathMap中找不到index对应的LexemePath
//单字输出
this.outputSingleCJK(index);
index++;
}
}
//清空当前的Map
this.pathMap.clear();
}
在词代码中有个方法 outputStringCJK(index)便是将没有切分的单字进行输出。
最后看看对停用词的处理
/**
* 返回lexeme
*
* 同时处理合并
* @return
*/
Lexeme getNextLexeme(){
//从结果集取出,并移除第一个Lexme
Lexeme result = this.results.pollFirst();
while(result !=null){
//数量词合并
this.compound(result);
if(Dictionary.getSingleton().isStopWord(this.segmentBuff , result.getBegin() , result.getLength())){
//是停止词继续取列表的下一个
result = this.results.pollFirst();
}else{
//不是停止词,生成lexeme的词元文本,输出
result.setLexemeText(String.valueOf(segmentBuff , result.getBegin() ,result.getLength()));
break;
}
}
return result;
}
在此方法中有个isStopWord ,这个方法是用来判断这个词是否为停用词,如果是停用词便继续取列表的下一个再来进行处理。这个过程中一边切词一边判断是否为停用词,可以通过调试看的更清楚些。
量词处理
/**
* 分词
*/
public void analyze(AnalyzeContext context) {
//处理中文数词
this.processCNumber(context);
//处理中文量词
this.processCount(context);
//判断是否锁定缓冲区
if(this.nStart == -1 && this.nEnd == -1 && countHits.isEmpty()){
//对缓冲区解锁
context.unlockBuffer(SEGMENTER_NAME);
}else{
context.lockBuffer(SEGMENTER_NAME);
}
}
CN_QuantifierSegmenter的词典来源两个地方:1.quantifier.dic文件,包含量词 2.数词直接写到ChnNumberChars类中了,内容如下:“一二两三四五六七八九十零壹贰叁肆伍陆柒捌玖拾百千万亿拾佰仟萬億兆卅廿”
处理的基本思路就是匹配连续的相同类型字符,直到出现不同类型字符为止,切出一个词
ik配置及在Solr中的配置使用
在solr中配置使用IK很简单
-
下载最新的Ik2012中文分词器。
-
解压IK Analyzer 2012FF_hf1.zip,获得IK Analyzer 2012FF_hf1.
将该目录下的IKAnalyzer.cfg.xml,IKAnalyzer2012FF_u1.jar,stopword.dic
放到安装TOMCAT_HOME/webapps/solr/WEB-INF/classes目录下(没有就创建classes文件夹。)
- 修改/solr_home/collection1/conf/中的schema.xml,在中增加如下内容:
<analyzer type="index" isMaxWordLength="false" class="org.wltea.analyzer.lucene.IKAnalyzer"/>
<analyzer type="query" isMaxWordLength="true" class="org.wltea.analyzer.lucene.IKAnalyzer"/>
同时修改filed使filed引用text_ik.这样才能使用IK分词器。
<fieldname=“name” type="text_ik"indexed=“true” stored=“true”/>
下面讲解下IK本身的配置文件IKAnalyzer.cfg.xml,其结构如下:
<?xmlversion="1.0"encoding="UTF-8"?>IK Analyzer 扩展配置
<entrykey=“ext_dict”>ext/ext.dic;
<entrykey=“ext_stopwords”>stopword.dic;
上面的目录结构很清晰 ext_dict这个属性就是用来配置扩展的字典的,可以添加n多个,比如添加其他的扩展的词库可以如下方式
<entrykey=“ext_dict”>ext/ext.dic;ext/net.dic;ext/百科书名.dic;ext/常用餐饮词汇.dic;ext/常用计算机技术词库.dic;ext/常用人名.dic;ext/超市商品名称产地及药房商品名称产地.dic;ext/成语俗语.dic;ext/电力词汇大全.dic;ext/电子商务专用词汇.dic;ext/动物词语大全.dic;ext/环保词汇.dic;ext/魔兽世界.dic;ext/摄影大全.dic;ext/四字成语大全.dic;ext/淘宝专用词汇.dic;ext/网络流行新词.dic;ext/冶金词汇大全.dic;ext/医学词汇大全.dic;ext/植物大全词典.dic;
通过这种方式可以一直扩展下去,不过真正加载的时候这些都会通过遍历的方式加载到一个词典下面。
ext_stopwords,这里用来加入自己的停用词词库,我在配置文件中加入的是哈工大的停用词词库,效果还不错。
改造
ik分词器有两种模式,粗粒度(智能)和细粒度(非智能)。在搜索引擎中应用时,索引端分词应考虑最大场景,适用于细粒度分词,查询端应精准匹配适用于粗粒度分词。但只使用ik分词器的两种模式分出来的词有个问题,细粒度分词不包含粗粒度分词,细粒度分词调用了judge的方法,粗粒度分词直接输出。导致很多搜索词匹配不到索引分词,搜索不出结果,造成用户体验差。
扩展词典的地址只在resource/ik_analyzer.xml中定义,在多种业务core情况下,定制化扩展词典地址不能自定义
Dictionary类是单例的,多业务core共用一套词典,场景不同时急需定制化词典
/**
* 歧义识别
*
* @param lexemeCell 歧义路径链表头
*/
private LexemePath judge(QuickSortSet.Cell lexemeCell) {
针对上面三种case场景。下面是改造的过程
-
添加CoreConfig类,定制业务维度的主词典、扩展词典、停止词词典、量词词典
-
添加ConfigurationUtil工具栏,解析xml结构
-
DefaultConfig类中添加
private Set<String> extDictFiles = new HashSet<>(3);
-
改造IKAnalyzer.cfg.xml结构为
<analyzer name="ik"> <core name="tss"> <!-- 主词典 name为文件路径--> <mainDict name="/opt/files/search/public/dicts/ikanalyzer/tss/main2012.dic"/> <!-- 扩展词典 支持多个扩展词典用;隔开--> <extDict name="/opt/files/search/public/dicts/ikanalyzer/tss/ext.dic;/opt/files/search/public/dicts/ikanalyzer/tss/ext2.dic"/> <!-- 停止词典 支持多个扩展词典用;隔开--> <stopDict name="/opt/files/search/public/dicts/ikanalyzer/tss/stopword.dic"/> <!-- 量词词典 --> <quantifierDict name="/opt/files/search/public/dicts/ikanalyzer/tss/quantifier.dic"/> </core> <core name="tss2"> <mainDict name="/opt/files/search/public/dicts/ikanalyzer/tss2/main2012.dic"/> <extDict name="/opt/files/search/public/dicts/ikanalyzer/tss2/ext.dic"/> <stopDict name="/opt/files/search/public/dicts/ikanalyzer/tss2/stopword.dic"/> <quantifierDict name="/opt/files/search/public/dicts/ikanalyzer/tss2/quantifier.dic"/> </core> </analyzer>
通过这个可以看出每个业务core都有定制的词典
-
AnalyzeContext类中添加最大分词结果集
//最终分词结果集 private HashSet<String> results2;//LexemePath位置索引表 private Map<Integer, LexemePath> pathMap2;
将细粒度分词结果和粗粒度分词结果添加至result2中
-
IKAnalyzer类中添加useMaxWord最大分词模型开关,IkAnalyzerFactory类中添加useMaxWord,ext_dict,core属性赋值,可以在solr的schema.xml中自定义,其中ext_dict支持多个,用英文逗号,隔开
<fieldType name="text_ik" class="solr.TextField" omitNorms="true" positionIncrementGap="100"> <analyzer type="index"> <tokenizer useSmart="false" useMaxWord="true" ext_dict="/opt/files/search/public/dicts/extDcit.txt,/opt/files/search/public/dicts/extDcit2.txt" core="sss" class="com.suning.search.epanalyzer.lucene.IKAnalyzerFactory"/> <filter class="com.shentong.search.analyzers.PinyinTransformTokenFilterFactory" minTermLenght="2"/> <filter class="com.shentong.search.analyzers.PinyinNGramTokenFilterFactory" minGram="1" maxGram="20"/> </analyzer> <analyzer type="query"> <tokenizer useSmart="true" ext_dict="/opt/files/search/public/dicts/extDcit.txt,/opt/files/search/public/dicts/extDcit2.txt" core="sss" class="com.suning.search.epanalyzer.lucene.IKAnalyzerFactory"/> <filter class="solr.SynonymFilterFactory" synonyms="/opt/files/search/public/dicts/synonyms/synonyms.txt" ignoreCase="true" expand="true"/> </analyzer> </fieldType>
测试
- 性能测试
分词模式 | 建索引耗时 | 索引条数 |
---|---|---|
useSmart=false,useMaxWord=true 细粒度最大分词模式 | 61355ms | 100000 |
useSmart=false,useMaxWord=false 细粒度模式 | 43208ms | 100000 |
useSmart=true 粗粒度模式 | 35991ms | 100000 |
这里可以看出粗粒度模式建索引最快,但不适合建索引场景。
细粒度模式10w条数据比粗粒度模式消耗多8s,在建索引机器和查询机器分隔开的架构中,这种性能的差距微乎其微。即使细粒度最大分词模式也仅比细粒度模式消耗多18s,是完全可以接受的。
-
功能测试
使用epbs本地宝验证,本地epbs内有两个solr core(bgd,extdata)
bgd的schema中分词器定义为
<fieldType name="text_ik" class="solr.TextField" omitNorms="true" positionIncrementGap="100"> <analyzer type="index"> <tokenizer useSmart="true" core="bgd" ext_dict="" class="com.suning.search.analyzer.lucene.EPAnalyzerFactory"/> <filter class="com.shentong.search.analyzers.PinyinTransformTokenFilterFactory" minTermLenght="2"/> <filter class="com.shentong.search.analyzers.PinyinNGramTokenFilterFactory" minGram="1" maxGram="20"/> </analyzer> <analyzer type="query"> <tokenizer useSmart="true" core="bgd" ext_dict="" class="com.suning.search.analyzer.lucene.EPAnalyzerFactory"/> <filter class="solr.SynonymFilterFactory" synonyms="/opt/files/search/public/dicts/synonyms/synonyms.txt" ignoreCase="true" expand="true"/> </analyzer> </fieldType>
extdata的schema分词器定义为
<fieldType name="text_ik" class="solr.TextField" omitNorms="true" positionIncrementGap="100"> <analyzer type="index"> <tokenizer useSmart="false" useMaxWord="true" ext_dict="" class="com.suning.search.analyzer.lucene.EPAnalyzerFactory"/> <filter class="com.shentong.search.analyzers.PinyinTransformTokenFilterFactory" minTermLenght="2"/> <filter class="com.shentong.search.analyzers.PinyinNGramTokenFilterFactory" minGram="1" maxGram="20"/> </analyzer> <analyzer type="query"> <tokenizer useSmart="true" ext_dict="" class="com.suning.search.analyzer.lucene.EPAnalyzerFactory"/> <filter class="solr.SynonymFilterFactory" synonyms="/opt/files/search/public/dicts/synonyms/synonyms.txt" ignoreCase="true" expand="true"/> </analyzer> </fieldType>
两个core的分词器中属性core不一致,对应的词典位置也不一致
<analyzer name="ik"> <core name="bgd"> <!-- 主词典 name为文件路径--> <mainDict name="/opt/files/search/public/dicts/ikanalyzer/bgd/main2012.dic"/> <!-- 扩展词典 支持多个扩展词典用;隔开--> <extDict name="/opt/files/search/public/dicts/ikanalyzer/bgd/ext.dic;/opt/files/search/public/dicts/ikanalyzer/bgd/ext2.dic"/> <!-- 停止词典 支持多个扩展词典用;隔开--> <stopDict name="/opt/files/search/public/dicts/ikanalyzer/bgd/stopword.dic"/> <!-- 量词词典 --> <quantifierDict name="/opt/files/search/public/dicts/ikanalyzer/bgd/quantifier.dic"/> </core> <core name="extdata"> <mainDict name="/opt/files/search/public/dicts/ikanalyzer/extdata/main2012.dic"/> <extDict name="/opt/files/search/public/dicts/ikanalyzer/extdata/ext.dic"/> <stopDict name="/opt/files/search/public/dicts/ikanalyzer/extdata/stopword.dic"/> <quantifierDict name="/opt/files/search/public/dicts/ikanalyzer/extdata/quantifier.dic"/> </core> <core name=""> <mainDict name="/opt/files/search/public/dicts/ikanalyzer/main2012.dic"/> <extDict name="/opt/files/search/public/dicts/ikanalyzer/ext.dic"/> <stopDict name="/opt/files/search/public/dicts/ikanalyzer/stopword.dic"/> <quantifierDict name="/opt/files/search/public/dicts/ikanalyzer/quantifier.dic"/> </core> </analyzer>
修改ext.dic开始测试
调用分词接口查看分词结果
将bgd业务的ext.dic 中添加“阿珂”词,并回调重置词典接口“/epbs/admin/refreshDicts.json?moduleDict=extDict&coreCode=bgd” 再测试为
将extdata业务的ext.dic 中去除“阿珂”词,并回调重置词典接口“/epbs/admin/refreshDicts.json?moduleDict=extDict&coreCode=extdata” 再测试为
通过测试可以看出两个业务的扩展词典已经区分开来,实现了一个服务中多个业务分词定制化
ik源码学习自:https://blog.csdn.net/a925907195/article/details/41826363?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-2.channel_param&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-2.channel_param