用不到一百行代码为Lucene实现优雅的中文分词

废话不多说直接上代码 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虽然也有BreakIteratorAPI,但是只能断句无法断词,安卓自带的倒是可以断词的。

        <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 并不完美,没有单字分词的话,是无法获取相关高亮片段的,还需要改进。

上文排序案例测试

族, (0.8255894)

中国 银行 (0.62306976)

可以得到更多实惠 (0.49845585)

(0.25077444)

洛杉矶 ,洛杉矶居 (0.16718297)


索引性能测试

在实际项目中测试,索引压缩后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属格,可借鉴。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值