Lucene搭建搜索引擎初探

最近要做例句搜索的优化,因此重新看一看lucene,边学习边搭demo。由于平时使用惯了python,所以这一次使用pylucene做demo。本文着重于lucene的介绍,一些内容主要参考了niyanchun的博客,并增加了几个pylucene的示例代码。

配置Pylucene环境

安装pylucene

安装JCC

  • cd pylucene-8.1.1/jcc
  • setup.py中修改jdk位置
  • 如果是MACOS
    • export CC=/usr/bin/clang
    • export CXX=/usr/bin/clang++
  • python setup.py build
  • python setup.py install

安装ANT

安装lucene

  • cd pylucene-8.1.1
  • vi Makefile
    • PREFIX_PYTHON=…./conda-env
    • PYTHON=$(PREFIX_PYTHON)/bin/python
    • ANT=…/apache-ant-1.9.14/bin/ant
    • JCC=$(PYTHON) -m jcc.main
    • NUM_FILES=8
  • make
  • make install

术语总结

索引整体的逻辑结构图,如下所示:

图片

索引

对于初学全文检索的人来说,索引这个词非常具有迷惑性,主要原因是它有两个词性:

  • 动词:做动词时,一般英文写为“indexing”,比如“索引一个文件”翻译为“indexing a file”,它指的是我们将原始数据经过一系列的处理,最终形成可以高效全文检索(对于Lucene,就是生成倒排索引)的过程。这个过程就称之为索引(indexing)
  • 名词:做名词时,写为“index”。经过indexing最终形成的结果(一般以文件形式存在)称之为索引(index)

所以,见到索引这个词,你一定要分清楚是动词还是名词。后面为了清楚,凡是作为动词的时候我使用indexing,作为名词的时候使用index。Index是Lucene中的顶级逻辑结构,它是一个逻辑概念,如果对应到具体的实物,就是一个目录,目录里面所有的文件组成一个index。注意,这个目录里面不会再嵌套目录,只会包含多个文件。具体index的构成细节后面会专门写一篇文章来介绍。对应到代码里面,就是org.apache.lucene.store.Directory这个抽象类。最后要说明一点的是,Lucene中的Index和ElasticSearch里面的Index不是一个概念,ElasticSearch里面的shard对应的才是Lucene的Index。

文档(Document)和字段(Field)

一个Index里面会包含若干个文档,文档就像MySQL里面的一行(record)或者HBase里面的一列。文档是Lucene里面索引和搜索的原子单位,就像我们在MySQL里面写数据的时候,肯定是以行为单位的;读的时候也是以行为单位的。当然我们可以指定只读/写行里面某些字段,但仍是以行为单位的,Lucene也是一样,以文档为最小单位。代码里面是这样说明的:”Documents are the unit of indexing and search“.每个文档都会有一个唯一的文档ID。

文档是一个灵活的概念,不同的业务场景对应的具体含义不同。对于搜索引擎来说,一个文档可能就代表爬虫爬到的一个网页,很多个网页(文档)组成了一个索引。而对于提供检索功能的邮件客户端来说,一个文档可能就代表一封邮件,很多封邮件(文档)组成了一个索引。再比如假设我们要构建一个带全文检索功能的商品管理系统,那一件商品就是一个文档,很多个商品组成了一个索引。对于日志处理,一般是一行日志代表一个文档。

文档里面包含若干个字段,真正的数据是存储在字段里面的。一个字段包含三个要素:名称、类型、值。我们要索引数据,必须将数据以文本形式存储到字段里之后才可以。Lucene的字段由一个key-value组成,就像map一样。value支持多种类型,如果value是一个map类型,那就是嵌套字段了。

最后需要注意的是,不同于传统的关系型数据库,Lucene不要求一个index里面的所有文档的字段要一样,如果你喜欢,每一条文档的结构都可以不一样(当然实际中不建议这样操作),而且不需要事先定义,这个特性一般称之为“flexible schema”。传统的关系型数据库要求一个表里面的所有字段的结构必须一致,而且必事先定义好,一般称之为“strict schema”或者”fixed schema“。比如,有一个名为“mixture”的索引包含3条Document,如下:

1
2
3
4
{
    { "name": "Ni Yanchun", "gender": "male", "age": 28  },
    { "name": "Donald John Trump", "gender": "male", "birthday": "1946.06.14"},
    { "isbn": "978-1-933988-17-7", "price": 60, "publish": "2010", "topic": ["lucene", "search"]}

}

可以看到,3条Document的字段并不完全一样,这在Lucene中是合法的。

Token和Term

Token存储在字段中的文本数据经过分词器分词后(准确的说是经过Tokenizer处理之后)产生的一系列词或者词组。比如假设有个”content”字段的存储的值为”My name is Ni Yanchun”,这个字段经过Lucene的标准分词器分词后的结果是:”my”, “name”, “is”, “ni”, “yanchun”。这里的每个词就是一个token,当然实际上除了词自身外,token还会包含一些其它属性。后面的文章中会介绍这些属性。

一个token加上它原来所属的字段的名称构成了Term。比如”content”和”my”组成一个term,”content”和”name”组成另外一个term。我们检索的时候搜的就是Term,而不是Token或者Document(但搜到term之后,会找到包含这个term的Document,然后返回整个Document,而不是返回单个Term)。

Index Segment

在上面的图中,Document分别被一些绿框括了起来,这个称为Segment。Indexing的时候,并不是将所有数据写到一起,而是再分了一层,这层就是segment。Indexing的时候,会先将Document缓存,然后定期flush到文件。每次flush就会生成一个Segment。所以一个Index包含若干个Segment,每个Segment包含一部分Document。为了减少文件描述符的使用,这些小的Segment会定期的合并为(merge)大的Segment,数据量不大的时候,合并之后一个index可能只有一个Segment。搜索的时候,会搜索各个Segment,然后合并搜索结果。

Analyzer

参考:
https://gist.github.com/Sennahoi/740753384999add46fc1
https://niyanchun.com/lucene-learning-4.html

原理

Analyzer像一个数据加工厂,输入是原始的文本数据,输出是经过各种工序加工的term,然后这些terms以倒排索引的方式存储起来,形成最终用于搜索的Index。所以Analyzer也是我们控制数据能以哪些方式检索的重要点。

内置的Analyzer对比

Lucene已经帮我们内置了许多Analyzer,我们先来挑几个常见的对比一下他们的分析效果吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.niyanchun;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.core.KeywordAnalyzer;
import org.apache.lucene.analysis.core.SimpleAnalyzer;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.analysis.en.EnglishAnalyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
public class AnalyzerCompare {

    private static final Analyzer[] ANALYZERS = new Analyzer[]{
            new WhitespaceAnalyzer(), // 仅根据空白字符(whitespace)进行分词。
            new KeywordAnalyzer(), // 不做任何分词,把整个原始输入作为一个token。所以可以看到输出只有1个token,就是原始句子。
            new SimpleAnalyzer(), // 根据非字母(non-letters)分词,并且将token全部转换为小写。所以该分词的输出的terms都是由小写字母组成的。
            new StandardAnalyzer(EnglishAnalyzer.getDefaultStopSet()) // 基于JFlex进行语法分词,然后删除停用词,并且将token全部转换为小写。标准分词器会处理停用词,但默认其停用词库为空,这里我们使用英文的停用词};
    public static void main(String[] args) throws Exception {
        String content = "My name is Ni Yanchun, I'm 28 years old. You can contact me with the email niyanchun@outlook.com";
        System.out.println("原始数据:\n" + content + "\n\n分析结果:");
        for (Analyzer analyzer : ANALYZERS) {
            showTerms(analyzer, content);
        }
    }

    private static void showTerms(Analyzer analyzer, String content) throws IOException {

        try (TokenStream tokenStream = analyzer.tokenStream("content", content)) {
            StringBuilder sb = new StringBuilder();
            AtomicInteger tokenNum = new AtomicInteger();
            tokenStream.reset();
            while (tokenStream.incrementToken()) {
                tokenStream.reflectWith(((attClass, key, value) -> {
                    if ("term".equals(key)) {
                        tokenNum.getAndIncrement();
                        sb.append("\"").append(value).append("\", ");
                    }
                }));
            }
            tokenStream.end();

            System.out.println(analyzer.getClass().getSimpleName() + ":\n" + tokenNum + " tokens: [" + sb.toString().substring(0, sb.toString().length() - 2) + "]");
}
    }
}

这段代码的功能是使用常见的四种分词器(WhitespaceAnalyzer,KeywordAnalyzer,SimpleAnalyzer,StandardAnalyzer)对“My name is Ni Yanchun, I’m 28 years old. You can contact me with the email niyanchun@outlook.com”这句话进行analyze,输出最终的terms。其中需要注意的是,标准分词器会去掉停用词(stop word),但其内置的停用词库为空,所以我们传了一个英文默认的停用词库。运行代码之后的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
原始数据:
My name is Ni Yanchun, I'm 28 years old. You can contact me with the email niyanchun@outlook.com

分析结果:
WhitespaceAnalyzer:
17 tokens: ["My", "name", "is", "Ni", "Yanchun,", "I'm", "28", "years", "old.", "You", "can", "contact", "me", "with", "the", "email", "niyanchun@outlook.com"]
KeywordAnalyzer:
1 tokens: ["My name is Ni Yanchun, I'm 28 years old. You can contact me with the email niyanchun@outlook.com"]
SimpleAnalyzer:
19 tokens: ["my", "name", "is", "ni", "yanchun", "i", "m", "years", "old", "you", "can", "contact", "me", "with", "the", "email", "niyanchun", "outlook", "com"]
StandardAnalyzer:
15 tokens: ["my", "name", "ni", "yanchun", "i'm", "28", "years", "old", "you", "can", "contact", "me", "email", "niyanchun", "outlook.com"]

Analyzer原理

前面我们说了Analyzer就像一个加工厂,包含很多道工序。这些工序在Lucene里面分为两大类:Tokenizer和TokenFilter。Tokenizer永远是Analyzer的第一道工序,有且只有一个。它的作用是读取输入的原始文本,然后根据工序的内部定义,将其转化为一个个token输出。TokenFilter只能接在Tokenizer之后,因为它的输入只能是token。然后它将输入的token进行加工,输出加工之后的token。一个Analyzer中,TokenFilter可以没有,也可以有多个。也就是说一个Analyzer内部的流水线是这样的:

图片

比如StandardAnalyzer的流水线是这样的:

图片

所以,Analyzer的原理还是比较简单的,Tokenizer读入文本转化为token,然后后续的TokenFilter将token按需加工,输出需要的token。我们可以自由组合已有的Tokenizer和TokenFilter来满足自己的需求,也可以实现自己的Tokenizer和TokenFilter。

源码分析

Analyzer和TokenStream

Analyzer对应的实现类是org.apache.lucene.analysis.Analyzer,这是一个抽象类。它的主要作用是构建一个org.apache.lucene.analysis.TokenStream对象,该对象用于分析文本。代码中的类描述是这样的:

An Analyzer builds TokenStreams, which analyze text. It thus represents a policy for extracting index terms from text.

因为它是一个抽象类,所以实际使用的时候需要继承它,实现具体的类。比如第一部分我们使用的4个内置Analyzer都是直接或间接继承的该类。继承的子类需要实现createComponents方法,之前说的一系列工序就是加在这个方法里的,可以认为一道工序就是整个流水线中的一个Component。Analyzer抽象类还实现了一个tokenStream方法,并且是final的。该方法会将一系列工序转化为TokenStream对象输出。比如SimpleAnalyzer的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class SimpleAnalyzer extends Analyzer {
public SimpleAnalyzer() {
}

@Override
protected TokenStreamComponents createComponents(final String fieldName) {
Tokenizer tokenizer = new LetterTokenizer();
return new TokenStreamComponents(tokenizer, new LowerCaseFilter(tokenizer));
}

@Override
protected TokenStream normalize(String fieldName, TokenStream in) {
return new LowerCaseFilter(in);
}
}

TokenStream的作用就是流式的产生token。这些token可能来自于indexing时文档里面的字段数据,也可能来自于检索时的检索语句。其实就是之前说的indexing和查询的时候都会调用Analyzer。

Tokenizer和TokenFilter

TokenStream有两个非常重要的抽象子类:org.apache.lucene.analysis.Tokenizer和org.apache.lucene.analysis.TokenFilter。这两个类的实质其实都是一样的,都是对Token进行处理。不同之处就是前面介绍的,Tokenizer是第一道工序,所以它的输入是原始文本,输出是token;而TokenFilter是后面的工序,它的输入是token,输出也是token。实质都是对token的处理,所以实现它两个的子类都需要实现incrementToken方法,也就是在这个方法里面实现处理token的具体逻辑。incrementToken方法是在TokenStream类中定义的。比如前面提到的StandardTokenizer就是实现Tokenizer的一个具体子类;LowerCaseFilter和StopFilter就是实现TokenFilter的具体子类。

最后要说一下,Analyzer的流程越长,处理逻辑越复杂,性能就越差,实际使用中需要注意权衡。Analyzer的原理及代码就分析到这里,因为篇幅,一些源码没有在文章中全部列出,如果你有兴趣,建议去看下常见的Analyzer的实现的源码,一定会有收获。

基本使用

那么在python中我们怎么使用呢?下面举个小例子,使用pylucene其实和java的lucene没有太大差别,因为实际都是调用的java的类库。这里我没自定义过Analyzer,都是将输入进行处理后,变成用空格分好的结果,再送入到WhitespaceAnalyzer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from org.apache.lucene.analysis.core import WhitespaceAnalyzer
from org.apache.lucene.index import IndexWriter, IndexWriterConfig
from org.apache.lucene.store import SimpleFSDirectory
from org.apache.lucene.document import Document, Field
from org.apache.lucene.document import TextField
from org.apache.lucene.search import BooleanQuery
from org.apache.lucene.queryparser.classic import QueryParser
# 声明
lucene_analyzer = WhitespaceAnalyzer()
# 在建索引中使用analyzer
sentence = 'i am so confused .'
config = IndexWriterConfig(self.lucene_analyzer)
index_writer = IndexWriter(SimpleFSDirectory(INDEXIDR), config)
document = Document()
document.add(Field('sentence', sentence, TextField.TYPE_STORED))
index_writer.addDocument(document)

# 在构建query时使用analyzer
query = 'confuse'
boolean_query = BooleanQuery.Builder()
simple_query = QueryParser("sentence", lucene_analyzer).parse(query)

倒排索引、Token与词向量

倒排索引(Inverted Index)和正向索引(Forward Index)

我们用一个例子来看什么是倒排索引,什么是正向索引。假设有两个文档(前面的数字为文档ID):

