• 用不到一百行代码为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("");
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96

    使用:

    
    	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;
    			}
    		};
    	}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    测试案例代码见上文

    原理

    在自定义过滤器WordBreakFilter中调用icu4j的BreakIterator断词功能。Java虽然也有BreakIteratorAPI,但是只能断句无法断词,安卓自带的倒是可以断词的。

            
                com.ibm.icu
                icu4j
                70.1
            
    
    • 1
    • 2
    • 3
    • 4
    • 5

    难点

    需要在自定义过滤器中获得原文本,将原文本喂给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"
        }
    
    • 1
    • 2
    • 3

    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属格,可借鉴。

  • 相关阅读:
    群晖DS218+部署PostgreSQL(docker)
    java基于ssm+vue的高校会议预约系统 elementui
    C++ 11 内敛函数inline
    GC FullGC
    (二) gitblit用户使用教程
    【洛谷题解/SDOI2012】P2303/SDOI2012 D1T1 Longge的问题
    【算法】BF、KMP算法及OJ题
    C# +SQL 存储过程 实现系统数据权限审查AOP效果
    初高(重要的是高中)中数学知识点综合(持续更新)
    国产替代风潮下,电子元器件B2B商城系统如何助力企业突围市场竞争
  • 原文地址:https://blog.csdn.net/sinat_27171121/article/details/126688727