Lucene 中提供了 SmartCN 为中文提供分词功能,实际应用中还会涉及到停用词、扩展词(特殊词、专业词)等,因此本文将聚焦在 SmartCN 而暂时不考虑其他中文分词类库。
1 简介
analyzers-smartcn
是一个用于简体中文索引词的 Analyzer。但是需要注意的它提供的 API 是试验性的,后续版本中可能进行更改。
可以它包含了如下两部分:
org.apache.lucene.analysis.cn.smart
用于简体中文的分析器,用来建立索引。
org.apache.lucene.analysis.cn.smart.hhmm
SmartChineseAnalyzer 隐藏了 Hidden Model 包。
analyzers-smartcn
中包含了 3 种分析器,它们用不同的方式来分析中文:
-
StandardAnalyzer
会单个汉字来作为标记。例如:“中台的作用”分析后成为:中-台-的-作-用 -
CJKAnalyzer
它在 analyzers/cjk 包中,使用相邻两个汉字作为标记。“中台的作用”分析后成为:中台-的作-用 -
SmartChineseAnalyzer
尝试使用中文文本分割成单词作为标记。“中台的作用”分析后成为:中台-的-作用
很显然 SmartChineseAnalyzer
更符合日常搜索的使用场景。
2 SmartChineseAnalyzer
上面的例子展示了 SmartChineseAnalyzer 对中文的处理,实际上 SmartChineseAnalyzer 同时还支持中英文混合排版。
SmartChineseAnalyzer 使用了概率知识来获取最佳的中文分词。文本会首先被分割成字句,再将字句分割成单词。分词基于 Hidden Markov Model。使用大型训练语料来计算中文单词频率概率。
SmartChineseAnalyzer 需要一个词典来提供统计数据。它自带了一个现成的词典。包含的词典数据来自 ICTCLAS1.0。
SamrtChineseAnalyzer 提供了 3 种构造函数,通过构造函数能够控制是否使用 SmartChineseAnalyzer 自带的停用词,以及使用外部的停用词。
通过实际测试,我们可以了解分词结果,分词代码如下:
String text = "K8s 和 YARN 都不够好,全面解析 Facebook 自研流处理服务管理平台";
Analyzer analyzer = new SmartChineseAnalyzer();
TokenStream tokenStream = analyzer.tokenStream("testField", text);
OffsetAttribute offsetAttribute = tokenStream.addAttribute(OffsetAttribute.class);
tokenStream.reset();
List<String> tokens = new ArrayList<>();
while (tokenStream.incrementToken()) {
tokens.add(offsetAttribute.toString());
}
tokenStream.end();
String format = String.format("tokens:%s", tokens);
System.out.println(format);
通过下面下面 3 个例子分析结果,我们可以大致了解 SmartChineseAnalyzer。
"K8s 和 YARN 都不够好,全面解析 Facebook 自研流处理服务管理平台"
分词结果是:
[k, 8, s, 和, yarn, 都, 不, 够, 好, 全面, 解析, facebook, 自, 研, 流, 处理, 服务, 管理, 平台]
“极客时间学习心得:用分类和聚焦全面夯实技术认知"
分词结果如下:
[极, 客, 时间, 学习, 心得, 用, 分类, 和, 聚焦, 全面, 夯实, 技术, 认知]
"交易中台架构设计:海量并发的高扩展,新业务秒级接入"
分词结果如下:
[交易, 中, 台, 架构, 设计, 海量, 并发, 的, 高, 扩展, 新, 业务, 秒, 级, 接入]
很显然,停用词中没有没有过滤掉“的”。为了更加了解 SmartChineseAnalyzer 中实现,可以查看一些源码中的停用词配置信息。
其中设定的默认停用词会读取 stopwards.txt 文件中停用词,可以在引入的 jar 包中找到该文件。
其内容主要是一些标点符号作为停用词。
[ , 、, 。, !, , (, ), 《, 》, ,, -, 【, 】, —, :, ;, “, ”, ?, !, ", #, $, &, ', (, ), *, +, ,, -, ., /, ·, :, [, <, ], >, ?, @, ●, [, \, ], ^, _, `, ;, =, {, |, }, ~]
在 stopwords.txt 中虽然只是提供了一些标点符号作为停用词但是其中定义了停用词的三个类别:Punctuation tokens to remove、English Stop Words、Chinese Stop Words。
因此可以按照 lucene 源文件中的 stopwords.txt 的格式定义并引入自定义停用词。
3 为 SmartChineseAnalyzer 自定义停用词
这里可以在默认的停用词中添加一个停用词“的”,然后重新对“交易中台架构设计:海量并发的高扩展,新业务秒级接入” 进行分词并查看分词结果。
创建一个 stopwords.txt 文件,并将 smartcn 中停用词内容拷贝过来,并追加如下内容
//////////////// Chinese Stop Words ////////////////
的
在构建 SmartChineseAnalyzer 是通过构造函数指定停用词。
CharArraySet stopWords = CharArraySet.unmodifiableSet(WordlistLoader.getWordSet(
IOUtils.getDecodingReader(
new ClassPathResource("stopwords.txt").getInputStream(),
StandardCharsets.UTF_8),
STOPWORD_FILE_COMMENT));
Analyzer analyzer = new SmartChineseAnalyzer(stopWords);
完整代码执行代码如下:
String text = "交易中台架构设计:海量并发的高扩展,新业务秒级接入";
CharArraySet stopWords = CharArraySet.unmodifiableSet(WordlistLoader.getWordSet(
IOUtils.getDecodingReader(
new ClassPathResource("stopwords.txt").getInputStream(),
StandardCharsets.UTF_8),
STOPWORD_FILE_COMMENT));
Analyzer analyzer = new SmartChineseAnalyzer(stopWords);
TokenStream tokenStream = analyzer.tokenStream("testField", text);
OffsetAttribute offsetAttribute = tokenStream.addAttribute(OffsetAttribute.class);
tokenStream.reset();
List<String> tokens = new ArrayList<>();
while (tokenStream.incrementToken()) {
tokens.add(offsetAttribute.toString());
}
tokenStream.end();
System.out.println(String.format("tokens:%s", tokens));
结果显示:
[交易, 中, 台, 架构, 设计, 海量, 并发, 高, 扩展, 新, 业务, 秒, 级, 接入]
停用词 “的” 已经成功被去掉。
4 为 SmartChineseAnalyzer 实现扩展词
但是结果中显示的”中台“一次被拆分了”中“、”台“两个词,而当前”中台“已经是大家所熟知的一个术语。如果能够 SmartChineseAnalyzer 实现扩展词,那么可以则能够像停用词一样方便。
SmartChineseAnalyzer.createComponents() 方法的是最关键的实现,代码如下:
@Override
public TokenStreamComponents createComponents(String fieldName) {
final Tokenizer tokenizer = new HMMChineseTokenizer();
TokenStream result = tokenizer;
// result = new LowerCaseFilter(result);
// LowerCaseFilter is not needed, as SegTokenFilter lowercases Basic Latin text.
// The porter stemming is too strict, this is not a bug, this is a feature:)
result = new PorterStemFilter(result);
if (!stopWords.isEmpty()) {
result = new StopFilter(result, stopWords);
}
return new TokenStreamComponents(tokenizer, result);
}
但是 SmartChineseAnalyzer 类被关键字 final 修饰,也就意味着无法通过继承来沿用 SmartChineseAnalyzer 的功能,但是可以通过继承抽象类 Analyzer 并复写 createComponents()、normalize() 方法。实现代码如下:
public class MySmartChineseAnalyzer extends Analyzer {
private CharArraySet stopWords;
public MySmartChineseAnalyzer(CharArraySet stopWords) {
this.stopWords = stopWords;
}
@Override
public Analyzer.TokenStreamComponents createComponents(String fieldName) {
final Tokenizer tokenizer = new HMMChineseTokenizer();
TokenStream result = tokenizer;
this is a feature:)
result = new PorterStemFilter(result);
if (!stopWords.isEmpty()) {
result = new StopFilter(result, stopWords);
}
return new TokenStreamComponents(tokenizer, result);
}
@Override
protected TokenStream normalize(String fieldName, TokenStream in) {
return new LowerCaseFilter(in);
}
}
通过上面的代码我们既能实现 SmartChineseAnalyzer 相同的功能,但是仍不能实现对扩展词的实现。参考
很显然,上面的代码分为代码部分:
- 生成 tokenizer 对象;
- 生成 tokenStream 对象,并进行停用词过滤;
- 使用 tokenizer、tokenStream 来构建 TokenStreamComponents 对象。
因此,我们可以参考 SmartChineseAnalyzer 中对停用词的实现,来实现扩展词。
StopFilter 是抽象类 TokenStream 的子类。其继承链条如下:
StopFilter -> FilteringTokenFilter -> TokenFilter ->TokenStream
且 FilteringTokenFilter、TokenFilter、TokenStream 都是抽象类,其子类都需要复写 incrementToken() 和实现 accept()。
因此可自定义 ExtendWordFilter 来实现扩展词的功能,ExtendWordFilter 继承 TokenFilter,并复写 IncrementToken(),代码如下:
public class ExtendWordFilter extends TokenFilter {
private int hadMatchedWordLength = 0;
private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
private final PositionIncrementAttribute posIncrAtt = addAttribute(PositionIncrementAttribute.class);
private final List<String> extendWords;
public ExtendWordFilter(TokenStream in, List<String> extendWords) {
super(in);
this.extendWords = extendWords;
}
@Override
public final boolean incrementToken() throws IOException {
int skippedPositions = 0;
while (input.incrementToken()) {
if (containsExtendWord()) {
if (skippedPositions != 0) {
posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + skippedPositions);
}
return true;
}
skippedPositions += posIncrAtt.getPositionIncrement();
}
return false;
}
protected boolean containsExtendWord() {
Optional<String> matchedWordOptional = extendWords.stream()
.filter(word -> word.contains(termAtt.toString()))
.findFirst();
if (matchedWordOptional.isPresent()) {
hadMatchedWordLength += termAtt.length();
if (hadMatchedWordLength == matchedWordOptional.get().length()) {
termAtt.setEmpty();
termAtt.append(matchedWordOptional.get());
return true;
}
} else {
hadMatchedWordLength = 0;
}
return matchedWordOptional.isEmpty();
}
}
incrementToken() 中主要是调用 setPositionIncrement() 设置数据读取位置。containsExtendWord() 用来判断是否包含扩展词,以此为根据来合并和实现扩展词。
修改刚刚自定义的 CustomSmartChineseAnalyzer 中 createComponents() 方法,添加如下逻辑:
if (!words.isEmpty()) {
result = new ExtendWordFilter(result, words);
}
修改后的 CustomSmartChineseAnalyzer 完整代码如下:
public class CustomSmartChineseAnalyzer extends Analyzer {
private CharArraySet extendWords;
private List<String> words;
private CharArraySet stopWords;
public CustomSmartChineseAnalyzer(CharArraySet stopWords, List<String> words) {
this.stopWords = stopWords;
this.words = words;
}
@Override
public Analyzer.TokenStreamComponents createComponents(String fieldName) {
final Tokenizer tokenizer = new HMMChineseTokenizer();
TokenStream result = tokenizer;
result = new LowerCaseFilter(result);
result = new PorterStemFilter(result);
if (!stopWords.isEmpty()) {
result = new StopFilter(result, stopWords);
}
if (!words.isEmpty()) {
result = new ExtendWordFilter(result, words);
}
return new TokenStreamComponents(tokenizer, result);
}
@Override
protected TokenStream normalize(String fieldName, TokenStream in) {
return new LowerCaseFilter(in);
}
}
最后调用测试代码,查看分词结果。
@Test
void test_custom_smart_chinese_analyzer() throws IOException {
String text = "交易中台架构设计:海量并发的高扩展,新业务秒级接入";
CharArraySet stopWords = CharArraySet.unmodifiableSet(WordlistLoader.getWordSet(
IOUtils.getDecodingReader(
new ClassPathResource("stopwords.txt").getInputStream(),
StandardCharsets.UTF_8),
STOPWORD_FILE_COMMENT));
List<String> words = Collections.singletonList("中台");
Analyzer analyzer = new CustomSmartChineseAnalyzer(stopWords, words);
TokenStream tokenStream = analyzer.tokenStream("testField", text);
OffsetAttribute offsetAttribute = tokenStream.addAttribute(OffsetAttribute.class);
tokenStream.reset();
List<String> tokens = new ArrayList<>();
while (tokenStream.incrementToken()) {
tokens.add(offsetAttribute.toString());
}
tokenStream.end();
System.out.println(String.format("tokens:%s", tokens));
}
执行结果如下:
[交易, 中台, 架构, 设计, 海量, 并发, 高, 扩展, 新, 业务, 秒, 级, 接入]
上面的代码只是初稿,存在着部分 Code Smell,感兴趣的可以尝试消除那些 Code Smell。
虽然实现了扩展词的功能,但是是在叫高层的地方修改数据,且效率也并不佳,但是较容易扩展且拥有较好的可读性。
如想提升性能,可以参考 HHMMSegmenter.process() 方法在分词过程中实现停用词、扩展词等功能,并考虑扩展性。