  • a good student.
  • a gifted student.

这两个文档经过Analyzer之后(这里我们不去停顿词),分别得到以下两个索引表:

图片

这两个表都是key-value形式的Map结构,该数据结构的最大特点就是可以根据key快速访问value。我们分别分析以下这两个表。

表1中,Map的key是一个个词,也就是上文中Analyzer的输出。value是包含该词的文档的ID。这种映射的好处就是如果我们知道了词,就可以很快的查出有哪些文档包含该词。大家想一下我们平时的检索是不是就是这种场景:我们知道一些关键字,然后想查有哪些网页包含该关键词。表1这种词到文档的映射结构就称之为倒排索引

表2中,Map的key是文档id,而value是该文档中包含的所有词。这种结构的映射的好处是只要我们知道了文档(ID),就能知道这个文档里面包含了哪些词。这种文档到词的映射结构称之为正向索引

倒排索引是文档检索系统最常用的数据结构,Lucene用的就是这种数据结构。那对于检索有了倒排索引是不是就够用了呢?我们来看一个搜索结果:

图片

这里我搜索了我年少时的偶像S.H.E,一个台湾女团,Google返回了一些包含该关键字的网页,同时它将网页中该关键字用红色字体标了出来。几乎所有的搜索引擎都有该功能。大家想一下,使用上述的倒排索引结构能否做到这一点?

答案是做不到的。倒排索引的结构只能让我们快速判断一个文档(上述例子中一个网页就是一个文档)是否包含该关键字,但无法知道关键字出现在文档中的哪个位置。那搜索引擎是如何知道的呢?其实使用的是另外一个结构——词向量,词向量和倒排索引的信息都是在Analyze阶段计算出来的。在介绍词向量之前,我们先来看一下Analyze的输出结果——Token。

Token

Token除了包含词以外,还存在一些其它属性,下面就让我们来看看完整的token长什么样?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;

public class AnalysisDebug {

    public static void main(String[] args) throws Exception {
        Analyzer analyzer = new StandardAnalyzer();
        String sentence = "a good student, a gifted student.";
        try (TokenStream tokenStream = analyzer.tokenStream("sentence", sentence)) {
            tokenStream.reset();

            while (tokenStream.incrementToken()) {
                System.out.println("token: " + tokenStream.reflectAsString(false));
            }
            tokenStream.end();
        }
    }
}

我们借助TokenStream对象输出经过StandardAnalyzer处理的数据,程序运行结果如下:

1
2
3
4
5
6
token: term=a,bytes=[61],startOffset=0,endOffset=1,positionIncrement=1,positionLength=1,type=<ALPHANUM>,termFrequency=1
token: term=good,bytes=[67 6f 6f 64],startOffset=2,endOffset=6,positionIncrement=1,positionLength=1,type=<ALPHANUM>,termFrequency=1
token: term=student,bytes=[73 74 75 64 65 6e 74],startOffset=7,endOffset=14,positionIncrement=1,positionLength=1,type=<ALPHANUM>,termFrequency=1
token: term=a,bytes=[61],startOffset=16,endOffset=17,positionIncrement=1,positionLength=1,type=<ALPHANUM>,termFrequency=1
token: term=gifted,bytes=[67 69 66 74 65 64],startOffset=18,endOffset=24,positionIncrement=1,positionLength=1,type=<ALPHANUM>,termFrequency=1
token: term=student,bytes=[73 74 75 64 65 6e 74],startOffset=25,endOffset=32,positionIncrement=1,positionLength=1,type=<ALPHANUM>,termFrequency=1

这个输出结果是非常值得探究的。可以看到sentence字段的文本数据”a good student, a gifted student”经过StandardAnalyzer分析之后输出了6个token,每个token由一些属性组成,这些属性对应的定义类在org.apache.lucene.analysis.tokenattributes包下面,有兴趣的可以查阅。这里我们简单介绍一下这些属性:

  • term:解析出来的词。注意这里的term不同于我们之前介绍的Term,它仅指提取出来的词
  • bytes:词的字节数组形式。
  • startOffset, endOffset:词开始和结束的位置,从0开始计数。大家可以数一下。
  • positionIncrement:当前词和上个词的距离,默认为1,表示词是连续的。如果有些token被丢掉了,这个值就会大于1了。可以将上述代码中注释掉的那行放开,同时将原来不带停用词的analyzer注释掉,这样解析出的停用词token就会被移除,你就会发现有些token的该字段的值会变成2。该字段主要用于支持”phrase search”, “span search”以及”highlight”,这些搜索都需要知道关键字在文档中的position,以后介绍搜索的时候再介绍。另外这个字段还有一个非常重要的用途就是支持同义词查询。我们将该某个token的positionIncrement置为0,就表示该token和上个token没有距离,搜索的时候,不论搜这两个token任何一个,都会返回它们两对应的文档。假设第一个token是土豆,下一个token是马铃薯,马铃薯对应的token的positionIncrement为0,那我们搜马铃薯时,也会给出土豆相关的信息,反之亦然。
  • positionLength:该字段跨了多少个位置。代码注释中说极少有Analyzer会产生该字段,基本都是使用默认值1.
  • type:字段类型。需要注意的是这个类型是由每个Analyzer的Tokenizer定义的,不同的Analyer定义的类型可能不同。比如StandardAnalyzer使用的StandardTokenizer定义了这几种类型:
  • termFrequency:词频。注意这里的词频不是token在句子中出现的频率,而是让用户自定义的,比如我们想让某个token在评分的时候更重要一些,那我们就可以将其词频设置大一些。如果不设置,默认都会初始化为1。比如上面输出结果中有两个”a”字段,词频都为初始值1,这个在后续的流程会合并,合并之后,词频会变为2。

除了以上属性外,还有一个可能存在的属性就是payload,我们可以在这个字段里面存储一些信息。以上就是一个完整的Token。

词向量(Term Vector)

Analyzer分析出来的Token并不会直接写入Index,还需要做一些转化:

  • 取token中的词,以及包含该词的字段信息、文档信息(doc id),形成词到字段信息、文档信息的映射,也就是我们前面介绍的倒排索引。
  • 取token中的词,以及包含该词的positionIncrement、startOffset、endOffset、termFrequency信息,组成从token到后面四个信息的映射,这就是词向量

所以,倒排索引和词向量都是从term到某个value的映射,只是value的值不一样。这里需要注意,倒排索引是所有文档范围内的,而词向量是某个文档范围的。简言之就是一个index对应一个倒排索引,而一个document就有一个词向量。有了倒排索引,我们就知道搜索关键字包含在index的哪些document的字段中。有了词向量,我们就知道关键字在匹配到的document的具体位置。下面让我们从代码角度来验证一下上面的理论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.*;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;

import java.nio.file.Paths;

public class TermVectorShow {

    public static void main(String[] args) throws Exception {
        // 构建索引
        final String indexPath = "indices/tv-show";
        Directory indexDir = FSDirectory.open(Paths.get(indexPath));

        Analyzer analyzer = new StandardAnalyzer();
        IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
        iwc.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
        IndexWriter writer = new IndexWriter(indexDir, iwc);

        String sentence = "a good student, a gifted student";
        // 默认不会保存词向量,这里我们通过一些设置来保存词向量的相关信息
        FieldType fieldType = new FieldType();
        fieldType.setStored(true);
        fieldType.setStoreTermVectors(true);
        fieldType.setStoreTermVectorOffsets(true);
        fieldType.setStoreTermVectorPositions(true);
fieldType.setIndexOptions(
IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        Field field = new Field("content", sentence, fieldType);
        Document document = new Document();
        document.add(field);
        writer.addDocument(document);
        writer.close();

        // 从索引读取Term Vector信息
        IndexReader indexReader = DirectoryReader.open(indexDir);
        Terms termVector = indexReader.getTermVector(0, "content");
        TermsEnum termIter = termVector.iterator();
        while (termIter.next() != null) {
            PostingsEnum postingsEnum = termIter.postings(null, PostingsEnum.ALL);
            while (postingsEnum.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
                int freq = postingsEnum.freq();
                System.out.printf("term: %s, freq: %d,", termIter.term().utf8ToString(), freq);
                while (freq > 0) {
                    System.out.printf(" nextPosition: %d,", postingsEnum.nextPosition());
                    System.out.printf(" startOffset: %d, endOffset: %d",
                            postingsEnum.startOffset(), postingsEnum.endOffset());
                    freq--;
                }
                System.out.println();
            }
        }
    }
}

这段代码实现的功能是先indexing 1条document,形成index,然后我们读取index,从中获取那条document content字段的词向量。需要注意,indexing时默认是不存储词向量相关信息的,我们需要通过FieldType做显式的设置,否则你读取出来的Term Vector会是null。我们看一下程序的输出结果:

1
2
3
4
term: a, freq: 2, nextPosition: 0, startOffset: 0, endOffset: 1 nextPosition: 3, startOffset: 16, endOffset: 17
term: gifted, freq: 1, nextPosition: 4, startOffset: 18, endOffset: 24
term: good, freq: 1, nextPosition: 1, startOffset: 2, endOffset: 6
term: student, freq: 2, nextPosition: 2, startOffset: 7, endOffset: 14 nextPosition: 5, startOffset: 25, endOffset: 32

这里我们indexing的数据和上一节token部分的数据是一样的,而且都使用的是StandardAnalyzer,所以我们可以对比着看上一节输出的token和这里输出的term vector数据。可以看到,之前重复的token(a和student)到这里已经被合并了,并且词频也相应的变成了2。然后我们看一下position信息和offset信息也是OK的。而像token中的positionLength、type等信息都丢弃了。
词向量的信息量比较大,所以默认并不记录,我们想要保存时需要针对每个字段做显式的设置,Lucene 8.2.0中包含如下一些选项(见org.apache.lucene.index.IndexOptions枚举类):

