2.4 Lucene索引详解

介绍完Lucene的分词器,我们接着介绍Lucene是如何索引文档的,索引文档就是把文档变成索引这种数据结构的过程。

2.4.1 Lucene字段类型

文档是Lucene索引的基本单位,比文档更小的单位是字段,字段是文档的一部分,每个字段由3部分组成:名称(name)、类型(type)和取值(value)。字段的取值一般为文本类型(字符串、字符流等)、二进制类型和数值类型。Lucene中的字段类型主要有以下几种:

● TextField

TextField会把该字段的内容索引并词条化,但是不保存词向量。比如,包含整篇文档内容的body字段,常常使用TextField类型进行索引。

● StringField StringField只会对该字段的内容索引,但是并不词条化,也不保存词向量。字符串的值会被索引为一个单独的词项。比如,有个字段是国家名称,字段名为“country”,以国家“阿尔吉利亚”为例,只索引不词条化是最合适的。

● IntPoint

IntPoint适合索引值为int类型的字段。IntPoint是为了快速过滤的,如果需要展示出来需要另存一个字段。比如,商品的数量用字段“productCount”存储,根据商品数量进行过滤操作时可以直接通过productCount字段获取结果,但是要想展示商品数量,需要另外再存储一个字段。

● LongPoint

用法和IntPoint类似,区别在LongPoint适合索引值为长整型long类型的字段。

● FloatPoint

用法和IntPoint类似,区别在FloatPoint适合索引值为float类型的字段。

● DoublePoint

用法和IntPoint类似,区别在DoublePoint适合索引值为double类型的字段。

● SortedDocValuesField

存储值为文本内容的DocValue字段,SortedDocValuesField适合索引字段值为文本内容并且需要按值进行排序的字段。

● SortedSetDocValuesField

存储多值域的DocValues字段,SortedSetDocValuesField适合索引字段值为文本内容并且需要按值进行分组、聚合等操作的字段。

● NumericDocValuesField

存储单个数值类型的DocValues字段,主要包括(int, long, float, double)。

● SortedNumericDocValuesField

存储数值类型的有序数组列表的DocValues字段。

● StoredField

StoredField适合索引只需要保存字段值不进行其他操作的字段。

注:DocValues是Lucene 4.X版本以后新增的重要特性,我们都知道,Lucene是使用经典的倒排索引的模式来达到快速检索的目的,简单地说,就是建立词项和文档id的关系映射,在搜索时,通过类似hash算法来快速定位到一个搜索关键词,然后读取文档id集合,这样搜索数据是非常高效快速的,当然它也存在一定的缺陷。假如我们需要对数据做一些聚合操作,例如排序、分组时,Lucene内部会遍历提取所有出现在文档集合的排序字段,然后再次构建一个最终的排好序的文档集合,这个过程全部维持在内存中操作,而且如果排序数据量巨大的话,非常容易造成内存溢出和性能缓慢。基于此,在lucene 4.X之后出现了DocValues这一新特性,DocValues其实是Lucene在构建索引时额外建立一个有序的基于document=>field/value的映射列表。在构建索引时会对开启docvalues的字段额外构建一个已经排好序的文档到字段级别的一个列式存储映射,它减轻了在排序和分组时对内存的依赖,而且大大提升了这个过程的性能,当然它也会耗费一定的磁盘空间。

2.4.2 索引文档示例

首先新建一个代表新闻的实体类News.java,为了简单起见,我们给新闻对象设置新闻id、新闻标题、新闻内容和评论数4个属性,然后提供相应的构造方法、Setter和Getter方法,代码见代码清单2-7。

代码清单2-7 News.java

          public class News {
              private int id;
              private String title;
              private String content;
              private int reply;
              public News(){
              }
              public News(int id, String title, String content, int reply){
                  super();
                  this.id = id;
                  this.title = title;
                  this.content = content;
                  this.reply = reply;
              }
              public int getId(){
                  return id;
              }
              public void setId(int id){
                  this.id = id;
              }
              public String getTitle(){
                  return title;
              }
              public void setTitle(String title){
                  this.title = title;
              }
              public String getContent(){
                  return content;
              }
              public void setContent(String content){
                  this.content = content;
              }
              public int getReply(){
                  return reply;
              }
              public void setReply(int reply){
                  this.reply = reply;
              }
          }

