Lucene的Smart CN實(shí)現(xiàn)分詞、停用詞、擴(kuò)展詞

0.png

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),可以查看一些源碼中的停用詞配置信息。

00.png

其中設(shè)定的默認(rèn)停用詞會(huì)讀取 stopwards.txt 文件中停用詞,可以在引入的 jar 包中找到該文件。

01.png

其內(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。

02.png

因此可以按照 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)。參考

很顯然,上面的代碼分為代碼部分:

  1. 生成 tokenizer 對(duì)象;
  2. 生成 tokenStream 對(duì)象,并進(jìn)行停用詞過濾;
  3. 使用 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ò)展性。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。