  • NONE:不索引
  • DOCS:只索引字段,不保存词频等位置信息
  • DOCS_AND_FREQS:索引字段并保存词频信息,但不保存位置信息
  • DOCS_AND_FREQS_AND_POSITIONS:索引字段并保存词频及位置信息
  • DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS:索引字段并保存词频、位置、偏移量等信息

phrase search和span search需要position信息支持,所以一般全文搜索引擎默认会采用DOCS_AND_FREQS_AND_POSITIONS策略,这样基本就能覆盖常用的搜索需求了。而需要高亮等功能的时候,才需要记录offset信息。

字段及其属性

在创建Field的时候,第一个参数是字段名,第二个是字段值,第三个就是字段属性了。字段的属性决定了字段如何Analyze,以及Analyze之后存储哪些信息,进而决定了以后我们可以使用哪些方式进行检索。

Field类

Field对应的类是org.apache.lucene.document.Field,该类实现了org.apache.lucene.document.IndexableField接口,代表用于indexing的一个字段。Field类比较底层一些,所以Lucene实现了许多Field子类,用于不同的场景,比如下图是IDEA分析出来的Field的子类:

图片

如果有某个子类能满足我们的场景,那推荐使用子类。在介绍常用子类之前,需要了解一下字段的三大类属性:

  • 是否indexing(只有indexing的数据才能被搜索)
  • 是否存储(即是否保存字段的原始值)
  • 是否保存term vector

这些属性就是由之前文章中介绍的org.apache.lucene.index.IndexOptions枚举类定义的:

  • NONE:不索引
  • DOCS:只索引字段,不保存词频等位置信息
  • DOCS_AND_FREQS:索引字段并保存词频信息,但不保存位置信息
  • DOCS_AND_FREQS_AND_POSITIONS:索引字段并保存词频及位置信息
  • DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS:索引字段并保存词频、位置、偏移量等信息

Field的各个子类就是实现了对不同类型字段的存储,同时选择了不同的字段属性,这里列举几个常用的:

  • TextField:存储字符串类型的数据。indexing+analyze;不存储原始数据;不保存term vector。适用于需要全文检索的数据,比如邮件内容,网页内容等。
  • StringField:存储字符串类型的数据。indexing但不analyze,即整个字符串就是一个token,之前介绍的KeywordAnalyzer就属于这种;不存储原始数据;不保存term vector。适用于文章标题、人名、ID等只需精确匹配的字符串。
  • IntPoint, LongPoint, FloatPoint, DoublePoint:用于存储各种不同类型的数值型数据。indexing;不存储原始数据;不保存term vector。适用于数值型数据的存储。

所以,对于Field及其子类我们需要注意以下两点:

  • 几乎所有的Field子类(除StoredField)都默认不存储原始数据,如果需要存储原始数据,就要额外增加一个StoredField类型的字段,专门用于存储原始数据。注意该类也是Field的一个子类。当然也有一些子类的构造函数中提供了参数来控制是否存储原始数据,比如StringField,创建实例时可以通过传递Field.Store.YES参数来存储原始数据。
  • Field子类的使用场景和对应的属性都已经设置好了,如果子类不能满足我们的需求,就需要对字段属性进行自定义,但子类的属性一般是不允许更改的,需要直接使用Field类,再配合FieldType类进行自定义化。

FieldType类

org.apache.lucene.document.FieldType类实现了org.apache.lucene.index.IndexableFieldType接口,用于描述字段的属性,如下:

1
2
3
4
5
6
7
FieldType fieldType = new FieldType();
fieldType.setStored(true);
fieldType.setStoreTermVectors(true);
fieldType.setStoreTermVectorOffsets(true);
fieldType.setStoreTermVectorPositions(true);
fieldType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
Field field = new Field("content", sentence, fieldType);

该类定义了一些成员变量,这些成员变量就是字段的一些属性,这里列一下代码中的成员变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private boolean stored;
private boolean tokenized = true;
private boolean storeTermVectors;
private boolean storeTermVectorOffsets;
private boolean storeTermVectorPositions;
private boolean storeTermVectorPayloads;
private boolean omitNorms;
private IndexOptions indexOptions = IndexOptions.NONE;
private boolean frozen;
private DocValuesType docValuesType = DocValuesType.NONE;
private int dataDimensionCount;
private int indexDimensionCount;
private int dimensionNumBytes;
private Map<String, String> attributes;

大部分属性含义已经比较清楚了,这里再简单介绍一下其含义:

  • stored:是否存储字段,默认为false。
  • tokenized:是否analyze,默认为true。
  • storeTermVectors:是否存储TermVector(如果是true,也不存储offset、position、payload信息),默认为false。
  • storeTermVectorOffsets:是否存储offset信息,默认为false。
  • storeTermVectorPositions:是否存储position信息,默认为false。
  • storeTermVectorPayloads:是否存储payload信息,默认为false。
  • omitNorms:是否忽略norm信息,默认为false。那什么是norm信息呢?Norm的全称是“Normalization”,理解起来非常简单,按照TF-IDF的计算方式,包含同一个搜索词的多个文本,文本越短其权重(或者叫相关性)越高。比如有两个文本都包含搜索词,一个文本只有100个词,另外文本一个有1000个词,那按照TF-IDF的算法,第一个文本跟搜索词的相关度比第二个文本高。这个信息就是norm信息。如果我们将其忽略掉,那在计算相关性的时候,会认为长文本和短文本的权重得分是一样的。
  • indexOptions:即org.apache.lucene.index.IndexOptions,已经介绍过了。默认值为DOCS_AND_FREQS_AND_POSITIONS。
  • frozen:该值设置为true之后,字段的各个属性就不允许再更改了,比如Field的TextField、StringField等子类都将该值设置为true了,因为他们已经将字段的各个属性定制好了。
  • dataDimensionCountindexDimensionCountdimensionNumBytes:这几个和数值型的字段类型有关系,Lucene 6.0开始,对于数值型都改用Point来组织。dataDimensionCount和indexDimensionCount都可以理解为是Point的维度,类似于数组的维度。dimensionNumBytes则是Point中每个值所使用的字节数,比如IntPoint和FloatPoint是4个字节,LongPoint和DoublePoint则是8个字节。
  • attributes:可以选择性的以key-value的形式给字段增加一些元数据信息,但注意这个key-value的map不是线程安全的。
  • docValuesType:指定字段的值指定以何种类型索引DocValue。那什么是DocValue?DocValue就是从文档到Term的一个正向索引,需要这个东西是因为排序、聚集等操作需要根据文档快速访问文档内的字段。Lucene 4.0之前都是在查询的时候将所有文档的相关字段信息加载到内存缓存,一方面用的时候才加载,所以慢,另一方面对于内存压力很大。4.0中引入了DocValue的概念,在indexing阶段除了创建倒排索引,也可以选择性的创建一个正向索引,这个正向索引就是DocValue,主要用于排序、聚集等操作。其好处是存储在磁盘上,减小了内存压力,而且因为是事先计算好的,所以使用时速度也很快。弊端就是磁盘使用量变大(需要耗费 document个数*每个document的字段数 个字节),同时indexing的速度慢了。

对于我们使用(包括ES、Solr等基于Lucene的软件)而言,只需要知道我们检索一个字段的时候可以控制保存哪些信息,以及这些信息在什么场景下使用,能带来什么好处,又会产生什么弊端即可。举个例子:比如我们在设计字段的时候,如果一个字段不会用来排序,也不会做聚集,那就没有必要生成DocValue,既能节省磁盘空间,又能提高写入速度。另外对于norm信息,如果你的场景只关注是否包含,那无需保存norm信息,但如果也关注相似度评分,并且文本长短是一个需要考虑的因素,那就应该保存norm信息。

基本使用

参考:
https://blog.51cto.com/8744704/2086852
https://www.cnblogs.com/leeSmall/p/9011405.html
https://www.amazingkoala.com.cn/Lucene/DocValues/2019/0412/48.html
https://www.cnblogs.com/cnjavahome/p/9192467.html

在例句搜索中主要使用了以下几种FieldType:

  • TextField
  • IntPoint
    • 把整型存入索引中,必须同时加入NumericDocValuesField和StoredField,是看IntPoint源码注释中写的,不知道为什么一定要这样写
  • FloatPoint
    • 把浮点数存入索引中,必须同时加入FloatDocValuesField和StoredField
  • 数组类型
    • 复杂类型存储
1
2
3
4
5
6
7
8
9
10
11
12
13
confidence = int(data_json['confidence'])
document.add(IntPoint("confidence", confidence))
document.add(NumericDocValuesField("confidence", confidence))
document.add(StoredField("confidence", confidence))

score = float(data_json['score'])
document.add(FloatPoint("score", score))
document.add(FloatDocValuesField("score", score))
document.add(StoredField("score", score))

document.add(SortedSetDocValuesField("keyword", BytesRef(keyword_text)))
document.add(StoredField("keyword", keyword_text))
document.add(Field("keyword", keyword_text, TextField.TYPE_STORED))

索引存储文件介绍

索引文件格式

不论是Solr还是ES,底层index的存储都是完全使用Lucene原生的方式,没有做改变,所以本文会以ES为例来介绍。需要注意的是Lucene的index在ES中称为shard,本文中提到的index都指的是Lucene的index,即ES中的shard。先来看一个某个index的数据目录:

图片

可以看到一个索引包含了很多文件,似乎很复杂。但仔细观察之后会发现乱中似乎又有些规律:很多文件前缀一样,只是后缀不同,比如有很多_3c开头的文件。回想一下之前文章的介绍,index由若干个segment组成,而一个index目录下前缀相同表示这些文件都属于同一个segment

那各种各样的后缀又代表什么含义呢?Lucene存储segment时有两种方式:

  • multifile格式。该模式下会产生很多文件,不同的文件存储不同的信息,其弊端是读取index时需要打开很多文件,可能造成文件描述符超出系统限制。
  • compound格式。一般简写为CFS(Compound File System),该模式下会将很多小文件合并成一个大文件,以减少文件描述符的使用。

我们先来介绍multifile格式下的各个文件:

  • write.lock:每个index目录都会有一个该文件,用于防止多个IndexWriter同时写一个文件。
  • segments_N:该文件记录index所有segment的相关信息,比如该索引包含了哪些segment。IndexWriter每次commit都会生成一个(N的值会递增),新文件生成后旧文件就会删除。所以也说该文件用于保存commit point信息。

上面这两个文件是针对当前index的,所以每个index目录下都只会有1个(segments_N可能因为旧的没有及时删除临时存在两个)。下面介绍的文件都是针对segment的,每个segment就会有1个。