Lucene索引文档要依靠一个IndexWriter对象,创建IndexWriter需要提供两个参数,一个是IndexWriterConfig对象,该对象可以设置创建索引使用哪种分词器,另一个是索引的保存路径。IndexWriter对象的addDocument()方法用于添加文档,该方法的参数为Document对象。IndexWriter对象一次可以添加多个文档,最后调用commit()方法生成索引。CreateIndex.java是我们创建索引的代码,我们首先创建了3个News对象,每条新闻添加上新闻ID、新闻标题、新闻内容和新闻的评论条数,后面的代码会把这3个News对象写入Lucene索引。IKAnalyzer对象用于指定创建索引时的分词器,它作为参数传入IndexWriterConfig()方法中实例化一个IndexWriterConfig对象。IndexWriterConfig对象的setOpenMode()方法可以设置索引的打开方式,传入OpenMode.CREATE参数表示先清空索引再重新创建,传入CREATE_OR_APPEND参数表示如果索引不存在会新建,已存在则附加。Directory对象用于表示索引的位置,把索引路径和IndexWriterConfig对象传入IndexWriter()方法,实例化IndexWriter对象,之后就可以通过IndexWriter对象进行文档的操作。

文档是Lucene索引和搜索的基本单位,在代码中Document类表示文档,比文档更小的单位是域,也可以称为字段,一个文档可以有多个域。FieldType对象用于指定域的索引信息,例如是否解析、是否存储、是否保存词项频率、位移信息等。FieldType对象的setIndexOptions()方法可以设定域的索引选项,可选参数及含义如下:

● IndexOptions.DOCS

只索引文档,词项频率和位移信息不保存。

● IndexOptions.DOCS_AND_FREQS

只索引文档和词项频率,位移信息不保存。

● IndexOptions.DOCS_AND_FREQS_AND_POSITIONS

索引文档、词项频率和位移信息。

● IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS

索引文档、词项频率、位移信息和偏移量。

● IndexOptions.NONE

不索引。

全文搜索中很重要的一项需求是关键字的高亮,要想准确地获取位置信息以及一些偏移量就需要在创建索引的时候进行记录,如果信息不准确,那么可能在搜索的时候就会发生错位,反映到网页上就是标注了不该标注的字,没有标注该标的内容。在索引的时候,可以使用FieldType对象提供的方法设置相对增量和位移信息。

● setStored(boolean value)

参数默认值为false,设置为true存储字段值。

● setTokenized(boolean value)

参数设置为true,会使用配置的分词器对字段值进行词条化。

● setStoreTermVectors(boolean value)

参数为true,保存词向量。

● setStoreTermVectorPositions(boolean value)

参数为true,保存词项在词向量中的位移信息。

● setStoreTermVectorOffsets(boolean value)

参数为true,保存词项在词向量中的偏移信息。

