Lucene的Smart CN实现分词、停用词、扩展词

0.png

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 中实现,可以查看一些源码中的停用词配置信息。

00.png

其中设定的默认停用词会读取 stopwards.txt 文件中停用词,可以在引入的 jar 包中找到该文件。

01.png

其内容主要是一些标点符号作为停用词。

[ , 、, 。, !, , (, ), 《, 》, ,, -, 【, 】, —, :, ;, “, ”, ?, !, ", #, $, &, ', (, ), *, +, ,, -, ., /, ·, :, [, <, ], >, ?, @, ●, [, \, ], ^, _, `, ;, =, {, |, }, ~]

在 stopwords.txt 中虽然只是提供了一些标点符号作为停用词但是其中定义了停用词的三个类别:Punctuation tokens to remove、English Stop Words、Chinese Stop Words。

02.png

因此可以按照 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 相同的功能,但是仍不能实现对扩展词的实现。参考

很显然,上面的代码分为代码部分:

  1. 生成 tokenizer 对象;
  2. 生成 tokenStream 对象,并进行停用词过滤;
  3. 使用 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() 方法在分词过程中实现停用词、扩展词等功能,并考虑扩展性。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343