  • .si:Segment Info的缩写,用于记录segment的一些元数据信息。
  • .fnm:Fields,用于记录fields设置类信息,比如字段的index option信息,是否存储了norm信息、DocValue等。
  • .fdt:Field Data,存储字段信息。当通过StoredField或者Field.Store.YES指定存储原始field数据时,这些数据就会存储在该文件中。
  • .fdx:Field Index,.fdt文件的索引/指针。通过该文件可以快速从.fdt文件中读取field数据。
  • .doc:Frequencies,存储了一个documents列表,以及它们的term frequency信息。
  • .pos:Positions,和.doc类似,但保存的是position信息。
  • .pay:Payloads,和.doc类似,但保存的是payloads和offset信息。
  • .tim:Term Dictionary,存储所有文档analyze出来的term信息。同时还包含term对应的document number以及若干指向.doc, .pos, .pay的指针,从而可以快速获取term的term vector信息。。
  • .tip:Term Index,该文件保存了Term Dictionary的索引信息,使得可以对Term Dictionary进行随机访问。
  • .nvd, .nvm:Norms,这两个都是用来存储Norms信息的,前者用于存储norms的数据,后者用于存储norms的元数据。
  • .dvd, .dvm:Per-Document Values,这两个都是用来存储DocValues信息的,前者用于数据,后者用于存储元数据。
  • .tvd:Term Vector Data,用于存储term vector数据。
  • .tvx:Term Vector Index,用于存储Term Vector Data的索引数据。
  • .liv:Live Documents,用于记录segment中哪些documents没有被删除。一般不存在该文件,表示segment内的所有document都是live的。如果有documents被删除,就会产生该文件。以前是使用一个.del后缀的文件来记录被删除的documents,现在改为使用该文件了。
  • .dim,.dii:Point values,这两个文件用于记录indexing的Point信息,前者保存数据,后者保存索引/指针,用于快速访问前者。

上面介绍了很多文件类型,实际中不一定都有,如果indexing阶段不保存字段的term vector信息,那存储term vector的相关文件可能就不存在。如果一个index的segment非常多,那将会有非常非常多的文件,检索时,这些文件都是要打开的,很可能会造成文件描述符不够用,所以Lucene引入了前面介绍的CFS格式,它把上述每个segment的众多文件做了一个合并压缩(.liv和.si没有被合并,依旧单独写文件),最终形成了两个新文件:.cfs和.cfe,前者用于保存数据,后者保存了前者的一个Entry Table,用于快速访问。所以,如果使用CFS的话,最终对于每个segment,最多就只存在.cfs, .cfe, .si, .liv4个文件了。Lucene从1.4版本开始,默认使用CFS来保存segment数据,但开发者仍然可以选择使用multifile格式。一般来说,对于小的segment使用CFS,对于大的segment,使用multifile格式。比如Lucene的org.apache.lucene.index.MergePolicy构造函数中就提供merge时在哪些条件下使用CFS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  /**
   * Default ratio for compound file system usage. Set to <tt>1.0</tt>, always use 
   * compound file system.
   */
  protected static final double DEFAULT_NO_CFS_RATIO = 1.0;

  /**
   * Default max segment size in order to use compound file system. Set to {@link Long#MAX_VALUE}.
   */
  protected static final long DEFAULT_MAX_CFS_SEGMENT_SIZE = Long.MAX_VALUE;

  /** If the size of the merge segment exceeds this ratio of
   *  the total index size then it will remain in
   *  non-compound format */
  protected double noCFSRatio = DEFAULT_NO_CFS_RATIO;
  
  /** If the size of the merged segment exceeds
   *  this value then it will not use compound file format. */
  protected long maxCFSSegmentSize = DEFAULT_MAX_CFS_SEGMENT_SIZE;

  /**
   * Creates a new merge policy instance.
   */
  public MergePolicy() {
    this(DEFAULT_NO_CFS_RATIO, DEFAULT_MAX_CFS_SEGMENT_SIZE);
  }
  
  /**
   * Creates a new merge policy instance with default settings for noCFSRatio
   * and maxCFSSegmentSize. This ctor should be used by subclasses using different
   * defaults than the {@link MergePolicy}
   */
  protected MergePolicy(double defaultNoCFSRatio, long defaultMaxCFSSegmentSize) {
    this.noCFSRatio = defaultNoCFSRatio;
    this.maxCFSSegmentSize = defaultMaxCFSSegmentSize;

}

栗子

首先在ES中创建一个索引:

1
2
3
4
5
6
7
8
PUT nyc-test
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "refresh_interval": -1
  }
}

这里设置1个shard,0个副本,并且将refresh_interval设置为-1,表示不自动刷新。创建完之后就可以在es的数据目录找到该索引,es的后台索引的目录结构为:<数据目录>/nodes/0/indices/<索引UUID>//index,这里的shard就是Lucene的index。我们看下刚创建的index的目录:

1
2
3
4
-> % ll
总用量 4.0K
-rw-rw-r-- 1 allan allan 230 10月 11 21:45 segments_2
-rw-rw-r-- 1 allan allan   0 10月 11 21:45 write.lock

可以看到,现在还没有写入任何数据,所以只有index级别的segments_N和write.lock文件,没有segment级别的文件。写入1条数据并查看索引目录的变化:

1
2
3
4
5
6
7
8
9
10
11
12
PUT nyc-test/doc/1
{
"name": "Jack"
}

# 查看索引目录
-> % ll
总用量 4.0K
-rw-rw-r-- 1 allan allan 0 10月 11 22:20 _0.fdt
-rw-rw-r-- 1 allan allan 0 10月 11 22:20 _0.fdx
-rw-rw-r-- 1 allan allan 230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan 0 10月 11 22:19 write.lock

可以看到出现了1个segment的数据,因为ES把数据缓存在内存里面,所以文件大小为0。然后再写入1条数据,并查看目录变化:

1
2
3
4
5
6
7
8
9
10
11
12
PUT nyc-test/doc/2
{
"name": "Allan"
}

# 查看目录
-> % ll
总用量 4.0K
-rw-rw-r-- 1 allan allan 0 10月 11 22:20 _0.fdt
-rw-rw-r-- 1 allan allan 0 10月 11 22:20 _0.fdx
-rw-rw-r-- 1 allan allan 230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan 0 10月 11 22:19 write.lock

因为ES缓存机制的原因,目录没有变化。显式的refresh一下,让内存中的数据落地:

1
2
3
4
5
6
7
8
9
POST nyc-test/_refresh

-> % ll
总用量 16K
-rw-rw-r-- 1 allan allan 405 10月 11 22:22 _0.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:22 _0.cfs
-rw-rw-r-- 1 allan allan 393 10月 11 22:22 _0.si
-rw-rw-r-- 1 allan allan 230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan 0 10月 11 22:19 write.lock

ES的refresh操作会将内存中的数据写入到一个新的segment中,所以refresh之后写入的两条数据形成了一个segment,并且使用CFS格式存储了。然后再插入1条数据,接着update这条数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 触发Lucene commit
POST nyc-test/_flush?wait_if_ongoing

# 查看目录
-> % ll
总用量 32K
-rw-rw-r-- 1 allan allan 405 10月 11 22:22 _0.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:22 _0.cfs
-rw-rw-r-- 1 allan allan 393 10月 11 22:22 _0.si
-rw-rw-r-- 1 allan allan 67 10月 11 22:24 _1_1.liv
-rw-rw-r-- 1 allan allan 405 10月 11 22:24 _1.cfe
-rw-rw-r-- 1 allan allan 2.5K 10月 11 22:24 _1.cfs
-rw-rw-r-- 1 allan allan 393 10月 11 22:24 _1.si
-rw-rw-r-- 1 allan allan 361 10月 11 22:25 segments_3
-rw-rw-r-- 1 allan allan 0 10月 11 22:19 write.lock

# 查看segment信息
GET _cat/segments/nyc-test?v

index shard prirep ip segment generation docs.count docs.deleted size size.memory committed searchable version compound
nyc-test 0 p 10.8.4.42 _0 0 2 0 3.2kb 1184 true true 7.4.0 true
nyc-test 0 p 10.8.4.42 _1 1 1 2 3.2kb 1184 true true 7.4.0 true

触发Lucene commit之后,可以看到segments_2变成了segments_3。然后调用_cat接口查看索引的segment信息也能看到目前有2个segment,而且都已经commit过了,并且compound是true,表示是CFS格式存储的。当然Lucene的segment是可以合并的。我们通过ES的forcemerge接口进行合并,并且将所有segment合并成1个segment,forcemerge的时候会自动调用flush,即会触发Lucene commit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
POST nyc-test/_forcemerge?max_num_segments=1

-> % ll
总用量 60K
-rw-rw-r-- 1 allan allan 69 10月 11 22:27 _2.dii
-rw-rw-r-- 1 allan allan 123 10月 11 22:27 _2.dim
-rw-rw-r-- 1 allan allan 142 10月 11 22:27 _2.fdt
-rw-rw-r-- 1 allan allan 83 10月 11 22:27 _2.fdx
-rw-rw-r-- 1 allan allan 945 10月 11 22:27 _2.fnm
-rw-rw-r-- 1 allan allan 110 10月 11 22:27 _2_Lucene50_0.doc
-rw-rw-r-- 1 allan allan 80 10月 11 22:27 _2_Lucene50_0.pos
-rw-rw-r-- 1 allan allan 287 10月 11 22:27 _2_Lucene50_0.tim
-rw-rw-r-- 1 allan allan 145 10月 11 22:27 _2_Lucene50_0.tip
-rw-rw-r-- 1 allan allan 100 10月 11 22:27 _2_Lucene70_0.dvd
-rw-rw-r-- 1 allan allan 469 10月 11 22:27 _2_Lucene70_0.dvm
-rw-rw-r-- 1 allan allan 59 10月 11 22:27 _2.nvd
-rw-rw-r-- 1 allan allan 100 10月 11 22:27 _2.nvm
-rw-rw-r-- 1 allan allan 572 10月 11 22:27 _2.si
-rw-rw-r-- 1 allan allan 296 10月 11 22:27 segments_4
-rw-rw-r-- 1 allan allan 0 10月 11 22:19 write.lock


GET _cat/segments/nyc-test?v

index shard prirep ip segment generation docs.count docs.deleted size size.memory committed searchable version compound
nyc-test 0 p 10.8.4.42 _2 2 3 0 3.2kb 1224 true true 7.4.0 false

可以看到,force merge之后只有一个segment了,并且使用了multifile格式存储,而不是compound。当然这并非Lucene的机制,而是ES自己的设计。
最后用图总结一下:

图片

想了解更详细的,可以阅读:https://lucene.apache.org/core/8_2_0/core/org/apache/lucene/codecs/lucene80/package-summary.html#package.description

Query

参考:
https://niyanchun.com/lucene-learning-8.html
https://niyanchun.com/lucene-learning-9.html
https://pythonhosted.org/lupyne/examples.html

在Lucene中,Term是查询的基本单元(unit),所有查询类的父类是org.apache.lucene.search.Query,本文会介绍下图中这些主要的Query子类:

图片

DisjunctionMaxQuery主要用于控制评分机制,SpanQuery代表一类查询,有很多的实现。这两类查询不是非常常用。

TermQuery

TermQuery是最基础最常用的的一个查询了,对应的类是org.apache.lucene.search.TermQuery。其功能很简单,就是查询哪些文档中包含指定的term。看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
 * Query Demo.
 *
 * @author NiYanchun
 **/
public class QueryDemo {

    /**
     * 搜索的字段
     */
    private static final String SEARCH_FIELD = "contents";

    public static void main(String[] args) throws Exception {
        // 索引保存目录
        final String indexPath = "indices/poems-index";
        // 读取索引
        IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
        IndexSearcher searcher = new IndexSearcher(indexReader);

        // TermQuery
        termQueryDemo(searcher);
    }

    private static void termQueryDemo(IndexSearcher searcher) throws IOException {
        System.out.println("TermQuery, search for 'death':");
        TermQuery termQuery = new TermQuery(new Term(SEARCH_FIELD, "death"));

        resultPrint(searcher, termQuery);
    }

    private static void resultPrint(IndexSearcher searcher, Query query) throws IOException {
        TopDocs topDocs = searcher.search(query, 10);
        if (topDocs.totalHits.value == 0) {
            System.out.println("not found!\n");
            return;
        }

        ScoreDoc[] hits = topDocs.scoreDocs;

        System.out.println(topDocs.totalHits.value + " result(s) matched: ");
        for (ScoreDoc hit : hits) {
            Document doc = searcher.doc(hit.doc);
            System.out.println("doc=" + hit.doc + " score=" + hit.score + " file: " + doc.get("path"));
        }
        System.out.println();
    }
}