代码清单2-8 CreateIndex.java

        import java.io.IOException;
        import java.nio.file.Files;
        import java.nio.file.Path;
        import java.nio.file.Paths;
        import java.util.Date;
        import org.apache.lucene.analysis.Analyzer;
        import org.apache.lucene.document.Document;
        import org.apache.lucene.document.Field;
        import org.apache.lucene.document.FieldType;
        import org.apache.lucene.document.IntPoint;
        import org.apache.lucene.document.SortedNumericDocValuesField;
        import org.apache.lucene.document.StoredField;
        import org.apache.lucene.index.IndexOptions;
        import org.apache.lucene.index.IndexWriter;
        import org.apache.lucene.index.IndexWriterConfig;
        import org.apache.lucene.index.IndexWriterConfig.OpenMode;
        import org.apache.lucene.store.Directory;
        import org.apache.lucene.store.FSDirectory;
        import tup.lucene.ik.IKAnalyzer6x;
        public class CreateIndex {
            public static void main(String[] args){
                // 创建3个News对象
                News news1 = new News();
                news1.setId(1);
                news1.setTitle("习近平会见美国总统奥巴马,学习国外经验");
                news1.setContent("国家主席习近平9月3日在杭州西湖国宾馆会见前来出席
                                二十国集团领导人杭州峰会的美国总统奥巴马...");
                news1.setReply(672);
                News news2 = new News();
                news2.setId(2);
                news2.setTitle("北大迎4380名新生 农村学生700多人近年最多");
                news2.setContent("昨天,北京大学迎来4380名来自全国各地及数十个国家
                              的本科新生。其中,农村学生共700余名,为近年最多...");
                news2.setReply(995);
                News news3 = new News();
                news3.setId(3);
                news3.setTitle("特朗普宣誓(Donald Trump)就任美国第45任总统");
                news3.setContent("当地时间1月20日,唐纳德·特朗普在美国国会宣誓就
                                  职,正式成为美国第45任总统。");
                news3.setReply(1872);
                // 创建IK分词器
                Analyzer analyzer = new IKAnalyzer6x();
                IndexWriterConfig icw = new IndexWriterConfig(analyzer);
                icw.setOpenMode(OpenMode.CREATE);
                Directory dir = null;
                IndexWriter inWriter = null;
                // 索引目录
                Path indexPath = Paths.get("indexdir");
                // 开始时间
                Date start = new Date();
                try {
                    if(! Files.isReadable(indexPath)){
                      System.out.println("Document directory '" + indexPath.
                        toAbsolutePath()+ "' does not exist or is not
                        readable, please check the path");
                      System.exit(1);
                    }
                    dir = FSDirectory.open(indexPath);
                    inWriter = new IndexWriter(dir,icw);
                    //设置新闻ID索引并存储
                    FieldType idType = new FieldType();
                    idType.setIndexOptions(IndexOptions.DOCS);
                    idType.setStored(true);
                    //设置新闻标题索引文档、词项频率、位移信息和偏移量,存储并词条化
                    FieldType titleType = new FieldType();
                    titleType.setIndexOptions(IndexOptions.
                    DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
                    titleType.setStored(true);
                    titleType.setTokenized(true);
                    FieldType contentType = new FieldType();
                    contentType.setIndexOptions(IndexOptions.
                    DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
                    contentType.setStored(true);
                    contentType.setTokenized(true);
                    contentType.setStoreTermVectors(true);
                    contentType.setStoreTermVectorPositions(true);
                    contentType.setStoreTermVectorOffsets(true);
                    contentType.setStoreTermVectorPayloads(true);
                    Document doc1 = new Document();
                    doc1.add(new Field("id",String.valueOf(news1.getId()),
                            idType));
                    doc1.add(new Field("title",news1.getTitle(),
                            titleType));
                    doc1.add(new Field("content",news1.getContent(),
                                contentType));
                    doc1.add(new IntPoint("reply",news1.getReply()));
                    doc1.add(new StoredField("reply_display",
                                news1.getReply()));
                    Document doc2 = new Document();
                    doc2.add(new Field("id",String.valueOf(news2.getId()),
                              idType));
                    doc2.add(new Field("title",news2.getTitle(),
                            titleType));
                    doc2.add(new Field("content",news2.getContent(),
                            contentType));
                    doc2.add(new IntPoint("reply",news2.getReply()));
                    doc2.add(new StoredField("reply_display",
                             news2.getReply()));
                    Document doc3 = new Document();
                    doc3.add(new Field("id", String.valueOf(news3.getId()),
                             idType));
                    doc3.add(new Field("title"news3.getTitle(), titleType));
                    doc3.add(new Field("content", news3.getContent(),
                             contentType));
                    doc3.add(new IntPoint("reply", news3.getReply()));
                    doc3.add(new StoredField("reply_display", news3.
                             getReply()));
                    inWriter.addDocument(doc1);
                    inWriter.addDocument(doc2);
                    inWriter.addDocument(doc3);
                    inWriter.commit();
                    inWriter.close();
                    dir.close();
                } catch(IOException e){
                    e.printStackTrace();
                }
                Date end = new Date();
                System.out.println("索引文档用时:" +(end.getTime()- start.
                    getTime()) + " milliseconds");
            }
        }

运行结果:

        **********开始创建索引**********
        加载扩展词典:ext.dic
        加载扩展停止词典:stopword.dic
        加载扩展停止词典:ext_stopword.dic
        索引文档用时:884 milliseconds
        **********索引创建完成**********
        刷新工程,在indexdir目录下可以看到生成的索引文件:
        _0.cfe       _0.cfs       _0.si         segments_1       write.lock

2.4.3 Luke中查看索引

索引创建完成以后生成了一批特殊格式的文件,可以使用索引查看工具Luke来查看。启动Luke, Path选为Lucene索引的位置,在本例中为LuceneDemo/indexdir的绝对路径。初次启动Luke的界面如图2-9所示。

图2-9 Luke中打开索引路径

Luke启动之后,Overview选项卡界面显示了打开的索引的基本信息:索引路径、字段数、文档数、词项数、索引版本、词项频率等,如图2-10所示。

图2-10 Luke中查看索引

切换到Documents选项卡即可查询写入的文档,Luke查看索引结果如图2-11所示。可以看到reply字段为空,是因为IntPoint字段类型是为了快速过滤的,如果需要展示出来需要另存一个字段,我们用reply_display来存储。

图2-11 Luke中查看索引

2.4.4 索引的删除

上一小节介绍了Lucene是如何索引文档的,索引同样存在CRUD操作,这一小节通过示例介绍索引的删除和更新操作。删除与更新和新增一样,也是通过IndexWriter对象来操作的,IndexWrite对象的deleteDocuments()方法用于实现索引的删除,updateDocument()方法用于实现索引的更新。删除索引的代码见代码清单2-9,该示例实现了根据Term来删除单个或多个Document,删除title中包含关键词“美国“的文档。

代码清单2-9

        import java.io.IOException;
        import java.nio.file.Path;
        import java.nio.file.Paths;
        import org.apache.lucene.analysis.Analyzer;
        import org.apache.lucene.index.IndexWriter;
        import org.apache.lucene.index.IndexWriterConfig;
        import org.apache.lucene.index.Term;
        import org.apache.lucene.store.Directory;
        import org.apache.lucene.store.FSDirectory;
        import tup.lucene.ik.IKAnalyzer6x;
        public class DeleteIndex {
            public static void main(String[] args){
                // 删除title中含有关键词“美国”的文档
                deleteDoc("title", "美国");
            }
            public static void deleteDoc(String field, String key){
                Analyzer analyzer = new IKAnalyzer6x();
                IndexWriterConfig icw = new IndexWriterConfig(analyzer);
                Path indexPath = Paths.get("indexdir");
                Directory directory;
                try {
                    directory = FSDirectory.open(indexPath);
                    IndexWriter indexWriter = new IndexWriter(directory,
                                              icw);
                    indexWriter.deleteDocuments(new Term(field, key));
                    indexWriter.commit();
                    indexWriter.close();
                    System.out.println("删除完成!");
                } catch(IOException e){
                    e.printStackTrace();
                }
            }
        }

除此之外,IndexWriter还提供了以下方法:

● DeleteDocuments(Query query):根据Query条件来删除单个或多个Document。

● DeleteDocuments(Query[] queries):根据Query条件来删除单个或多个Document。

● DeleteDocuments(Term term):根据Term来删除单个或多个Document。

● DeleteDocuments( Term[] terms):根据Term来删除单个或多个Document。

● DeleteAll(): 删除所有的Document。

使用IndexWriter进行Document删除操作时,文档并不会立即被删除,而是把这个删除动作缓存起来,当IndexWriter.Commit()或IndexWriter.Close()时,删除操作才会被真正执行。

2.4.5 索引的更新

索引更新操作实质上是先删除索引,再重新建立新的文档,见代码清单2-10。

代码清单2-10

        import java.nio.file.Path;
        import java.nio.file.Paths;
        import org.apache.lucene.analysis.Analyzer;
        import org.apache.lucene.document.Document;
        import org.apache.lucene.document.Field.Store;
        import org.apache.lucene.document.TextField;
        import org.apache.lucene.index.IndexWriter;
        import org.apache.lucene.index.IndexWriterConfig;
        import org.apache.lucene.index.Term;
        import org.apache.lucene.store.Directory;
        import org.apache.lucene.store.FSDirectory;
        import tup.lucene.ik.IKAnalyzer6x;
        public class UpdateIndex {
          public static void main(String[] args){
                Analyzer analyzer = new IKAnalyzer6x();
                IndexWriterConfig icw = new IndexWriterConfig(analyzer);
                Path indexPath = Paths.get("indexdir");
                Directory directory;
                try {
                    directory = FSDirectory.open(indexPath);
                    IndexWriter indexWriter = new IndexWriter(directory, icw);
                    Document doc = new Document();
                    doc.add(new TextField("id", "2", Store.YES));
                    doc.add(new TextField("title", "北京大学开学迎来4380名新生
                            ", Store.YES));
                    doc.add(new TextField("content", " 昨天,北京大学迎来4380名
                          来自全国各地及数十个国家的本科新生。其中,农村学生共700余
                          名,为近年最多...", Store.YES));
                    indexWriter.updateDocument(new Term("title", "北大"),
                          doc);
                    indexWriter.commit();
                    indexWriter.close();
                } catch(Exception e){
                    e.printStackTrace();
                }
            }
        }

上面的代码中新建了一个IndexWriter对象和Document对象,通过updateDocument()方法完成更新操作。Term对象用于定位文档,查找title中含有“北大”的文档,然后用新的文档替换原文档,这样就完成了索引的更新操作。