从Lucene到Elasticsearch:全文检索实战
上QQ阅读APP看书,第一时间看更新

2.3 Lucene分词详解

2.3.1 Lucene分词系统

在第1章中已经提到,索引和查询都是以词项为基本单位,词项是词条化的结果。在Lucene中,分词主要依靠Analyzer类解析实现。Analyzer类是一个抽象类(public abstract class org.apache.lucene.analysis.Analyzer),切分词的具体规则是由子类实现的,所以对于不同的语言规则,要有不同的分词器。

Analyzer内部主要通过TokenStream类实现。Tonkenizer类和TokenFilter类是TokenStream的两个子类。Tokenizer处理单个字符组成的字符流,读取Reader对象中的数据,处理后转换成词汇单元。TokenFilter完成文本过滤器的功能,但在使用过程中必须注意不同过滤器的使用顺序。

在创建索引的时候需要用到分词器,在进行索引查询的时候也会用到分词器,并且这两个地方要使用同一个分词器,否则可能会搜索不出来结果。Lucene提供了多种分词方法,简介如下:

● StopAnalyzer(停用词分词器)

StopAnalyzer能过滤词汇中的特定字符串和词汇,并且完成大写转小写的功能。

● StandardAnalyzer(标准分词器)

StandardAnalyzer根据空格和符号来完成分词,还可以完成数字、字母、E-mail地址、IP地址以及中文字符的分析处理,还可以支持过滤词表,用来代替StopAnalyzer能够实现的过滤功能。

● WhitespaceAnalyzer(空格分词)

WhitespaceAnalyzer使用空格作为间隔符的词汇分割分词器。处理词汇单元的时候,以空格字符作为分割符号。分词器不做词汇过滤,也不进行小写字符转换。实际中可以用来支持特定环境下的西文符号的处理。由于不完成单词过滤和小写字符转换功能,也不需要过滤词库支持。词汇分割策略上简单使用非英文字符作为分割符,不需要分词词库支持。

● SimpleAnalyzer(简单分词)

SimpleAnalyzer具备基本西文字符词汇分析的分词器,处理词汇单元时,以非字母字符作为分割符号。分词器不能做词汇的过滤,只进行词汇的分析和分割。输出的词汇单元完成小写字符转换,去掉标点符号等分割符。在全文检索系统开发中,通常用来支持西文符号的处理,不支持中文。由于不完成单词过滤功能,所以不需要过滤词库支持。词汇分割策略上简单使用非英文字符作为分割符,不需要分词词库的支持。

● CJKAnalyzer(二分法分词)

内部调用CJKTokenizer分词器,对中文进行分词,同时使用StopFilter过滤器完成过滤功能,可以实现中文的多元切分和停用词过滤。

● KeywordAnalyzer(关键词分词)

把整个输入作为一个单独词汇单元,方便特殊类型的文本进行索引和检索。针对邮政编码、地址等文本信息使用关键词分词器进行索引项建立非常方便。

2.3.2 分词器测试

Lucene标准分词器会把句子分成一个一个单个的单词,标准分词的代码见代码清单2-1。

代码清单2-1 Lucene标准分词

        import java.io.IOException;
        import java.io.StringReader;
        import org.apache.lucene.analysis.Analyzer;
        import org.apache.lucene.analysis.TokenStream;
        import org.apache.lucene.analysis.standard.StandardAnalyzer;
        import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
        public class StdAnalyzer {
            private static String strCh = "中华人民共和国简称中国,是一个有13亿人口
                的国家";
            private static String strEn = "Dogs can not achieve a place,
              eyes can reach; ";
            public static void main(String[] args)throws IOException {
                System.out.println("StandardAnalyzer对中文分词:");
                stdAnalyzer(strCh);
                System.out.println("StandardAnalyzer对英文分词:");
                stdAnalyzer(strEn);
            }
            public static void stdAnalyzer(String str)throws IOException{
                Analyzer analyzer = null;
                analyzer = new StandardAnalyzer();
                StringReader reader = new StringReader(str);
                TokenStream toStream = analyzer.tokenStream(str, reader);
                toStream.reset();
                CharTermAttribute teAttribute =
                            toStream.getAttribute(CharTermAttribute.class);
                System.out.println("分词结果:");
                while(toStream.incrementToken()){
                    System.out.print(teAttribute.toString()+ "|");
                }
                System.out.println("\n");
                analyzer.close();
            }
        }

运行结果:

StandardAnalyzer对中文分词:

分词结果:

中|华|人|民|共|和|国|简|称|中|国|是|一|个|有|13|亿|人|口|的|国|家|