上面代码先读取索引文件,然后执行了一个term查询,查询所有包含death关键词的文档。为了方便打印,我们封装了一个resultPrint函数用于打印查询结果。On Death一诗包含了death关键字,所以程序执行结果为:

1
2
3
TermQuery, search for 'death':
1 result(s) matched: 
doc=3 score=0.6199532 file: data/poems/OnDeath.txt

BooleanQuery

BooleanQuery用于将若干个查询按照与或的逻辑关系组织起来,支持嵌套。目前支持4个逻辑关系:

  • SHOULD:逻辑的关系,文档满足任意一个查询即视为匹配。
  • MUST:逻辑的关系,文档必须满足所有查询才视为匹配。
  • FILTER:逻辑的关系,与must的区别是不计算score,所以性能会比must好。如果只关注是否匹配,而不关注匹配程度(即得分),应该优先使用filter。
  • MUST NOT:逻辑与的关系,且取反。文档不满足所有查询的条件才视为匹配。

使用方式也比较简单,以下的代码使用BooleanQuery查询contents字段包含love但不包含seek的词:

1
2
3
4
5
6
7
8
9
private static void booleanQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("BooleanQuery, must contain 'love' but absolutely not 'seek': ");
    BooleanQuery.Builder builder = new BooleanQuery.Builder();
    builder.add(new TermQuery(new Term(SEARCH_FIELD, "love")), BooleanClause.Occur.MUST);
    builder.add(new TermQuery(new Term(SEARCH_FIELD, "seek")), BooleanClause.Occur.MUST_NOT);
    BooleanQuery booleanQuery = builder.build();

    resultPrint(searcher, booleanQuery);
}

Love’s SecretFreedom and Love两首诗中均包含了love一词,但前者还包含了seek一词,所以最终的搜索结果为Freedom and Love

PhraseQuery

PhraseQuery用于搜索term序列,比如搜索“hello world”这个由两个term组成的一个序列。对于Phrase类的查询需要掌握两个点:

  • Phrase查询需要term的position信息,所以如果indexing阶段没有保存position信息,就无法使用phrase类的查询。
  • 理解slop的概念:Slop就是两个term或者两个term序列的edit distance。后面的FuzzyQuery也用到了该概念,这里简单介绍一下。

PhraseQuery使用的是Levenshtein distance,且默认的slop值是0,也就是只检索完全匹配的term序列。看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void phraseQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("\nPhraseQuery, search 'love that'");

    PhraseQuery.Builder builder = new PhraseQuery.Builder();
    builder.add(new Term(SEARCH_FIELD, "love"));
    builder.add(new Term(SEARCH_FIELD, "that"));
    PhraseQuery phraseQueryWithSlop = builder.build();

    resultPrint(searcher, phraseQueryWithSlop);
}
// 运行结果
PhraseQuery, search 'love that'
1 result(s) matched: 
doc=2 score=0.7089927 file: data/poems/Love'sSecret.txt

Love‘s Secret里面有这么一句:”Love that never told shall be“,是能够匹配”love that“的。我们也可以修改slop的值,使得与搜索序列的edit distance小于等于slop的文档都可以被检索到,同时距离越小的文档评分越高。看下面例子:

1
2
3
4
5
6
7
8
9
10
private static void phraseQueryWithSlopDemo(IndexSearcher searcher) throws IOException {
    System.out.println("PhraseQuery with slop: 'love <slop> never");
    PhraseQuery phraseQueryWithSlop = new PhraseQuery(1, SEARCH_FIELD, "love", "never");

    resultPrint(searcher, phraseQueryWithSlop);
}
// 运行结果
PhraseQuery with slop: 'love <slop> never
1 result(s) matched: 
doc=2 score=0.43595996 file: data/poems/Love'sSecret.txt

MultiPhraseQuery

不论是官方文档或是网上的资料,对于MultiPhraseQuery讲解的都比较少。但其实它的功能很简单,举个例子就明白了:我们提供两个由term组成的数组:[“love”, “hate”], [“him”, “her”],然后把这两个数组传给MultiPhraseQuery,它就会去检索 “love him”, “love her”, “hate him”, “hate her”的组合,每一个组合其实就是一个上面介绍的PhraseQuery。当然MultiPhraseQuery也可以接受更高维的组合。

由上面的例子可以看到PhraseQuery其实是MultiPhraseQuery的一种特殊形式而已,如果给MultiPhraseQuery传递的每个数组里面只有一个term,那就退化成PhraseQuery了。在MultiPhraseQuery中,一个数组内的元素匹配时是 或(OR) 的关系,也就是这些term共享同一个position。 还记得之前的文章中我们说过在同一个position放多个term,可以实现同义词的搜索。的确MultiPhraseQuery实际中主要用于同义词的查询。比如查询一个“我爱土豆”,那可以构造这样两个数组传递给MultiPhraseQuery查询:[“喜欢”,“爱”], [“土豆”,”马铃薯”,”洋芋”],这样查出来的结果就会更全面一些。最后来个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static void multiPhraseQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("MultiPhraseQuery:");

    // On Death 一诗中有这样一句: I know not what into my ear
    // Fog 一诗中有这样一句: It sits looking over harbor and city
    // 以下的查询可以匹配 "know harbor, know not, over harbor, over not" 4种情况
    MultiPhraseQuery.Builder builder = new MultiPhraseQuery.Builder();
    Term[] termArray1 = new Term[2];
    termArray1[0] = new Term(SEARCH_FIELD, "know");
    termArray1[1] = new Term(SEARCH_FIELD, "over");
    Term[] termArray2 = new Term[2];
    termArray2[0] = new Term(SEARCH_FIELD, "harbor");
    termArray2[1] = new Term(SEARCH_FIELD, "not");
    builder.add(termArray1);
    builder.add(termArray2);
    MultiPhraseQuery multiPhraseQuery = builder.build();

    resultPrint(searcher, multiPhraseQuery);
}

// 程序输出
MultiPhraseQuery:
2 result(s) matched: 
doc=0 score=2.7032354 file: data/poems/Fog.txt
doc=3 score=2.4798129 file: data/poems/OnDeath.txt

PrefixQuery、WildcardQuery、RegexpQuery

这三个查询提供模糊模糊查询的功能:

  • PrefixQuery只支持指定前缀模糊查询,用户指定一个前缀,查询时会匹配所有该前缀开头的term。
  • WildcardQuery比PrefixQuery更进一步,支持 *(匹配0个或多个字符)和 ?(匹配一个字符) 两个通配符。从效果上看,PrefixQuery是WildcardQuery的一种特殊情况,但其底层不是基于WildcardQuery,而是另外一种单独的实现。
  • RegexpQuery是比WildcardQuery更宽泛的查询,它支持正则表达式。支持的正则语法范围见org.apache.lucene.util.automaton.RegExp类。

需要注意,WildcardQuery和RegexpQuery的性能会差一些,因为它们需要遍历很多文档。特别是极力不推荐以模糊匹配开头。当然这里的差是相对其它查询来说的,我粗略测试过,2台16C+32G的ES,比较简短的文档,千万级以下的查询也能毫秒级返回。最后看几个使用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static void prefixQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("PrefixQuery, search terms begin with 'co'");
    PrefixQuery prefixQuery = new PrefixQuery(new Term(SEARCH_FIELD, "co"));

    resultPrint(searcher, prefixQuery);
}

private static void wildcardQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("WildcardQuery, search terms 'har*'");
    WildcardQuery wildcardQuery = new WildcardQuery(new Term(SEARCH_FIELD, "har*"));

    resultPrint(searcher, wildcardQuery);
}

private static void regexpQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("RegexpQuery, search regexp 'l[ao]*'");
    RegexpQuery regexpQuery = new RegexpQuery(new Term(SEARCH_FIELD, "l[ai].*"));

    resultPrint(searcher, regexpQuery);
}

// 程序输出
PrefixQuery, search terms begin with 'co'
2 result(s) matched: 
doc=0 score=1.0 file: data/poems/Fog.txt
doc=2 score=1.0 file: data/poems/Love'sSecret.txt

WildcardQuery, search terms 'har*'
1 result(s) matched: 
doc=0 score=1.0 file: data/poems/Fog.txt

RegexpQuery, search regexp 'l[ao]*'
2 result(s) matched: 
doc=0 score=1.0 file: data/poems/Fog.txt
doc=3 score=1.0 file: data/poems/OnDeath.txt

FuzzyQuery

FuzzyQuery和PhraseQuery一样,都是基于上面介绍的edit distance做匹配的,差异是在PhraseQuery中搜索词的是一个term序列,此时edit distance中定义的一个symbol就是一个词;而FuzzyQuery的搜索词就是一个term,所以它对应的edit distance中的symbol就是一个字符了。另外使用时还有几个注意点:

  • PhraseQuery采用Levenshtein distance计算edit distance,即相邻symbol交换是2个slop,而FuzzyQuery默认使用Damerau–Levenshtein distance,所以相邻symbol交换是1个slop,但支持用户使用Levenshtein distance。
  • FuzzyQuery限制最大允许的edit distance为2(LevenshteinAutomata.MAXIMUM_SUPPORTED_DISTANCE值限定),因为对于更大的edit distance会匹配出特别多的词,但FuzzyQuery的定位是解决诸如美式英语和英式英语在拼写上的细微差异。
  • FuzzyQuery匹配的时候还有个要求就是搜索的term和待匹配的term的edit distance必须小于它们二者长度的最小值。比如搜索词为”abcd”,设定允许的maxEdits(允许的最大edit distance)为2,那么按照edit distance的计算方式”ab”这个词是匹配的,因为它们的距离是2,不大于设定的maxEdits。但是,由于 2 < min( len(“abcd”), len(“ab”) ) = 2不成立,所以算不匹配。
1
2
3
4
5
6
7
8
9
10
11
12
private static void fuzzyQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("FuzzyQuery, search 'remembre'");
    // 这里把remember拼成了remembre
    FuzzyQuery fuzzyQuery = new FuzzyQuery(new Term(SEARCH_FIELD, "remembre"), 1);

    resultPrint(searcher, fuzzyQuery);
}

// 程序输出
FuzzyQuery, search 'remembre'
1 result(s) matched: 
doc=1 score=0.4473783 file: data/poems/FreedomAndLove.txt

PointRangeQuery

前面介绍Field的时候,我们介绍过几种常用的数值型Field:IntPoint、LongPoint、FloatPoint、DoublePoint。PointRangeQuery就是给数值型数据提供范围查询的一个Query,功能和原理都很简单,我们直接看一个完整的例子吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
 * Point Query Demo.
 *
 * @author NiYanchun
 **/
public class PointQueryDemo {

    public static void main(String[] args) throws Exception {
        // 索引保存目录
        final String indexPath = "indices/point-index";
        Directory indexDir = FSDirectory.open(Paths.get(indexPath));
        IndexWriterConfig iwc = new IndexWriterConfig(new StandardAnalyzer());
        iwc.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
        IndexWriter writer = new IndexWriter(indexDir, iwc);

        // 向索引中插入10条document,每个document包含一个field字段,字段值是0~10之间的数字
        for (int i = 0; i < 10; i++) {
            Document doc = new Document();
            Field pointField = new IntPoint("field", i);
            doc.add(pointField);
            writer.addDocument(doc);
        }
        writer.close();

        // 查询
        IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
        IndexSearcher searcher = new IndexSearcher(indexReader);

        // 查询field字段值在[5, 8]范围内的文档
        Query query = IntPoint.newRangeQuery("field", 5, 8);
        TopDocs topDocs = searcher.search(query, 10);

        if (topDocs.totalHits.value == 0) {
            System.out.println("not found!");
            return;
        }

        ScoreDoc[] hits = topDocs.scoreDocs;

        System.out.println(topDocs.totalHits.value + " result(s) matched: ");
        for (ScoreDoc hit : hits) {
            System.out.println("doc=" + hit.doc + " score=" + hit.score);
        }
    }
}

