废话不多说直接上代码 WordBreakFilter.java:
import com.ibm.icu.text.BreakIterator;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.*;
import test.CMN;
import java.io.IOException;
public final class WordBreakFilter extends TokenFilter {
private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
//private final TypeAttribute typeAtt = addAttribute(TypeAttribute.class);
private final OffsetAttribute offsetAtt = addAttribute(OffsetAttribute.class);
private final PositionIncrementAttribute posIncAtt = addAttribute(PositionIncrementAttribute.class);
private final PositionLengthAttribute posLengthAtt = addAttribute(PositionLengthAttribute.class);
public Analyzer.TokenStreamComponents component;
public WordBreakFilter(TokenStream in) {
super(in);
}
String text;
BreakIterator breakIterator = BreakIterator.getWordInstance();
int start = 0;
@Override
public boolean incrementToken() throws IOException {
//CMN.Log("incrementToken", input);
if(component.text!=null) {
breakIterator.setText(text = component.text);
component.text = null;
start = 0;
}
int end = breakIterator.next();
while (end != java.text.BreakIterator.DONE) {
String term = text.substring(start, end).trim();
int len = term.length();
boolean deBigram = true;
if (len>1 && len<termAtt.buffer().length) {
//CMN.Log("term::", term);
termAtt.setLength(len);
char c;
for (int i = 0; i < len; i++) {
c = termAtt.buffer()[i] = term.charAt(i);
if (deBigram
&& isBigram(c)
) {
deBigram = false;
}
}
}
if (!deBigram) {
offsetAtt.setOffset(start, end);
posIncAtt.setPositionIncrement(1); // 有点难
//posLengthAtt.setPositionLength(len);
start = end;
return true;
}
start = end;
end = breakIterator.next();
}
return false;
//return input.incrementToken();
}
/** 判断是否是合写语言,即中文那样不用空格断词的语言 */
private boolean isBigram(char c) {
final String block = Character.UnicodeBlock.of(c).toString();
if (block.startsWith("CJK")) {
return true;
}
switch (block) {
case "HIRAGANA":
case "KATAKANA":
case "HANGUL_SYLLABLES":
case "HANGUL_JAMO_EXTENDED_B":
case "EGYPTIAN_HIEROGLYPHS":
case "OLD_SOGDIAN":
case "SOGDIAN":
case "THAI":
case "TAMIL":
case "TAMIL_SUPPLEMENT":
case "TIBETAN":
case "BRAHMI":
case "YI_SYLLABLES":
case "YI_RADICALS":
return true;
}
return false;
}
@Override
public void end() throws IOException {
super.end();
breakIterator.setText("");
}
}
使用:
private static Analyzer newCjkAnalyzer() {
return new StopwordAnalyzerBase(Version.LUCENE_47){
protected TokenStreamComponents createComponents(String fieldName, Reader reader) {
//CMN.Log("createComponents...", fieldName, reader);
Tokenizer source = new StandardTokenizer(this.matchVersion, reader);
TokenStream result = new LowerCaseFilter(this.matchVersion, source);
WordBreakFilter bwf = new WordBreakFilter(result);
TokenStreamComponents ret = new TokenStreamComponents(source, new StopFilter(this.matchVersion, bwf, this.stopwords));
bwf.component = ret; // 将 TokenStreamComponents 挂在 WordBreakFilter 上面
return ret;
}
};
}
测试案例代码见上文
原理
在自定义过滤器WordBreakFilter
中调用icu4j的BreakIterator
断词功能。Java虽然也有BreakIterator
API,但是只能断句无法断词,安卓自带的倒是可以断词的。
<dependency>
<groupId>com.ibm.icu</groupId>
<artifactId>icu4j</artifactId>
<version>70.1</version>
</dependency>
难点
需要在自定义过滤器中获得原文本,将原文本喂给BreakIterator
。但是原文本经过lucene的层层封装已经变成private final String s;
了,且无法通过简单反射获取。所以需要魔改Lucene核心库,将text
暴露出来,挂在compenent之上,再在项目中自定义Analyzer,将compenent反向挂在自定义过滤器上面,这样,就能在自定义过滤器的incrementToken回调中获取原文本了。
一旦在自定义过滤器的incrementToken
方法中检测到新的text
出现,意味着已经开始解析新的字符串,进行新的切词了。此时将text
拿走,喂给BreakIterator,往后的incrementToken
调用就去一次次地迭代BreakIterator,直到返回-1。返回-1后再调去用原来的return input.incrementToken
方法,沿用原本的切词逻辑(StandardTokenizer加上各种过滤器如LowerCaseFilter),以同时摘录多字词与单字词作为文本的关键词。
那么迭代BreakIterator时获得的单词分界点,怎么反映到Lucene的切词上面呢?参考CJKBigramFilter,可分别设置termAttr
(关键词属性)和 offsetAtt
(文本位置属性),然后在incrementToken
方法中返回true即可。Lucene中的属性是个很有意思的东西,什么原理?暂时还没搞明白。
至于线程安全(跨线程使用analyzer实例),analyzer.createComponents会在每个线程调用一次以创建新的compenent,所以认为是安全的。
分词结果
“我是中国人” 分词为 我是|中国人 以及 我|是|中|国|人
需要注意,如果没有后面的单字分词(注释掉return input.incrementToken
),用“中国”不能搜索到“中国人”,所以可能需要更加精细地分词。还有一点,WordBreakFilter.java 并不完美,没有单字分词的话,是无法获取相关高亮片段的,还需要改进。
上文排序案例测试
索引性能测试
在实际项目中测试,索引压缩后24MB的牛津英语词典
(安卓APP中开多线程测试,设备是三星s7。电脑端即使单线程也快三倍。总时间的话,包括了IO读取词典内容、Jsoup解析html为text、建立lucene索引,其中读取内容占用二十多秒、解析html占用二十多秒):
默认分析器:65-75s,占用47MB。
WordBreakFilter: 80s-85s,占用53MB。
CJKBigramFilter:85s,占用60MB。
另外再测试IKAnalyzer
api ('com.janeluo:ikanalyzer:2012_u6'){
exclude module:"lucene-core"
}
IKAnalyzer,smartcn,paoding都已停更。elasticsearch-analysis-ik还在更新,但是集成到elasticsearch中去了。
new IKAnalyzer(true) :
耗时136-138s,占用48MB
new IKAnalyzer(false) :
耗时145s,占用53MB
IKAnalyzer的构造函数有个布尔开关,可以打开智能分词。默认false,打开后,会过滤掉一些分词结果。如分析 “我是中国人 是我天山雪”的结果:
new IKAnalyzer(true) :
我|是|中国人 是|我|天山|雪
new IKAnalyzer(false) :
我|是|中国人 中国|国人 是|我|天山|雪
IKAnalyzer还会规整化英语里的’s属格,可借鉴。