StandardAnalyzer对英文分词:

分词结果:

dogs|can|achieve|place|eyes|can|reach|

下面重构以上代码,测试多种分词器的分词效果,见代码清单2-2。

代码清单2-2 Lucene多种分词器示例

    import java.io.IOException;
    import java.io.StringReader;
    import org.apache.lucene.analysis.Analyzer;
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cjk.CJKAnalyzer;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.core.KeywordAnalyzer;
    import org.apache.lucene.analysis.core.SimpleAnalyzer;
    import org.apache.lucene.analysis.core.StopAnalyzer;
    import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
    import org.apache.lucene.analysis.standard.StandardAnalyzer;
    import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
    public class VariousAnalyzers {
        private static String str = "中华人民共和国简称中国,是一个有13亿人口的国家";
        public static void main(String[] args)throws IOException {
            Analyzer analyzer = null;
            analyzer = new StandardAnalyzer(); // 标准分词
            System.out.println("标准分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
            analyzer = new WhitespaceAnalyzer(); // 空格分词
            System.out.println("空格分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
            analyzer = new SimpleAnalyzer(); // 简单分词
            System.out.println("简单分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
            analyzer = new CJKAnalyzer(); // 二分法分词
            System.out.println("二分法分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
            analyzer = new KeywordAnalyzer(); // 关键字分词
            System.out.println("关键字分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
            analyzer = new StopAnalyzer(); // 停用词分词
            System.out.println("停用词分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
            analyzer = new SmartChineseAnalyzer(); // 中文智能分词
            System.out.println("中文智能分词:" + analyzer.getClass());
            printAnalyzer(analyzer);
        }
        public static void printAnalyzer(Analyzer analyzer)
        throws IOException {
            StringReader reader = new StringReader(str);
            TokenStream toStream = analyzer.tokenStream(str, reader);
            toStream.reset(); // 清空流
            CharTermAttribute teAttribute = toStream.getAttribute
         (CharTermAttribute.class);
            while(toStream.incrementToken()){
                System.out.print(teAttribute.toString()+ "|");
            }
            System.out.println("\n");
            analyzer.close();
        }
    }

运行结果:

标准分词:

class org.apache.lucene.analysis.standard.StandardAnalyzer

中|华|人|民|共|和|国|简|称|中|国|是|一|个|有|13|亿|人|口|的|国|家|

空格分词:

class org.apache.lucene.analysis.core.WhitespaceAnalyzer

中华人民共和国简称中国,|是一个有13亿人口的国家|

简单分词:

class org.apache.lucene.analysis.core.SimpleAnalyzer

中华人民共和国简称中国|是一个有|亿人口的国家|

二分法分词:

class org.apache.lucene.analysis.cjk.CJKAnalyzer

中华|华人|人民|民共|共和|和国|国简|简称|称中|中国|是一|一个|个有|13|亿人|人口|口的|的国|国家|

关键字分词:

class org.apache.lucene.analysis.core.KeywordAnalyzer

中华人民共和国简称中国,是一个有13亿人口的国家|

停用词分词:

class org.apache.lucene.analysis.core.StopAnalyzer

中华人民共和国简称中国|是一个有|亿人口的国家|

中文智能分词:

class org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer

中华人民共和国|简称|中国|是|一个|有|13|亿|人口|的|国家|

2.3.3 IK分词器配置

Lucene 6.0使用IK分词器需要修改IKAnalyzer和IKTokenizer。在包tup.lucene.ik下新建一个IKTokenizer6x类和一个IKAnalyzer6x类。IKTokenizer6x类的代码见代码清单2-3, IKAnalyzer6x类的代码见代码清单2-4。

代码清单2-3 IKTokenizer6x.java

        import java.io.IOException;
        import org.apache.lucene.analysis.Tokenizer;
        import org.apache.lucene.analysis.tokenattributes
              .CharTermAttribute;
        import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
        import org.apache.lucene.analysis.tokenattributes.TypeAttribute;
        import org.wltea.analyzer.core.IKSegmenter;
        import org.wltea.analyzer.core.Lexeme;
        public class IKTokenizer6x extends Tokenizer {
            // IK分词器实现
            private IKSegmenter _IKImplement;
            // 词元文本属性
            private final CharTermAttribute termAtt;
            // 词元位移属性
            private final OffsetAttribute offsetAtt;
            // 词元分类属性
            //(该属性分类参考org.wltea.analyzer.core.Lexeme中的分类常量)
            private final TypeAttribute typeAtt;
            // 记录最后一个词元的结束位置
            private int endPosition;
            // Lucene 6.x Tokenizer适配器类构造函数;实现最新的Tokenizer接口
            public IKTokenizer6x(boolean useSmart){
                super();
                offsetAtt = addAttribute(OffsetAttribute.class);
                termAtt = addAttribute(CharTermAttribute.class);
                typeAtt = addAttribute(TypeAttribute.class);
                _IKImplement = new IKSegmenter(input, useSmart);
            }
            @Override
            public boolean incrementToken()throws IOException {
                clearAttributes();     // 清除所有的词元属性
                Lexeme nextLexeme = _IKImplement.next();
                if(nextLexeme ! = null){
                  // 将Lexeme转成Attributes
                termAtt.append(nextLexeme.getLexemeText());   // 设置词元文本
                termAtt.setLength(nextLexeme.getLength());    // 设置词元长度
                offsetAtt.setOffset(nextLexeme.getBeginPosition(),
                            nextLexeme.getEndPosition());      // 设置词元位移
                //记录分词的最后位置
                endPosition = nextLexeme.getEndPosition();
                typeAtt.setType(nextLexeme.getLexemeText());  // 记录词元分类
                return true;       // 返回true告知还有下个词元
              }
                return false;      // 返回false告知词元输出完毕
            }
            @Override
            public void reset()throws IOException {
                super.reset();
                _IKImplement.reset(input);
            }
            @Override
            public final void end(){
                int finalOffset = correctOffset(this.endPosition);
                offsetAtt.setOffset(finalOffset, finalOffset);
            }
        }

代码清单2-4 IKAnalyzer6x.java

        import org.apache.lucene.analysis.Analyzer;
        import org.apache.lucene.analysis.Tokenizer;
        public class IKAnalyzer6x extends Analyzer {
            private boolean useSmart;
            public boolean useSmart(){
                return useSmart;
            }
            public void setUseSmart(boolean useSmart){
                this.useSmart = useSmart;
            }
            public IKAnalyzer6x(){
                this(false); // IK分词器Lucene Analyzer接口实现类;
                              // 默认细粒度切分算法
            }
            // IK分词器Lucene Analyzer接口实现类;当为true时,分词器进行智能切分
            public IKAnalyzer6x(boolean useSmart){
                super();
                this.useSmart = useSmart;
            }
            // 重写最新版本的createComponents;重载Analyzer接口,构造分词组件
            @Override
            protected TokenStreamComponents createComponents(String
                      fieldName){
                Tokenizer _IKTokenizer = new IKTokenizer6x(this.useSmart());
                return new TokenStreamComponents(_IKTokenizer);
            }
        }

实例化IKAnalyzer6x即可使用IK分词器了,创建默认细粒度切分算法的IK Analyzer:

        Analyzer analyzer = new IKAnalyzer6x();

创建智能切分算法的IK Analyzer:

        Analyzer analyzer = new IKAnalyzer6x(true);

2.3.4 中文分词器对比

分词效果会直接影响到文档搜索的准确性,我们对比Lucene 6.0中自带的中文智能分词器SmartChineseAnalyzer和IK Analyzer,比较一下哪一个分词器的准确率更高,见代码清单2-5。

代码清单2-5 中文分词效果对比

    import java.io.IOException;
    import java.io.StringReader;
    import org.apache.lucene.analysis.Analyzer;
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.tokenattributes
          .CharTermAttribute;
    import tup.lucene.ik.IKAnalyzer6x;
    public class IkVSSmartcn {
    private static String str1 = "公路局正在治理解放大道路面积水问题。";
    private static String str2 = "IKAnalyzer是一个开源的,基于java语言开发
                                  的轻量级的中文分词工具包。";
    public static void main(String[] args)throws IOException {
            Analyzer analyzer = null;
            System.out.println("句子一: "+str1);
            System.out.println("SmartChineseAnalyzer分词结果:");
            analyzer = new SmartChineseAnalyzer();
            printAnalyzer(analyzer, str1);
            System.out.println("IKAnalyzer分词结果:");
            analyzer = new IKAnalyzer6x(true);
            printAnalyzer(analyzer, str1);
            System.out.println("------------------------------------");
            System.out.println("句子二:"+str2);
            System.out.println("SmartChineseAnalyzer分词结果:");
            analyzer = new SmartChineseAnalyzer();
            printAnalyzer(analyzer, str2);
            System.out.println("IKAnalyzer分词结果:");
            analyzer = new IKAnalyzer6x(true);
            printAnalyzer(analyzer, str2);
            analyzer.close();
        }
        public static void printAnalyzer(Analyzer analyzer, String str)
            throws IOException {
            StringReader reader = new StringReader(str);
            TokenStream toStream = analyzer.tokenStream(str, reader);
            toStream.reset();      // 清空流
            CharTermAttribute teAttribute = toStream.getAttribute(
                    CharTermAttribute.class);
            while(toStream.incrementToken()){
                System.out.print(teAttribute.toString()+ "|");
            }
            System.out.println();
        }
    }

运行结果如下:

        句子一:公路局正在治理解放大道路面积水问题。
        SmartChineseAnalyzer分词结果:
            公路局|正|在|治理|解放|大|道路|面积|水|问题|
        IKAnalyzer分词结果:
            公路局|正在|治理|解放|大道|路面|积水|问题|
        -------------------------------------------
        句子二:IKAnalyzer是一个开源的,基于java语言开发的轻量级的中文分词工具包。
        SmartChineseAnalyzer分词结果:
        ikanalyz|是|一个|开|源|的|基于|java|语言|开发|的|轻量级|的|中文|分词|工具包|
        IKAnalyzer分词结果:
        ikanalyzer|是|一个|开源|的|基于|java|语言|开发|的|轻量级|的|中文|分词|工具包

从分词结果中可以看到,对于句子一,SmartChineseAnalyzer没有把“正在”分成一个词,“大道路面积水”分成了“大”“道路”“面积”“水”,而IK Analyzer把“正在”分成一个词,把“大道路面积水”分成了“大道”“路面”“积水”。对于句子二,两者的分词效果差不多,但是SmartChineseAnalyzer把“开源”分开了。总体而言,IK Analyzer的中文分词的准确性比SmartChineseAnalyzer要高一些。我们在以后的案例和项目中也会选择IK Analyzer作为我们的中文分词器。

开源的中分词工具有很多,比如ICTCLAS中文分词、Paoding分词、jcseg分词等,这里只对比了Lucene自带的中文智能分词器和IK分词器,读者如果有兴趣可以继续研究。

2.3.5 扩展停用词词典

IK Analyzer默认的停用词词典为IKAnalyzer2012_u6/stopword.dic,这个停用词词典并不完整,只有30多个英文停用词,推荐使用扩展的停用词词表(下载地址:https://github.com/cseryp/stopwords)。在工程中新增文件ext_stopword.dic,文件和IKAnalyzer.cfg.xml在同一目录,编辑IKAnalyzer.cfg.xml把新增的停用词字典写入配置文件,多个停用词字典用逗号隔开,配置如下:

        <entry key="ext_stopwords">stopword.dic; ext_stopword.dic</entry>

2.3.6 扩展自定义词典

IK Analyzer也支持自定义词典,在IKAnalyzer.cfg.xml同一目录新建ext.dic,把新的词语按行写入文件,然后编辑IKAnalyzer.cfg.xml,把新增的停用词字典写入配置文件,多个字典用空格隔开,配置如下:

        <entry key="ext_dict">ext.dic; </entry>

比如,对于网络流行语“厉害了我的哥”,默认的词库中没有这个词,要在自定义词典中将其写入以后才能分成一个词。测试自定义词典的代码见代码清单2-6。

代码清单2-6 自定义词典测试

        import java.io.IOException;
        import java.io.StringReader;
        import org.apache.lucene.analysis.Analyzer;
        import org.apache.lucene.analysis.TokenStream;
        import org.apache.lucene.analysis.tokenattributes
              .CharTermAttribute;
        import tup.lucene.ik.IKAnalyzer6x;
        public class ExtDicTest {
            private static String str = "厉害了我的哥!中国环保部门即将发布治理北京
                                        雾霾的方法!";
            public static void main(String[] args)throws IOException {
                Analyzer analyzer = new IKAnalyzer6x(true);
                StringReader reader = new StringReader(str);
                TokenStream toStream = analyzer.tokenStream(str, reader);
                toStream.reset();
                CharTermAttribute   teAttribute= toStream.getAttribute(
                        CharTermAttribute.class);
                System.out.println("分词结果:");
                while(toStream.incrementToken()){
                    System.out.print(teAttribute.toString()+ "|");
                }
                System.out.println("\n");
                analyzer.close();
            }
        }

运行结果:

        加载扩展词典:ext.dic
        加载扩展停止词典:stopword.dic
        分词结果:
        厉|害了|的哥|中国|环保部门|发布|治理|北京|雾|霾|方法|
        在ext.dic中添加自定义词项:
        中国环保部门
        北京雾霾
        厉害了我的哥
        再次运行,结果如下:
        加载扩展词典:ext.dic
        加载扩展停止词典:stopword.dic
        分词结果:
        厉害了我的哥|中国环保部门|发布|治理|北京雾霾|方法|