// 程序输出
4 result(s) matched: 
doc=5 score=1.0
doc=6 score=1.0
doc=7 score=1.0
doc=8 score=1.0

TermRangeQuery

TermRangeQuery和PointRangeQuery功能类似,不过它比较的是字符串,而非数值。比较基于org.apache.lucene.util.BytesRef.compareTo(BytesRef other)方法。直接看例子:

1
2
3
4
5
6
7
8
9
10
11
12
private static void termRangeQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("TermRangeQuery, search term between 'loa' and 'lov'");
    // 后面的true和false分别表示 loa <= 待匹配的term < lov
    TermRangeQuery termRangeQuery = new TermRangeQuery(SEARCH_FIELD, new BytesRef("loa"), new BytesRef("lov"), true, false);

    resultPrint(searcher, termRangeQuery);
}

// 程序输出
TermRangeQuery, search term between 'loa' and 'lov'
1 result(s) matched: 
doc=0 score=1.0 file: data/poems/Fog.txt    // Fog中的term 'looking' 符合搜索条件

ConstantScoreQuery

ConstantScoreQuery很简单,它的功能是将其它查询包装起来,并将它们查询结果中的评分改为一个常量值(默认为1.0)。上面FuzzyQuery一节里面最后举得例子中返回的查询结果score=0.4473783,现在我们用ConstantScoreQuery包装一下看下效果:

1
2
3
4
5
6
7
8
9
10
11
12
private static void constantScoreQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("ConstantScoreQuery:");
    ConstantScoreQuery constantScoreQuery = new ConstantScoreQuery(
            new FuzzyQuery(new Term(SEARCH_FIELD, "remembre"), 1));

    resultPrint(searcher, constantScoreQuery);
}

// 运行结果
ConstantScoreQuery:
1 result(s) matched: 
doc=1 score=1.0 file: data/poems/FreedomAndLove.txt

另外有个知识点需要注意:ConstantScoreQuery嵌套Filter和BooleanQuery嵌套Filter的查询结果不考虑评分的话是一样的,但前面在BooleanQuery中介绍过Filter,其功能与MUST相同,但不计算评分;而ConstantScoreQuery就是用来设置一个评分的。所以两者的查询结果是一样的,但ConstantScoreQuery嵌套Filter返回结果是附带评分的,而BooleanQuery嵌套Filter的返回结果是没有评分的(score字段的值为0)。

MatchAllDocsQuery

这个查询很简单,就是匹配所有文档,用于没有特定查询条件,只想预览部分数据的场景。直接看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void matchAllDocsQueryDemo(IndexSearcher searcher) throws IOException {
    System.out.println("MatchAllDocsQueryDemo:");
    MatchAllDocsQuery matchAllDocsQuery = new MatchAllDocsQuery();

    resultPrint(searcher, matchAllDocsQuery);
}

// 程序输出
MatchAllDocsQueryDemo:
4 result(s) matched: 
doc=0 score=1.0 file: data/poems/Fog.txt
doc=1 score=1.0 file: data/poems/FreedomAndLove.txt
doc=2 score=1.0 file: data/poems/Love'sSecret.txt
doc=3 score=1.0 file: data/poems/OnDeath.txt

想看更多资料,可参考:https://lucene.apache.org/core/8_2_0/core/org/apache/lucene/search/package-summary.html

QueryParser

QueryParser定义了一些查询语法,通过这些语法几乎可以实现前文介绍的所有Query API提供的功能,但它的存在并不是为了替换那些API,而是用在一些交互式场景中。比如下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class SearchFilesMinimal {

    public static void main(String[] args) throws Exception {
        // 索引保存目录
        final String indexPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/indices/poems-index";
        // 搜索的字段
        final String searchField = "contents";

        // 从索引目录读取索引信息
        IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
        // 创建索引查询对象
        IndexSearcher searcher = new IndexSearcher(indexReader);
        // 使用标准分词器
        Analyzer analyzer = new StandardAnalyzer();

        // 从终端获取查询语句
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
        // 创建查询语句解析对象
        QueryParser queryParser = new QueryParser(searchField, analyzer);
        while (true) {
            System.out.println("Enter query: ");

            String input = in.readLine();
            if (input == null) {
                break;
            }

            input = input.trim();
            if (input.length() == 0) {
                break;
            }

            // 解析用户输入的查询语句:build query
            Query query = queryParser.parse(input);
            System.out.println("searching for: " + query.toString(searchField));
            // 查询
            TopDocs results = searcher.search(query, 10);
            // 省略后面查询结果打印的代码
            }
        }
    }
}

在这段代码中,先读取了已经创建好的索引文件,然后创建了一个QueryParser实例(queryParser)。接着不断读取用户输入(input),并传给QueryParser的parse方法,该方法通过用户的输入构建一个Query对象用于查询。
QueryParser的构造函数为QueryParser(String f, Analyzer a),第1个参数指定一个默认的查询字段,如果后面输入的input里面没有指定查询字段,则默认查询该该字段,比如输入hello表示在默认字段中查询”hello“,而content: hello则表示在content字段中查询”hello“。第2个参数指定一个分析器,一般该分析器应该选择和索引阶段同样的Analyzer。

另外有两个点需要特别注意:

  • QueryParser默认使用TermQuery进行多个Term的OR关系查询(后文布尔查询那里会再介绍)。比如输入hello world,表示先将hello world分词(一般会分为hello和world两个词),然后使用TermQuery查询。如果需要全词匹配(即使用PhraseQuery),则需要将搜索词用双引号引起来,比如”hello world”。
  • 指定搜索字段时,该字段仅对紧随其后的第一个词或第一个用双引号引起来的串有效。比如title:hello world这个输入,title仅对hello有效,即搜索时只会在title字段中搜索hello,然后在默认搜索字段中搜索world。如果想要在一个字段中搜索多个词或多个用双引号引起来的词组时,将这些词用小括号括起来即可,比如title:(hello world)。

Wildcard搜索

通配符搜索和WildcardQuery API一样,仅支持?和两个通配符,前者用于匹配1个字符,后者匹配0到多个字符。输入title:te?t,则可以匹配到title中的”test“、”text*”等词。

注意:使用QueryParser中的wildcard搜索时,不允许以?和*开头,否则会抛异常,但直接使用WildcardQuery API时,允许以通配符开头,只是因为性能原因,不推荐使用。这样设计的原因我猜是因为QueryParser的输入是面向用户的,用户对于通配符开头造成的后果并不清楚,所以直接禁掉;而WildcardQuery是给开发者使用的,开发者在开发阶段很清楚如果允许这样做造成的后果是否可以接受,如果不能接受,也是可以通过接口禁掉开头就是用通配符的情况。

Regexp搜索

正则搜索和RegexpQuery一样,不同之处在于QueryParser中输入的正则表达式需要使用两个斜线(“/“)包围起来,比如匹配”moat”或”boat”的正则为/[mb]oat/。

Fuzzy搜索

在QueryParser中,通过在搜索词后面加波浪字符来实现FuzzyQuery,比如love~,默认edit distance是2,可以在波浪符后面加具体的整数值来修改默认值,合法的值为0、1、2.

Phrase slop搜索

PhraseQuery中可以指定slop(默认值为0,精确匹配)来实现相似性搜索,QueryParser中同样可以,使用方法与Fuzzy类似——将搜索字符串用双引号引起来,然后在末尾加上波浪符,比如”jakarta apache”~10。这里对数edit distance没有限制,合法值为非负数,默认值为0.

Range搜索

QueryParser的范围搜索同时支持TermRangeQuery和数值型的范围搜索,排序使用的是字典序开区间使用大括号,闭区间使用方括号。比如搜索修改日期介于2019年9月份和10月份的文档:mod_date:[20190901 TO 20191031],再比如搜索标题字段中包含hatelove的词(但不包含这两个词)的文档:title:{hate TO love}.

提升权重(boost)

查询时可以通过给搜索的关键字或双引号引起来的搜索串后面添加脱字符(^)及一个正数来提升其计算相关性时的权重(默认为1),比如love^5 China或”love China”^0.3。

Boolean操作符

QueryParser中提供了5种布尔操作符:AND、+、OR、NOT、-,所有的操作符必须大写

  • OR是默认的操作符,表示满足任意一个term即可。比如搜索love China,loveChina之间就是OR的关系,检索时文档匹配任意一个词即视为匹配。OR也可以使用可用||代替。
  • AND表示必须满足所有term才可以,可以使用&&代替。
  • +用在term之前,表示该term必须存在。比如+love China表示匹配文档中必须包含loveChina则可包含也可不含。
  • -用在term之前,表示该term必须不存在。比如-“hate China” “love China”表示匹配文档中包含”love China“,但不包含”hate China“的词。

分组

前面已经介绍过,可以使用小括号进行分组,通过分组可以表达一些复杂的逻辑。举两个例子:

  • (jakarta OR apache) AND website表示匹配文档中必须包含webiste,同时需要至少包含jakartaapache二者之一。
  • title:(+return +”pink panther”)表示匹配文档中的title字段中必须同时存在return“pink panther”串。

特殊字符

从前面的介绍可知,有很多符号在QueryParser中具有特殊含义,目前所有的特殊符号包括:+- && || ! ( ) { } [ ] ^ “ ~ ? : /。如果搜索关键字中存在这些特殊符号,则需要使用反斜线()转义。比如搜索(1+1)2则必须写为(1+1)*2。

相比于Lucene的其它搜索API,QueryParser提供了一种方式,让普通用户可以不需要写代码,只是掌握一些语法就可以进行复杂的搜索,在一些交互式检索场景中,还是非常方便的。

基本使用

下面展示一个使用pylucene构建一个基于term和关键词的Query,这里把keyword命中进行了加权,使用分数和长度进行排序,并将结果写进result中的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
boolean_query = BooleanQuery.Builder()
simple_query = QueryParser(
"en_tokenized",
self.lucene_analyzer).parse(query)
keyword_query = QueryParser(
"keyword",
self.lucene_analyzer).parse(query)
boost_keyword_query = BoostQuery(keyword_query, 2.0)
boolean_query.add(simple_query, BooleanClause.Occur.SHOULD)
boolean_query.add(boost_keyword_query, BooleanClause.Occur.SHOULD)
# searcher
lucene_searcher = IndexSearcher(
DirectoryReader.open(self.indir))
sorter = Sort([
SortField.FIELD_SCORE,
SortField('origin_score', SortField.Type.FLOAT, True),
SortField('en_sent_lenth', SortField.Type.INT, True)])

# rerank
collector = TopFieldCollector.create(sorter, self.maxrecal, self.maxrecal)
lucene_searcher.search(boolean_query.build(), collector)
scoreDocs = collector.topDocs().scoreDocs
result = []
for hit in scoreDocs:
doc = lucene_searcher.doc(hit.doc)
result.append(...)

相似度评分机制

TF-IDF

Bad-of-Words模型

先介绍一下NLP和IR领域里面非常简单且使用极其广泛的bag-fo-words model,即词袋模型。假设有这么一句话:“John likes to watch movies. Mary likes movies too.”。那这句话用JSON格式的词袋模型表示的话就是:

1
BoW = {"John":1,"likes":2,"to":1,"watch":1,"movies":2,"Mary":1,"too":1};

可以看到,词袋模型关注的是词的出现次数,而没有记录词的位置信息。所以不同的语句甚至相反含义的语句其词袋可能是一样的,比如“Mary is quicker than John”“John is quicker than Mary”这两句话,其词袋是一样的,但含义是完全相反的。所以凡是完全基于词袋模型的一些算法一般也存在这样该问题。

Term frequency

词频就是一个词(term)在一个文档中(document)出现的次数(frequency),记为tf_{t,d}。这是一种最简单的定义方式,实际使用中还有一些变种:

  • 布尔词频:如果词在文档中出现,则tf_{t,d}=1,否则为0。
  • 根据文档长短做调整的词频:tf_{t,d}/lenth,其中length为文档中的总词数。
  • 对数词频:log(1+tf_{t,d}),加1是防止对0求对数(0没有对数)。 一般选取常用对数或者自然对数。

