Lucene 中提供了 SmartCN 為中文提供分詞功能,實(shí)際應(yīng)用中還會(huì)涉及到停用詞、擴(kuò)展詞(特殊詞、專業(yè)詞)等,因此本文將聚焦在 SmartCN 而暫時(shí)不考慮其他中文分詞類庫。
1 簡介
analyzers-smartcn
是一個(gè)用于簡體中文索引詞的 Analyzer。但是需要注意的它提供的 API 是試驗(yàn)性的,后續(xù)版本中可能進(jìn)行更改。
可以它包含了如下兩部分:
org.apache.lucene.analysis.cn.smart
用于簡體中文的分析器,用來建立索引。
org.apache.lucene.analysis.cn.smart.hhmm
SmartChineseAnalyzer 隱藏了 Hidden Model 包。
analyzers-smartcn
中包含了 3 種分析器,它們用不同的方式來分析中文:
-
StandardAnalyzer
會(huì)單個(gè)漢字來作為標(biāo)記。例如:“中臺(tái)的作用”分析后成為:中-臺(tái)-的-作-用 -
CJKAnalyzer
它在 analyzers/cjk 包中,使用相鄰兩個(gè)漢字作為標(biāo)記。“中臺(tái)的作用”分析后成為:中臺(tái)-的作-用 -
SmartChineseAnalyzer
嘗試使用中文文本分割成單詞作為標(biāo)記。“中臺(tái)的作用”分析后成為:中臺(tái)-的-作用
很顯然 SmartChineseAnalyzer
更符合日常搜索的使用場(chǎng)景。
2 SmartChineseAnalyzer
上面的例子展示了 SmartChineseAnalyzer 對(duì)中文的處理,實(shí)際上 SmartChineseAnalyzer 同時(shí)還支持中英文混合排版。
SmartChineseAnalyzer 使用了概率知識(shí)來獲取最佳的中文分詞。文本會(huì)首先被分割成字句,再將字句分割成單詞。分詞基于 Hidden Markov Model。使用大型訓(xùn)練語料來計(jì)算中文單詞頻率概率。
SmartChineseAnalyzer 需要一個(gè)詞典來提供統(tǒng)計(jì)數(shù)據(jù)。它自帶了一個(gè)現(xiàn)成的詞典。包含的詞典數(shù)據(jù)來自 ICTCLAS1.0。
SamrtChineseAnalyzer 提供了 3 種構(gòu)造函數(shù),通過構(gòu)造函數(shù)能夠控制是否使用 SmartChineseAnalyzer 自帶的停用詞,以及使用外部的停用詞。
通過實(shí)際測(cè)試,我們可以了解分詞結(jié)果,分詞代碼如下:
String text = "K8s 和 YARN 都不夠好,全面解析 Facebook 自研流處理服務(wù)管理平臺(tái)";
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 個(gè)例子分析結(jié)果,我們可以大致了解 SmartChineseAnalyzer。
"K8s 和 YARN 都不夠好,全面解析 Facebook 自研流處理服務(wù)管理平臺(tái)"
分詞結(jié)果是:
[k, 8, s, 和, yarn, 都, 不, 夠, 好, 全面, 解析, facebook, 自, 研, 流, 處理, 服務(wù), 管理, 平臺(tái)]
“極客時(shí)間學(xué)習(xí)心得:用分類和聚焦全面夯實(shí)技術(shù)認(rèn)知"
分詞結(jié)果如下:
[極, 客, 時(shí)間, 學(xué)習(xí), 心得, 用, 分類, 和, 聚焦, 全面, 夯實(shí), 技術(shù), 認(rèn)知]
"交易中臺(tái)架構(gòu)設(shè)計(jì):海量并發(fā)的高擴(kuò)展,新業(yè)務(wù)秒級(jí)接入"
分詞結(jié)果如下:
[交易, 中, 臺(tái), 架構(gòu), 設(shè)計(jì), 海量, 并發(fā), 的, 高, 擴(kuò)展, 新, 業(yè)務(wù), 秒, 級(jí), 接入]
很顯然,停用詞中沒有沒有過濾掉“的”。為了更加了解 SmartChineseAnalyzer 中實(shí)現(xiàn),可以查看一些源碼中的停用詞配置信息。
其中設(shè)定的默認(rèn)停用詞會(huì)讀取 stopwards.txt 文件中停用詞,可以在引入的 jar 包中找到該文件。
其內(nèi)容主要是一些標(biāo)點(diǎn)符號(hào)作為停用詞。
[ , 、, 。, !, , (, ), 《, 》, ,, -, 【, 】, —, :, ;, “, ”, ?, !, ", #, $, &, ', (, ), *, +, ,, -, ., /, ·, :, [, <, ], >, ?, @, ●, [, \, ], ^, _, `, ;, =, {, |, }, ~]
在 stopwords.txt 中雖然只是提供了一些標(biāo)點(diǎn)符號(hào)作為停用詞但是其中定義了停用詞的三個(gè)類別:Punctuation tokens to remove、English Stop Words、Chinese Stop Words。
因此可以按照 lucene 源文件中的 stopwords.txt 的格式定義并引入自定義停用詞。
3 為 SmartChineseAnalyzer 自定義停用詞
這里可以在默認(rèn)的停用詞中添加一個(gè)停用詞“的”,然后重新對(duì)“交易中臺(tái)架構(gòu)設(shè)計(jì):海量并發(fā)的高擴(kuò)展,新業(yè)務(wù)秒級(jí)接入” 進(jìn)行分詞并查看分詞結(jié)果。
創(chuàng)建一個(gè) stopwords.txt 文件,并將 smartcn 中停用詞內(nèi)容拷貝過來,并追加如下內(nèi)容
//////////////// Chinese Stop Words ////////////////
的
在構(gòu)建 SmartChineseAnalyzer 是通過構(gòu)造函數(shù)指定停用詞。
CharArraySet stopWords = CharArraySet.unmodifiableSet(WordlistLoader.getWordSet(
IOUtils.getDecodingReader(
new ClassPathResource("stopwords.txt").getInputStream(),
StandardCharsets.UTF_8),
STOPWORD_FILE_COMMENT));
Analyzer analyzer = new SmartChineseAnalyzer(stopWords);
完整代碼執(zhí)行代碼如下:
String text = "交易中臺(tái)架構(gòu)設(shè)計(jì):海量并發(fā)的高擴(kuò)展,新業(yè)務(wù)秒級(jí)接入";
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));
結(jié)果顯示:
[交易, 中, 臺(tái), 架構(gòu), 設(shè)計(jì), 海量, 并發(fā), 高, 擴(kuò)展, 新, 業(yè)務(wù), 秒, 級(jí), 接入]
停用詞 “的” 已經(jīng)成功被去掉。
4 為 SmartChineseAnalyzer 實(shí)現(xiàn)擴(kuò)展詞
但是結(jié)果中顯示的”中臺(tái)“一次被拆分了”中“、”臺(tái)“兩個(gè)詞,而當(dāng)前”中臺(tái)“已經(jīng)是大家所熟知的一個(gè)術(shù)語。如果能夠 SmartChineseAnalyzer 實(shí)現(xiàn)擴(kuò)展詞,那么可以則能夠像停用詞一樣方便。
SmartChineseAnalyzer.createComponents() 方法的是最關(guān)鍵的實(shí)現(xiàn),代碼如下:
@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 類被關(guān)鍵字 final 修飾,也就意味著無法通過繼承來沿用 SmartChineseAnalyzer 的功能,但是可以通過繼承抽象類 Analyzer 并復(fù)寫 createComponents()、normalize() 方法。實(shí)現(xiàn)代碼如下:
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);
}
}
通過上面的代碼我們既能實(shí)現(xiàn) SmartChineseAnalyzer 相同的功能,但是仍不能實(shí)現(xiàn)對(duì)擴(kuò)展詞的實(shí)現(xiàn)。參考
很顯然,上面的代碼分為代碼部分:
- 生成 tokenizer 對(duì)象;
- 生成 tokenStream 對(duì)象,并進(jìn)行停用詞過濾;
- 使用 tokenizer、tokenStream 來構(gòu)建 TokenStreamComponents 對(duì)象。
因此,我們可以參考 SmartChineseAnalyzer 中對(duì)停用詞的實(shí)現(xiàn),來實(shí)現(xiàn)擴(kuò)展詞。
StopFilter 是抽象類 TokenStream 的子類。其繼承鏈條如下:
StopFilter -> FilteringTokenFilter -> TokenFilter ->TokenStream
且 FilteringTokenFilter、TokenFilter、TokenStream 都是抽象類,其子類都需要復(fù)寫 incrementToken() 和實(shí)現(xiàn) accept()。
因此可自定義 ExtendWordFilter 來實(shí)現(xiàn)擴(kuò)展詞的功能,ExtendWordFilter 繼承 TokenFilter,并復(fù)寫 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() 中主要是調(diào)用 setPositionIncrement() 設(shè)置數(shù)據(jù)讀取位置。containsExtendWord() 用來判斷是否包含擴(kuò)展詞,以此為根據(jù)來合并和實(shí)現(xiàn)擴(kuò)展詞。
修改剛剛自定義的 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);
}
}
最后調(diào)用測(cè)試代碼,查看分詞結(jié)果。
@Test
void test_custom_smart_chinese_analyzer() throws IOException {
String text = "交易中臺(tái)架構(gòu)設(shè)計(jì):海量并發(fā)的高擴(kuò)展,新業(yè)務(wù)秒級(jí)接入";
CharArraySet stopWords = CharArraySet.unmodifiableSet(WordlistLoader.getWordSet(
IOUtils.getDecodingReader(
new ClassPathResource("stopwords.txt").getInputStream(),
StandardCharsets.UTF_8),
STOPWORD_FILE_COMMENT));
List<String> words = Collections.singletonList("中臺(tái)");
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));
}
執(zhí)行結(jié)果如下:
[交易, 中臺(tái), 架構(gòu), 設(shè)計(jì), 海量, 并發(fā), 高, 擴(kuò)展, 新, 業(yè)務(wù), 秒, 級(jí), 接入]
上面的代碼只是初稿,存在著部分 Code Smell,感興趣的可以嘗試消除那些 Code Smell。
雖然實(shí)現(xiàn)了擴(kuò)展詞的功能,但是是在叫高層的地方修改數(shù)據(jù),且效率也并不佳,但是較容易擴(kuò)展且擁有較好的可讀性。
如想提升性能,可以參考 HHMMSegmenter.process() 方法在分詞過程中實(shí)現(xiàn)停用詞、擴(kuò)展詞等功能,并考慮擴(kuò)展性。