词频的优点是简单,但缺点也很显然:

  1. 词频中没有包含词的位置信息,所以从词频的角度来看,“Mary is quicker than John”“John is quicker than Mary”两条文档是完全一致的,但显然它们的含义是完全相反的。
  2. 词频没有考虑不同词的重要性一般是不一样的,比如停用词的词频都很高,但它们并不重要。

Inverse document frequency

一个词的逆文档频率用于衡量该词提供了多少信息,计算方式定义如下:$i d f_{t}=\log \frac{N}{d f_{t}}=-\log \frac{d f_{t}}{N}$

其中,t代表term,D代表文档,N代表语料库中文档总数,df_t代表语料库中包含t的文档的数据,即文档频率(document frequency)。如果语料库中不包含t,那df_t就等于0,为了避免除零操作,可以采用后面的公式,将df_t作为分子,也有的变种给df_t加了1。

对于固定的语料库,N是固定的,一个词的df_t越大,其idf(t,D)

就越小。所以那些很稀少的词的idf值会很高,而像停用词这种出现频率很高的词idf值很低。

TF-IDF Model

TF-IDF就是将TF和IDF结合起来,其实就是简单的相乘:$t f i d f(t, d)=t f_{t, d} \cdot i d f_{t}$。从公式可以分析出来,一个词t在某个文档d中的tf-idf值:

  • 当该词在少数文档出现很多次的时候,其值接近最大值;(场景1
  • 当该词在文档中出现次数少或者在很多文档中都出现时,其值较小;(场景2
  • 当该词几乎在所有文档中都出现时,其值接近最小值。(场景3

下面用一个例子来实战一下,还是以文中的4首英文短诗中的前3首为例。假设这3首诗组成了我们的语料库,每首诗就是一个文档(doc1:Fog、doc2:Freedom And Love、doc3:Love’s Secret),诗里面的每个单词就是一个个词(我们把标题也包含在里面)。然后我们选取“the”、 “freedom”、”love”三个词来分别计算它们在每个文档的TF-IDF,计算中使用自然对数形式。

  • the“在doc1中出现了1次,在doc2中出现了2次,在doc3中出现了1次,整个语料库有3个文档,包含”the”的文档也是3个。所以:

图片

  • freedom“在doc1中出现了0次,在doc2中出现了1次,在doc3中出现了0次,语料库中包含”freedom”的文档只有1个。所以:

图片

  • love“在doc1中现了0次,在doc2中出现了3次,在doc3中出现了5次,整个语料库有3个文档,包含”love”的文档有2个。所以:

图片

我们简单分析一下结果:”the“在所有文档中都出现了,所以其tf-idf值最低,为0,验证了上面公式分析中的场景3;”freedom“只有在第2个文档中出现了,所以其它两个的tf-idf值为0,表示不包含该词;”love“在第2、3个文档中都出现了,但在第3个文档中出现的频率更高,所以其tf-idf值最高。所以tf-idf算法的结果还是能很好的表示实际结果的。

Vector Space Model

通过TF-IDF算法,我们可以计算出每个词在语料库中的权重,而通过VSM(Vector Space Model),则可以计算两个文档的相似度。

假设有两个文档:

  • 文档1:”Jack Ma regrets setting up Alibaba.”
  • 文档2:”Richard Liu does not know he has a beautiful wife.”

这是原始的文档,然后通过词袋模型转化后为:

  • BoW1 = {“jack”:1, “ma”:1, “regret”:1, “set”:1, “up”:1, “alibaba”:1}
  • BoW2 = {“richard”:1, “liu”:1, “does”:1, “not”:1, “know”:1, “he”:1, “has”:1, “a”: 1, “beautiful”:1, “wife”:1}

接着,分别用TF-IDF算法计算每个文档词袋中每个词的tf-idf值(值是随便写的,仅供原理说明):

  • tf-idf_doc1 = { 0.41, 0.12, 0.76, 0.83, 0.21, 0.47 }
  • tf-idf_doc2 = { 0.12, 0.25, 0.67, 0.98, 0.43, 0.76, 0.89, 0.51, 0.19, 0.37 }

如果将上面的tf-idf_doc1和tf-idf_doc2看成是2个向量,那我们就通过上面的方式将原始的文档转换成了向量,这个向量就是VSM中的Vector。在VSM中,一个Vector就代表一个文档,记为V(q),Vector中的每个值就是原来文档中term的权重(这个权重一般使用tf-idf计算,也可以通过其他方式计算)。这样语料库中的很多文档就会产生很多的向量,这些向量一起构成了一个向量空间,也就是Vector Space。

假设有一个查询语句为”Jack Alibaba”,我们可以用同样的方式将其转化一个向量,假设这个向量叫查询向量V(q)。这样在语料库中检索和 q相近文档的问题就转换成求语料库中每个向量V(d)与V(q)的相似度问题了。而衡量两个向量相似度最常用的方法就是余弦相似度,用公式表示就是:,这个就是Vector Space Model。

TfidfSimilarity

参考:
https://en.wikipedia.org/wiki/Boolean_model_of_information_retrieval

Lucene使用Boolean model (BM) of Information Retrieval模型来计算一个文档是否和搜索词匹配,对于匹配的文档使用基于VSM的评分算法来计算得分。具体的实现类是org.apache.lucene.search.similarities.TFIDFSimilarity,但做了一些修正。本文不讨论BM算法,只介绍评分算法。TFIDFSimilarity采用的评分公式如下:,我们从外到内剖析一下这个公式:

  • 最外层的累加。搜索语句一般是由多个词组成的,比如”Jack Alibaba”就是有”Jack”和”Alibaba”两个词组成。计算搜索语句和每个匹配文档的得分的时候就是计算搜索语句中每个词和匹配文档的得分,然后累加起来就是搜索语句和该匹配文档的得分。这就是最外层的累加。
  • t.getBoost():之前的系列文章中介绍过,在查询或者索引阶段我们可以人为设定某些term的权重,t.getBoost()获取的就是这个阶段设置的权重。所以查询或索引阶段设置的权重也就是在这个时候起作用的。
  • norm(t, d):之前的系列文章中也介绍过,查询的时候一个文档的长短也是会影响词的重要性,匹配次数一样的情况下,越长的文档评分越低。这个也好理解,比如我们搜”Alibaba”,有两个文档里面都出现了一次该词,但其中一个文档总共包含100万个词,而另外一个只包含10个词,很显然,极大多数情况下,后者与搜索词的相关度是比前者高的。实际计算的时候使用的公式如下:,length是文档d的长度。
  • 计算:Lucene假设一个词在搜索语句中的词频为1(即使出现多次也不影响,就是重复计算多次而已),所以可以把这个公式拆开写:,这里的就对应上面的!
  • 在Lucene中,采用的TF计算公式为:,IDF计算公式为:

其实TFIDFSimilarity是一个抽象类,真正实现上述相似度计算的是org.apache.lucene.search.similarities.ClassicSimilarity类,上面列举的公式在其对应的方法中也可以找到。除了基于TFIDF这种方式外,Lucene还支持另外一种相似度算法BM25,并且从6.0.0版本开始,BM25已经替代ClassicSimilarity,作为默认的评分算法。

BM25Similarity

BM25全称“Best Match 25”,其中“25”是指现在BM25中的计算公式是第25次迭代优化。该算法是几位大牛在1994年TREC-3(Third Text REtrieval Conference)会议上提出的,它将文本相似度问题转化为概率模型,可以看做是TF-IDF的改良版,我们看下它是如何进行改良的。

对IDF的改良

BM25中的IDF公式为:。原版BM25的log中是没有加1的,Lucene为了防止产生负值,做了一点小优化。虽然对公式进行了更改,但其实和原来的公式没有实质性的差异,下面是新旧函数曲线对比:

图片

对TF的改良1

BM25中TF的公式为:,其中tf是传统的词频值。先来看下改良前后的函数曲线对比吧(下图中k=1.2):

图片

可以看到,传统的tf计算公式中,词频越高,tf值就越大,没有上限。但BM中的tf,随着词频的增长,tf值会无限逼近(k+1),相当于是有上限的。这就是二者的区别。一般 k

k取 1.2,Lucene中也使用1.2作为k的默认值。

对TF的改良2

在传统的计算公式中,还有一个norm。BM25将这个因素加到了TF的计算公式中,结合了norm因素的BM25中的TF计算公式为:,和之前相比,就是给分母上面的k加了一个乘数(1.0 - b + b * L),其中的L的计算公式为:$L=|d|/avgDl$,其中,|d|是当前文档的长度,avgDl是语料库中所有文档的平均长度。b是一个常数,用来控制L对最总评分影响的大小,一般取0~1之间的数(取0则代表完全忽略L)。Lucene中b的默认值为 0.75.

通过这些细节上的改良,BM25在很多实际场景中的表现都优于传统的TF-IDF,所以从Lucene 6.0.0版本开始,上位成为默认的相似度评分算法。

代码实践

Java

原始数据为4首英文短诗,每个诗对应一个文件,文件名为诗名。这里列出内容,方便后面讨论。

  • Fog(迷雾):
1
2
3
4
5
The fog comes
on little cat feet.
It sits looking over harbor and city
on silent haunches
and then, moves on.
  • Freedom And Love(自由与爱情):
1
2
3
4
5
6
7
8
How delicious is the winning
Of a kiss at loves beginning,
When two mutual hearts are sighing
For the knot there's no untying.
Yet remember, 'mist your wooing,
Love is bliss, but love has ruining;
Other smiles may make you fickle,
Tears for charm may tickle.
  • Love’s Secret(爱情的秘密):
1
2
3
4
5
6
7
8
9
10
11
12
Never seek to tell thy love,
Love that never told shall be;
For the gentle wind does move
Silently, invisibly.
I told my love, I told my love,
I told her all my heart,
Trembling, cold, in ghastly fears.
Ah! she did depart!
Soon after she was gone from me,
A traveller came by,
Silently, invisibly:
He took her with a sigh.
  • On Death(死亡):
1
2
3
4
Death stands above me, whispering low
I know not what into my ear:
Of his strange language all I know
Is, there is not a word of fear.

因为原始数据已经是文本格式了,所以我们构建索引的流程如下:
图片

其中的分析就是我们之前说的分词。然后先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 省略包等信息,完整文件见源文件

/**
 * Minimal Index Files code.
 **/
public class IndexFilesMinimal {

    public static void main(String[] args) throws Exception {
        // 原数据存放路径
        final String docsPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems";
        // 索引保存目录
        final String indexPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/indices/poems-index";

        final Path docDir = Paths.get(docsPath);
        Directory indexDir = FSDirectory.open(Paths.get(indexPath));
        // 使用标准分析器
        Analyzer analyzer = new StandardAnalyzer();

        IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
        // 每次都重新创建索引
        iwc.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
        // 创建IndexWriter用于写索引
        IndexWriter writer = new IndexWriter(indexDir, iwc);

        System.out.println("index start...");
        // 遍历数据目录,对目录下的每个文件进行索引
        Files.walkFileTree(docDir, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                indexDoc(writer, file);
                return FileVisitResult.CONTINUE;
            }
        });
        writer.close();

        System.out.println("index ends.");
    }

    private static void indexDoc(IndexWriter writer, Path file) throws IOException {
        try (InputStream stream = Files.newInputStream(file)) {
            System.out.println("indexing file " + file);
            // 创建文档对象
            Document doc = new Document();

            // 将文件绝对路径加入到文档中
            Field pathField = new StringField("path", file.toString(), Field.Store.YES);
            doc.add(pathField);
            // 将文件内容加到文档中
            Field contentsField = new TextField("contents", new BufferedReader(new InputStreamReader(stream)));
            doc.add(contentsField);

            // 将文档写入索引中
            writer.addDocument(doc);
        }
    }
}

这段代码的功能是遍历4首诗对应的文件,对其进行分词、索引,最终形成索引文件,供以后检索。里面有几个API比较关键,这里稍作一下介绍:

  • FSDirectory:该类实现了索引文件存储到文件系统的功能。我们无需关注底层文件系统的类型,该类会帮我们处理好。当然还有其它几个类型的Directory,以后再介绍。
  • StandardAnalyzer:Lucene内置的标准分词器,其分词的方法是去掉停用词(stop word),全部转化为小写,根据空白字符分成一个个词/词组。Lucene还支持好几种其它分词器,我们也可以实现自己的分词器,以后再介绍。
  • IndexWriter:该类是索引(此处为动词)文件的核心类,负责索引的创建和维护。

我们可以这样理解Lucene里面的组织形式:索引(Index)是最顶级的概念,可以理解为MySQL里面的表;索引里面包含很多个Document,一个Document可以理解为MySQL中的一行记录;一个Document里面可以包含很多个Field,每一个Field都是一个类似Map的结构,由字段名和字段内容组成,内容可再嵌套。在MySQL中,表结构是确定的,每一行记录的格式都是一样的,但Lucene没有这个要求,每个Document里面的字段可以完全不一样,即所谓的”flexible schema“。

在上述代码运行完之后,我们就生成了一个名叫poems-index的索引,该索引里面包含4个Document,每个Document对应一首短诗。每个Document由pathcontents两个字段组成,path里面存储的是诗歌文件的绝对路径,contents里面存储的是诗歌的内容。最终生成的索引目录包含如下一些文件:

图片

这样后台索引构建的工作就算完成了,接下来我们来看一下如何利用索引进行高效的搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 省略包等信息,完整文件见源文件

/**
 * Minimal Search Files code
 **/
public class SearchFilesMinimal {

    public static void main(String[] args) throws Exception {
        // 索引保存目录
        final String indexPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/indices/poems-index";
        // 搜索的字段
        final String searchField = "contents";

        // 从索引目录读取索引信息
        IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
        // 创建索引查询对象
        IndexSearcher searcher = new IndexSearcher(indexReader);
        // 使用标准分词器
        Analyzer analyzer = new StandardAnalyzer();

        // 从终端获取查询语句
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
        // 创建查询语句解析对象
        QueryParser queryParser = new QueryParser(searchField, analyzer);
        while (true) {
            System.out.println("Enter query: ");

            String input = in.readLine();
            if (input == null) {
                break;
            }

            input = input.trim();
            if (input.length() == 0) {
                break;
            }

            // 解析用户输入的查询语句:build query
            Query query = queryParser.parse(input);
            System.out.println("searching for: " + query.toString(searchField));
            // 查询
            TopDocs results = searcher.search(query, 10);
            ScoreDoc[] hits = results.scoreDocs;
            if (results.totalHits.value == 0) {
                System.out.println("no result matched!");
                continue;
            }

            // 输出匹配到的结果
            System.out.println(results.totalHits.value + " results matched: ");
            for (ScoreDoc hit : hits) {
                Document doc = searcher.doc(hit.doc);
                System.out.println("doc=" + hit.doc + " score=" + hit.score + " file: " + doc.get("path"));
            }
        }
    }
}

这段代码的核心流程是先从上一步创建的索引目录加载构建好的索引,然后获取用户输入并解析为查询语句(build query),接着运行查询(run query),如果有匹配到的,就输出匹配的结果。这里对查询比较重要的API做下简单说明:

  • IndexReader:打开一个索引;
  • IndexSearcher:搜索IndexReader打开的索引,返回TopDocs对象;
  • QueryParser:该类的parse方法解析用户输入的查询语句,返回一个Query对象;

下面我们来运行一下程序:

1
2
3
4
5
6
Enter query: 
love
searching for: love
2 results matched: 
doc=0 score=0.48849338 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/Love'sSecret.txt
doc=1 score=0.41322997 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/FreedomAndLove.txt

我们输入关键字”love“,搜索出来两个Document,分别对应Love’s Secret和Freedom And Love。doc=后面的数字是Document的ID,唯一标识一个Document。score后面的数字是搜索结果与我们搜索的关键字的相关度。然后我们再输入”LOVE“(注意字母都大写了):

1
2
3
4
5
6
Enter query: 
Love
searching for: love
2 results matched: 
doc=0 score=0.48849338 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/Love'sSecret.txt
doc=1 score=0.41322997 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/FreedomAndLove.txt

可以看到搜索结果与之前是一样的,这是因为我们搜索时使用了和构建索引时相同的分词器StandardAnalyzer,该分词器会将所有词转化为小写。然后我们再尝试一下其它搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Enter query: 
fog
searching for: fog
1 results matched: 
doc=2 score=0.67580885 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/Fog.txt

Enter query: 
above
searching for: above
1 results matched: 
doc=3 score=0.6199532 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/OnDeath.txt

Enter query: 
death
searching for: death
1 results matched: 
doc=3 score=0.6199532 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/OnDeath.txt

Enter query: 
abc
searching for: abc
no result matched!

都工作正常,最后一个关键字”abc“没有搜到,因为原文中也没有这个词。我们再来看一个复杂点的查询:

1
2
3
4
5
Enter query: 
+love -seek
searching for: +love -seek
1 results matched: 
doc=1 score=0.41322997 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/FreedomAndLove.txt

这里我们输入的关键字为”+love -seek“,这是一个高级一点的查询,含义是“包含love但不包含seek”,于是就只搜出来Freedom And Love一首诗了。

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import lucene
from java.nio.file import Paths
# from org.apache.lucene.analysis.cjk import CJKAnalyzer
from org.apache.lucene.document import Document, Field, FieldType, StoredField
from org.apache.lucene.document import TextField, FloatPoint, IntPoint
from org.apache.lucene.document import NumericDocValuesField
from org.apache.lucene.document import FloatDocValuesField
from org.apache.lucene.document import SortedSetDocValuesField
from org.apache.lucene.index import FieldInfo, IndexWriter, IndexWriterConfig
from org.apache.lucene.store import SimpleFSDirectory
from org.apache.lucene.util import Version
from org.apache.lucene.search import IndexSearcher, Sort, SortField
from org.apache.lucene.search import BooleanQuery
from org.apache.lucene.search import BooleanClause
from org.apache.lucene.search import TopFieldCollector, BoostQuery
from org.apache.lucene.queryparser.classic import QueryParser
from org.apache.lucene.index import DirectoryReader
from org.apache.lucene.analysis.core import WhitespaceAnalyzer
from org.apache.lucene.util import BytesRef
from strsimpy.levenshtein import Levenshtein


class LuceneECSearch(object):
def __init__(self, indir, seg_model_path, mode='search',
maxrecall=10000, maxdoc=30):
lucene.initVM()
self.indir = indir
self.lucene_analyzer = WhitespaceAnalyzer()
if mode == 'search':
self.indir = SimpleFSDirectory(Paths.get(indir))
self.maxdoc = maxdoc
self.maxrecal = maxrecall
self.levenshtein = Levenshtein()

def search(self, query):
# 构造Query
query = self.processor.process(query, 'en')
boolean_query = BooleanQuery.Builder()
simple_query = QueryParser(
"en_tokenized",
self.lucene_analyzer).parse(query)
keyword_query = QueryParser(
"keyword",
self.lucene_analyzer).parse(query)
boost_keyword_query = BoostQuery(keyword_query, 2.0)
boolean_query.add(simple_query, BooleanClause.Occur.SHOULD)
boolean_query.add(boost_keyword_query, BooleanClause.Occur.SHOULD)

# searcher
lucene_searcher = IndexSearcher(
DirectoryReader.open(self.indir))
sorter = Sort([
SortField.FIELD_SCORE,
SortField('origin_score', SortField.Type.FLOAT, True),
SortField('en_sent_lenth', SortField.Type.INT, True)])

# rerank
collector = TopFieldCollector.create(
sorter, self.maxrecal, self.maxrecal)
lucene_searcher.search(boolean_query.build(), collector)
scoreDocs = collector.topDocs().scoreDocs
result = []
for hit in scoreDocs:
doc = lucene_searcher.doc(hit.doc)
keyword_info = json.loads(doc.get('keyword_info'))
keyword_score = 0.0
for word, score in keyword_info.items():
word_splits = word.split(' ')
for split in word_splits:
if split == query:
keyword_score += score
json_answer = {
'en_tokenized': doc.get("en_tokenized"),
'en_sent': doc.get("en_sent"),
'en_sent_lenth': doc.get("en_sent_lenth"),
'cn_tokenized': doc.get("cn_tokenized"),
'cn_sent': doc.get("cn_sent"),
'confidence': int(doc.get("confidence")),
'origin_score': float(doc.get("origin_score")),
'new_score': float(doc.get("new_score"))}
result.append(json_answer)
result = self.rerank(query, result)
return result

def rerank(self, query, candidates):
candidates.sort(key=lambda x: x["origin_score"], reverse=True)
# candidates = candidates[:self.maxdoc * 2]
candidates.sort(key=lambda x: x["new_score"], reverse=True)
# 先把有释义的拿出来
return candidates

def build(self, docdir, modeldir):
if os.path.exists(self.indir):
shutil.rmtree(self.indir)
lucene.initVM()
INDEXIDR = Paths.get(self.indir)
indexdir = SimpleFSDirectory(INDEXIDR)

config = IndexWriterConfig(self.lucene_analyzer)
index_writer = IndexWriter(indexdir, config)

cnt = 0
with open(docdir, 'r', encoding='utf-8') as f:
for line in f.readlines():
line = line.strip()
try:
data_json = json.loads(line)
except Exception:
print('Json load error!')
continue
try:
document = Document()

en_tokenized = data_json['en_tokenized']
# TODO 去掉停用词
document.add(Field("en_tokenized", en_tokenized,
TextField.TYPE_STORED))

en_sent = data_json['en_sent']
document.add(Field("en_sent", en_sent,
TextField.TYPE_STORED))

en_sent_lenth = len(en_tokenized)
document.add(IntPoint("en_sent_lenth", en_sent_lenth))
document.add(NumericDocValuesField(
"en_sent_lenth", en_sent_lenth))
document.add(StoredField("en_sent_lenth", en_sent_lenth))

cn_tokenized = data_json['cn_tokenized']
document.add(Field("cn_tokenized", cn_tokenized,
TextField.TYPE_STORED))

cn_sent = data_json['cn_sent']
document.add(Field("cn_sent", cn_sent,
TextField.TYPE_STORED))

confidence = int(data_json['confidence'])
document.add(IntPoint("confidence", confidence))
document.add(NumericDocValuesField(
"confidence", confidence))
document.add(StoredField("confidence", confidence))

origin_score = float(data_json['origin_score'])
document.add(FloatPoint("origin_score", origin_score))
document.add(FloatDocValuesField("origin_score", origin_score))
document.add(StoredField("origin_score", origin_score))

keyword_info = {}
for keyword in data_json['keyword']:
keyword_text = keyword['text']
keyword_score = keyword['score']
document.add(
SortedSetDocValuesField(
"keyword", BytesRef(keyword_text)))
document.add(
StoredField("keyword", keyword_text))
document.add(
Field("keyword", keyword_text,
TextField.TYPE_STORED))
keyword_info[keyword_text] = keyword_score
keyword_info_str = json.dumps(
keyword_info, ensure_ascii=False)
document.add(
Field(
"keyword_info",
keyword_info_str,
TextField.TYPE_STORED))

new_score = ... # 经过模型计算出来的
0 new_score =
document.add(FloatPoint("new_score", new_score))
document.add(
FloatDocValuesField("new_score", new_score))
document.add(
StoredField("new_score", new_score))
index_writer.addDocument(document)
except Exception:
print('Index write error!')
continue
cnt += 1
if cnt % 1000 == 0:
print('Writing ', cnt)

index_writer.commit()
    index_writer.close()