NYC's Blog - Lucene 2020-09-16T08:12:00+08:00 Typecho http://niyanchun.com/feed/atom/tag/Lucene/ <![CDATA[ES数据可靠性分析]]> http://niyanchun.com/es-data-reliability.html 2020-09-16T08:12:00+08:00 2020-09-16T08:12:00+08:00 NYC https://niyanchun.com ES作为全文检索兼存储系统,数据可靠性至关重要,本文讨论ES是如何实现数据可靠性的。ES底层基于Lucene,所以有必要先搞清楚一些相关的概念。

refresh && flush && commit

Lucene中,有flush和commit的概念。所谓flush,就是定期将内存Buffer里面的数据刷新到Directory这样一个抽象的文件存储层,其实就是生成segment。需要注意的是,因为操作系统file cache的原因,这里的刷新未必会真的落盘,一般只是从内存buffer刷到了file cache而已,实质还是在内存中,所以是一个相对比较高效和轻量级的操作。flush方法的java doc是这样描述的:

Moves all in-memory segments to the Directory, but does not commit (fsync) them (call commit() for that).”

关于segment再补充一点:形成segment以后,数据就可以被搜索了,但因为Lucene flush一般比较频繁(ES里面执行频率默认是1秒),所以会产生很多小的segment文件,一方面太多的文件会占用太多的文件描述符;另一方面,搜索时文件太多也会影响搜索效率,所以Lucene有专门的Merge线程定期将小的segment文件merge为大文件。

Lucene的commit上面的Java doc已经提到了,它会调用fsync,commit方法的java doc如下:

Commits all pending changes (added and deleted documents, segment merges, added indexes, etc.) to the index, and syncs all referenced index files, such that a reader will see the changes and the index updates will survive an OS or machine crash or power loss.

因为会调用fsync,所以commit之后,文件肯定会被持久化到磁盘上,所以这是一个重操作,一方面是磁盘的性能比较差,另一方面是commit的时候会执行更新索引等操作。commit一般是当我们认为系统到达一个稳定点的时候,commit一次,类似于流式系统里面的checkpoint。当系统出现故障的时候,Lucene会从最近的一次commit point进行恢复,而不是最近的一次flush。

总结一下,flush会生成segment,之后数据就能被搜索了,是一个轻量级操作,但此时并不保证数据被持久化了。commit是一个比较重的落盘操作,用于持久化,不能被频繁执行。

以上是Lucene,现在我们来看ES。ES中有refresh和flush的概念,其实是和Lucene一一对应的,不过换了个名字。ES里面的refresh就是Lucene里面的flush;ES里面的flush就是Lucene里面的commit。所以,ES里面的refresh默认1秒执行一次,也就是数据写入ES之后最快1秒之后可以被搜索到,这也就是ES提供的近实时搜索NRT(Near Realtime)。而flush的执行时机有两个点:一个是ES会根据内存使用情况启发式的执行flush,另外一个时机是当translog达到512MB(默认值)时执行一次flush。网上很多文章(一般都比较早了)都提到每30分钟也会执行一次,但我在6.6版本的代码及文档里面没有找到这部分说明。

这里提到了translog,下面我们来介绍一下translog。

translog

仔细想一下上面介绍的Lucene的flush和commit,就会发现如果在数据还没有被commit之前,机器宕掉了,那上次commit之后到宕机前的数据就丢了。实际上,Lucene也是这么做的,异常恢复时会丢掉最后一次commit之后的数据(如果有的话)。这对于绝大多数业务是不能接受的,所以ES引入了translog来解决这个问题。其实也不算什么新技术,就是类似于传统DB里面的预写日志(WAL),不过在ES里面叫事务日志(transaction log),简称translog。

这样ES的写入的时候,先在内存buffer中进行Lucene documents的写入,写入成功后再写translog。内存buffer中的Lucene documents经过refresh会形成file system cache中的segment,此时,内容就可以被搜索到了。然后经过flush,持久化到磁盘上面。整个流程如下图:

写入流程

这里有几个问题需要说明一下。

  1. 为了保证数据完全的可靠,一般的写入流程都是先写WAL,再写内存,但ES是先写内存buffer,然后再写translog。这个顺序目前没有找到官方的说明,网上大部分说的是写的过程比较复杂,容易出错,先写内存可以降低处理的复杂性。不过这个顺序个人认为对于用户而言其实不是很关键,因为不管先写谁,最终两者都写成功才会返回给客户端。
  2. translog的落盘(即图中的fsync过程)有两种策略,分别对应不同的可靠程度。第一种是每次请求(一个index、update、delete或者bulk操作)都会执行fsync,fsync成功后,才会给客户端返回成功,也就是请求同步刷盘,这种可靠性最高,只要返回成功,那数据一定已经落盘了,这也是默认的方式。第二种是异步的,按照定时达量的方式,默认每5秒或者512MB的时候就fsync一次。异步一般可以获得更高的吞吐量,但弊端是存在数据丢失的风险。
  3. ES的flush(或者Lucene的commit)也是落盘,为什么不直接用,而加一个translog?translog或者所有的WAL的一大特性就是他们都是追加写,这样大多数时候都可以实现顺序读写,读和写可以完全并发,所以性能一般都是非常好的。有一些测试表明,磁盘的顺序写甚至比内存的随机写更快,见The Pathologies of Big Data的Figure 3。
  4. translog不是全局的,而是每个shard(也就是Lucene的index)一个,且每个shard的所有translog中同一时刻只会有一个translog文件是可写的,其它都是只读的(如果有的话)。具体细节可查看Translog类的Java doc说明。
  5. translog的老化机制在6.0之前是segment flush到磁盘后,就删掉了。6.0之后是按定时达量的策略进行删除,默认是512MB或者12小时。

因为ES的版本更迭比较快,配置项也是经常变更,所以上面没有列出具体的配置项,这里的默认值都来自 ES 6.8 translog文档,有兴趣的可以点击链接查看。

副本

引入translog之后,解决了进程突然挂掉或者机器突然宕机导致还处于内存,没有被持久化到磁盘的数据丢失的问题,但数据仅落到磁盘还是无法完全保证数据的安全,比如磁盘损坏等。分布式领域解决这个问题最直观和最简单的方式就是采用副本机制,比如HDFS、Kafka等都是此类代表,ES也使用了副本的机制。一个索引由一个primary和n(n≥0)个replica组成,replica的个数支持可以通过接口动态调整。为了可靠,primary和replica的shard不能在同一台机器上面。

这里要注意区分一下replica和shard的关系:比如创建一个索引的时候指定了5个shard,那么此时primary分片就有5个shard,每个replica也会有5个shard。我们可以通过接口随意修改replica的个数,但不能修改每个primary/replica包含5个shard这个事实。 当然,shard的个数也可以通过shrink接口进行调整,但这是一个很重的操作,而且有诸多限制,比如shard个数只能减少,且新个数是原来的因子,比如原来是8个shard,那只能选择调整为4、2或1个;如果原来是5个,那就只能调整为1个了。所以实际中,shard的个数一般要预先计划好(经验值是保证一个shard的大小在30~50GB之间),而replica的个数可以根据实际情况后面再做调整。

在数据写入的时候,数据会先写primary,写成功之后,同时分发给多个replica,待所有replica都返回成功之后,再返回给客户端。所以一个写请求的耗时可以粗略认为是写primary的时间+耗时最长的那个replica的时间。

引申:写入优化

总体来说,ES的写入能力不算太好,所以经常需要对写入性能做优化,除了保证良好的硬件配置外,还可以从ES自身进行机制进行优化,结合上面的介绍,可以很容易得出下面的一些优化手段:

  1. 如果对于搜索的实时性要求不高,可以适当增加refresh的时间,比如从默认的1秒改为30秒或者1min,甚至更长。如果是离线导入再搜索的场景,可以直接设置为"-1",即关闭自动的refresh,等导入完成后,通过接口手动refresh。其提高性能的原理是增加refresh的时间可以减少大量小的segment文件,这样在可以提高flush的效率,减小merge的压力。
  2. 如果对于数据可靠性要求不是特别高,可以将translog的落盘机制由默认的请求同步落盘,改为定时达量的异步落盘,提高落盘的效率。
  3. 如果对于数据可靠性要求不是特别高,可以在写入高峰期先不设置副本,待过了高峰之后再通过接口增加副本。这个可以通过ES的ILM策略,实现自动化。

优化往往都是有代价的,需要根据自己的实际场景进行评估。

引申:写入流程代码分析

本来计划是写一篇从原理、源码分析ES关于写入流程的文章,但发现已经有一些比较好的文章了,就换了个角度,从使用角度宏观介绍了用户一般比较关心的数据可靠性问题。这里推荐两篇介绍ES写入流程的文章:

这两篇文章都结合代码对写入流程进行了深入解析,文章非常不错,这里我再补充一些细节,供有兴趣debug源码的读者阅读。源码阅读环境搭建见我之前的文章IDEA或Eclipse中编译调试ElasticSearch源码,里面使用的ES版本是6.6(6.6.3 snapshot),下面代码的说明也基于这个版本。

ES提供两种类型的接口:对外的REST接口,对内的Transport接口。早一点的版本中,transport接口也开放给外部客户端使用,但现在已经逐步只用于内部节点之间通信了。不过REST接口其实只是在Transport接口进行了封装,请求到ES之后还是会转发到Transport接口对应的处理器去处理。REST和Transport接口的处理器都是在ActionModule类中注册的:

static Map<String, ActionHandler<?, ?>> setupActions(List<ActionPlugin> actionPlugins) {
    
    // 省略部分代码

    ActionRegistry actions = new ActionRegistry();

    // Transport接口处理器注册
    actions.register(MainAction.INSTANCE, TransportMainAction.class);
    actions.register(NodesInfoAction.INSTANCE, TransportNodesInfoAction.class);
    actions.register(RemoteInfoAction.INSTANCE, TransportRemoteInfoAction.class);
    actions.register(NodesStatsAction.INSTANCE, TransportNodesStatsAction.class);
    actions.register(NodesUsageAction.INSTANCE, TransportNodesUsageAction.class);
    actions.register(NodesHotThreadsAction.INSTANCE, TransportNodesHotThreadsAction.class);
    actions.register(ListTasksAction.INSTANCE, TransportListTasksAction.class);
    actions.register(GetTaskAction.INSTANCE, TransportGetTaskAction.class);
    actions.register(CancelTasksAction.INSTANCE, TransportCancelTasksAction.class);

    // 省略部分代码
}


public void initRestHandlers(Supplier<DiscoveryNodes> nodesInCluster) {
    List<AbstractCatAction> catActions = new ArrayList<>();
    Consumer<RestHandler> registerHandler = a -> {
        if (a instanceof AbstractCatAction) {
            catActions.add((AbstractCatAction) a);
        }
    };

    // REST接口处理器注册
    registerHandler.accept(new RestMainAction(settings, restController));
    registerHandler.accept(new RestNodesInfoAction(settings, restController, settingsFilter));
    registerHandler.accept(new RestRemoteClusterInfoAction(settings, restController));
    registerHandler.accept(new RestNodesStatsAction(settings, restController));
    registerHandler.accept(new RestNodesUsageAction(settings, restController));
    registerHandler.accept(new RestNodesHotThreadsAction(settings, restController));
    registerHandler.accept(new RestClusterAllocationExplainAction(settings, restController));
    registerHandler.accept(new RestClusterStatsAction(settings, restController));
    registerHandler.accept(new RestClusterStateAction(settings, restController, settingsFilter));
    registerHandler.accept(new RestClusterHealthAction(settings, restController));
    // 省略部分代码
}

所以我们要调试某个功能的时候其实只要在这里找到REST接口的处理器以及其对应的Transport接口对应的处理器即可。这里以数据写入为例,ES的写入主要分单条的index和批量的bulk操作,单条其实是bulk的一种特殊情况,所以处理复用的是bulk的逻辑。

写入的REST接口处理器是RestIndexAction

// 单条
registerHandler.accept(new RestIndexAction(settings, restController));
// 批量
registerHandler.accept(new RestBulkAction(settings, restController));

它们的构造函数里面也明确的定义了我们使用的REST接口:

public RestIndexAction(Settings settings, RestController controller) {
    super(settings);
    controller.registerHandler(POST, "/{index}/{type}", this); // auto id creation
    controller.registerHandler(PUT, "/{index}/{type}/{id}", this);
    controller.registerHandler(POST, "/{index}/{type}/{id}", this);
    CreateHandler createHandler = new CreateHandler(settings);
    controller.registerHandler(PUT, "/{index}/{type}/{id}/_create", createHandler);
    controller.registerHandler(POST, "/{index}/{type}/{id}/_create", createHandler);
}

public RestBulkAction(Settings settings, RestController controller) {
    super(settings);

    controller.registerHandler(POST, "/_bulk", this);
    controller.registerHandler(PUT, "/_bulk", this);
    controller.registerHandler(POST, "/{index}/_bulk", this);
    controller.registerHandler(PUT, "/{index}/_bulk", this);
    controller.registerHandler(POST, "/{index}/{type}/_bulk", this);
    controller.registerHandler(PUT, "/{index}/{type}/_bulk", this);

    this.allowExplicitIndex = MULTI_ALLOW_EXPLICIT_INDEX.get(settings);
}

这两个接口最终都会调用TransportBulkAction,该类会将bulk中的操作按照shard进行分类,然后交给TransportBulkAction执行。这里我以index操作为例列出调用链上的关键类和方法,供参考:

  1. RestIndexAction#prepareRequest:解析参数,构建IndexRequest,然后转发给TransportBulkAction#doExecute执行
  2. TransportBulkAction

    • doExecute:检查是否需要创建索引,如果需要则创建
    • executeIngestAndBulk:检查是否需要执行pipeline,如果有且本节点是ingest节点,则执行,否则转发到ingest节点
    • executeBulk->BulkOperation#doRun:按需生成doc id,计算操作在哪个shard执行,按照shard进行分组
  3. ReplicationOperation#execute:写主分片、写副本分片,等待返回。下面给出写主分片的调用,副本分片同理。

    • primaryResult = primary.perform(request); -> TransportReplicationAction#perform -> TransportShardBulkAction#shardOperationOnPrimary -> TransportShardBulkAction#performOnPrimary -> TransportShardBulkAction#executeBulkItemRequest ->

      TransportShardBulkAction#executeIndexRequestOnPrimary ->

      IndexShard#applyIndexOperationOnPrimary -> IndexShard#index ->

      InternalEngine#index

这里看下最后的InternalEngine#index部分关键代码:

 @Override
public IndexResult index(Index index) throws IOException {    
    
    // 省略部分代码
    
    final IndexResult indexResult;
    if (plan.earlyResultOnPreFlightError.isPresent()) {
        indexResult = plan.earlyResultOnPreFlightError.get();
        assert indexResult.getResultType() == Result.Type.FAILURE : indexResult.getResultType();
    } else if (plan.indexIntoLucene || plan.addStaleOpToLucene) {
        // 这里会调用 Lucene 的接口
        indexResult = indexIntoLucene(index, plan);
    } else {
        indexResult = new IndexResult(
                plan.versionForIndexing, getPrimaryTerm(), plan.seqNoForIndexing, plan.currentNotFoundOrDeleted);
    }
    if (index.origin().isFromTranslog() == false) {
        final Translog.Location location;
        if (indexResult.getResultType() == Result.Type.SUCCESS) {
            // Lucene写成功后写 translog
            location = translog.add(new Translog.Index(index, indexResult));
        } else if (indexResult.getSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO) {
            // if we have document failure, record it as a no-op in the translog and Lucene with the generated seq_no
            final NoOp noOp = new NoOp(indexResult.getSeqNo(), index.primaryTerm(), index.origin(),
                index.startTime(), indexResult.getFailure().toString());
            location = innerNoOp(noOp).getTranslogLocation();
        } else {
            location = null;
        }
        indexResult.setTranslogLocation(location);
    }
        
    // 省略部分代码
    
}

在indexIntoLucene里面就可以看到熟悉的Lucene接口IndexWriter了:

private void addDocs(final List<ParseContext.Document> docs, final IndexWriter indexWriter) throws IOException {
    if (docs.size() > 1) {
        indexWriter.addDocuments(docs);
    } else {
        indexWriter.addDocument(docs.get(0));
    }
    numDocAppends.inc(docs.size());
}


private void updateDocs(final Term uid, final List<ParseContext.Document> docs, final IndexWriter indexWriter) throws IOException {
    if (softDeleteEnabled) {
        if (docs.size() > 1) {
            indexWriter.softUpdateDocuments(uid, docs, softDeletesField);
        } else {
            indexWriter.softUpdateDocument(uid, docs.get(0), softDeletesField);
        }
    } else {
        if (docs.size() > 1) {
            indexWriter.updateDocuments(uid, docs);
        } else {
            indexWriter.updateDocument(uid, docs.get(0));
        }
    }
    numDocUpdates.inc(docs.size());
}

以上就是一些关键代码。

总结

ES的数据可靠性是从两个维度进行保证的:一方面,通过增加translog,解决了因为进程挂掉和机器宕机引发的内存数据丢失的问题,另一方面,通过副本机制解决了单点故障(当然还可以分担查询的压力。

]]>
<![CDATA[Lucene系列(10)——相似度评分机制浅析(终篇)]]> http://niyanchun.com/lucene-learning-10.html 2019-11-23T17:22:00+08:00 2019-11-23T17:22:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

本文是Lucene系列的终篇,在这篇文章中,我们会简单聊一下Lucene的相似度评分机制。

TF-IDF

Bag-of-words模型

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

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}/length$,其中length为文档中的总词数。
  • 对数词频:$log(1+tf_{t,d})$,加1是防止对0求对数(0没有对数)。 一般选取常用对数或者自然对数。

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

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

Inverse document frequency

一个词的逆文档频率用于衡量该词提供了多少信息,计算方式定义如下:

$$ idf_t = log\frac{N}{df_t}=-log\frac{df_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

TF-IDF就是将TF和IDF结合起来,其实就是简单的相乘:

$$ tfidf(t, d)=tf_{t,d} \cdot idf_t $$

从公式可以分析出来,一个词 $t$ 在某个文档 $d$ 中的tf-idf值:

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

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

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

    • doc1: $tf=ln(1+1), idf=ln\frac{3}{3}, tfidf("the","doc1")= tf \cdot idf =ln2 \cdot ln1=0$
    • doc2: $tf=ln(1+2), idf=ln\frac{3}{3}, tfidf("the","doc2")=tf \cdot idf=ln3 \cdot ln1=0$
    • doc3: $tf=ln(1+1), idf=ln\frac{3}{3}, tfidf("the","doc3")=tf \cdot idf=ln2 \cdot ln1=0$
  2. "freedom"在doc1中出现了0次,在doc2中出现了1次,在doc3中出现了0次,语料库中包含"freedom"的文档只有1个。所以:

    • doc1: $tf=ln(1+0), idf=ln\frac{3}{1}, tfidf("freedom","doc1")= tf \cdot idf =ln1 \cdot ln3=0$
    • doc2: $tf=ln(1+1), idf=ln\frac{3}{1}, tfidf("freedom","doc2")= tf \cdot idf =ln2 \cdot ln3=0.76$
    • doc3: $tf=ln(1+0), idf=ln\frac{3}{1}, tfidf("freedom","doc3")= tf \cdot idf =ln1 \cdot ln3=0$
  3. "love"在doc1中现了0次,在doc2中出现了3次,在doc3中出现了5次,整个语料库有3个文档,包含"love"的文档有2个。所以:

    • doc1: $tf=ln(1+0), idf=ln\frac{3}{2}, tfidf("love","doc1")= tf \cdot idf =ln1 \cdot ln\frac{3}{2}=0$
    • doc2: $tf=ln(1+3), idf=ln\frac{3}{2}, tfidf("love","doc2")= tf \cdot idf =ln4 \cdot ln\frac{3}{2}=0.56$
    • doc3: $tf=ln(1+5), idf=ln\frac{3}{2}, tfidf("love","doc3")= tf \cdot idf =ln6 \cdot ln\frac{3}{2}=0.73$

我们简单分析一下结果:"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)$ 的相似度问题了。而衡量两个向量相似度最常用的方法就是余弦相似度(Cosine similarity),以下内容来自维基百科:

余弦相似性通过测量两个向量的夹角的余弦值来度量它们之间的相似性。0度角的余弦值是1,而其他任何角度的余弦值都不大于1;并且其最小值是-1。从而两个向量之间的角度的余弦值确定两个向量是否大致指向相同的方向。两个向量有相同的指向时,余弦相似度的值为1;两个向量夹角为90°时,余弦相似度的值为0;两个向量指向完全相反的方向时,余弦相似度的值为-1。这结果是与向量的长度无关的,仅仅与向量的指向方向相关。余弦相似度通常用于正空间,因此给出的值为0到1之间。

用公式表示就是:

$$ cosineSimilarity(q,d)=\frac{V(q) \cdot V(d)}{|V(q)||V(d)|}=v(q) \cdot v(d) $$

其中,$V(q)$是查询向量,$V(d)$是文档向量,$|V(q)|$和$|V(q)|$是两个向量的长度,$v(q)$和$v(q)$则是对应的单位向量。

这个就是Vector Space Model。

TFIDFSimilarity

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

$$ Score(q, d)=\sum_{t\in q}(tf_{t, d} \cdot {idf_t}^2 \cdot t.getBoost() \cdot norm(t, d)) $$

我们从外到内剖析一下这个公式。

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

    $$ norm(t,d) = \frac{1}{\sqrt{length}} $$

    其中 $length$是文档 $d$ 的长度。

  4. $tf_{t, d} \cdot {idf_t}^2$。Lucene假设一个词在搜索语句中的词频为1(即使出现多次也不影响,就是重复计算多次而已),所以可以把这个公式拆开写:

    $$ tf_{t, d} \cdot {idf_t}^2 = tf_{t, d} \cdot {idf_t} \cdot 1 \cdot {idf_t}=(tf_{t, d} \cdot {idf_t}) \cdot (tf_{t, q} \cdot {idf_t}) $$

    这里的$(tf_{t, d} \cdot {idf_t}) \cdot (tf_{t, q} \cdot {idf_t})$就对应上面的$v(d) \cdot v(q)$.

    在Lucene中,采用的TF计算公式为:

    $$ tf_{t,d} = \sqrt {frequency} $$

    IDF计算公式为:

    $$ idf_t =1+log\frac{N+1}{df_t+1} $$

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

BM25Similarity

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

对IDF的改良

BM25中的IDF公式为:

$$ idf_t^{BM25} = log(1+\frac{N-df_t+0.5}{df_t+0.5}) $$

注意:原版BM25的log中是没有加1的,Lucene为了防止产生负值,做了一点小优化。

虽然对公式进行了更改,但其实和原来的公式没有实质性的差异,下面是新旧函数曲线对比:

idf-comparation

对TF的改良1

BM25中TF的公式为:

$$ tf_{t,d}^{BM25}=((k+1)*tf)/(k+tf) $$

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

tf-comparation

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

对TF的改良2

在传统的计算公式中,还有一个norm。BM25将这个因素加到了TF的计算公式中,结合了norm因素的BM25中的TF计算公式为:

$$ tf_{t,d}^{BM25}=((k+1)*tf)/(k*(1.0-b+b*L)+tf) $$

和之前相比,就是给分母上面的 $k$ 加了一个乘数 $(1.0-b+b*L)$. 其中的 $L$ 的计算公式为:

$$ L = \frac{|d|}{avgDl} $$

其中,$|d|$是当前文档的长度,$avgDl$ 是语料库中所有文档的平均长度。

$b$ 是一个常数,用来控制 $L$ 对最总评分影响的大小,一般取0~1之间的数(取0则代表完全忽略 $L$ )。Lucene中 $b$ 的默认值为 0.75.

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

Reference

]]>
<![CDATA[Lucene系列(9)——QueryParser介绍]]> http://niyanchun.com/lucene-learning-9.html 2019-11-02T11:17:00+08:00 2019-11-02T11:17:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

本文介绍一个比较“特殊”的查询API——QueryParser,它的特殊之处在于定义了一些查询语法,通过这些语法几乎可以实现前文介绍的所有Query API提供的功能,但它的存在并不是为了替换那些API,而是用在一些交互式场景中。本文不会再细述Lucene各个查询的含义及用法(比如什么是edit distance),所以如果你还不熟悉,请务必先阅读《Lucene系列(8)——常用Query介绍》一文。

QueryParser概述

其实在《Lucene系列(2)——代码实践》一文中我们已经使用过QueryParser进行查询了,这里再贴一下部分代码:

/**
 * 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);
            // 省略后面查询结果打印的代码
            }
        }
    }
}

在这段代码中,先读取了已经创建好的索引文件,然后创建了一个QueryParser实例(queryParser)。接着不断读取用户输入(input),并传给QueryParser的parse方法,该方法通过用户的输入构建一个Query对象用于查询。

QueryParser的构造函数为QueryParser(String f, Analyzer a),第1个参数指定一个默认的查询字段,如果后面输入的input里面没有指定查询字段,则默认查询该该字段,比如输入hello表示在默认字段中查询"hello",而content: hello则表示在content字段中查询"hello"。第2个参数指定一个分析器,一般该分析器应该选择和索引阶段同样的Analyzer。

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

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

下面通过一些例子看一下如何通过QueryParser提供的语法在一个输入串中实现前文介绍的各种搜索。

QueryParser语法

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+ORNOT-,所有的操作符必须大写

  • OR是默认的操作符,表示满足任意一个term即可。比如搜索love ChinaloveChina之间就是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提供了一种方式,让普通用户可以不需要写代码,只是掌握一些语法就可以进行复杂的搜索,在一些交互式检索场景中,还是非常方便的。

References

]]>
<![CDATA[三个臭皮匠不如一个诸葛亮之DisjunctionMaxQuery查询介绍]]> http://niyanchun.com/DisjunctionMaxQuery-introduction.html 2019-10-27T08:59:28+08:00 2019-10-27T08:59:28+08:00 NYC https://niyanchun.com 本文介绍Lucene/ElasticSearch/Solr中的DisjunctionMaxQuery,这里我先给出Lucene 8.2.0版本JavaDoc对于该查询接口的描述:

A query that generates the union of documents produced by its subqueries, and that scores each document with the maximum score for that document as produced by any subquery, plus a tie breaking increment for any additional matching subqueries. This is useful when searching for a word in multiple fields with different boost factors (so that the fields cannot be combined equivalently into a single search field). We want the primary score to be the one associated with the highest boost, not the sum of the field scores (as BooleanQuery would give). If the query is "albino elephant" this ensures that "albino" matching one field and "elephant" matching another gets a higher score than "albino" matching both fields. To get this result, use both BooleanQuery and DisjunctionMaxQuery: for each term a DisjunctionMaxQuery searches for it in each field, while the set of these DisjunctionMaxQuery's is combined into a BooleanQuery. The tie breaker capability allows results that include the same term in multiple fields to be judged better than results that include this term in only the best of those multiple fields, without confusing this with the better case of two different terms in the multiple fields.

如果你已经知道DisjunctionMaxQuery的含义,就很容易理解上面这段话:该查询生成多个子查询的合集,对于一个文档,如果同时匹配多个子查询,则取其中评分最高的那个子查询的评分作为每个文档的最终评分。有些绕,直接通过例子来看这个查询是用来解决什么问题的。看完之后,你也就明白上面再说什么了。

一个例子

为了方便,这里以ES为例进行说明。先创建一个名为 dis-max-test 的索引,并插入2条文档,每个文档包含一个 nameintroduction 字段:

// 插入数据
PUT dis-max-test/_bulk
{ "index": {}}
{ "name": "William Henry Gates III, Bill Gates", "introduction": "Founder of Microsoft Corporation."}        // 第一条数据:Bill Gates的信息
{ "index": {}}
{ "name": "Melinda Gates", "introduction": "Wife of Gates, a former general manager at Microsoft."}            // 第二条数据:Melinda Gates的信息

假设现在我们想搜索和“Bill Gates”相关的内容,则可以通过如下语句方式进行搜索:

# 搜索语句
GET dis-max-test/_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
      ]
    }
  }
}

上面这个语句的含义是搜索name或者introduction字段里面包含“Bill Gates”的文档,其查询结果如下:

# 搜索结果
{
  "took" : 4,
  "timed_out" : false,
  "hits" : {
    "max_score" : 0.8281169,
    "hits" : [
      {
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "Ge7O3W0BYOeS6h1DGlUi",
        "_score" : 0.8281169,        # 评分
        "_source" : {
          "name" : "Melinda Gates",
          "introduction" : "Wife of Gates, a former general manager at Microsoft."
        }
      },
      {
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "GO7O3W0BYOeS6h1DGlUi",
        "_score" : 0.7952278,         # 评分
        "_source" : {
          "name" : "William Henry Gates III, Bill Gates",
          "introduction" : "Founder of Microsoft Corporation."
        }
      }
    ]
  }
}

这个搜索结果是正确的,Match搜索的时候会把“Bill Gates”先分词,结果是BillGates,搜索的结果里面也都至少其中一个。但是有一点让人不是很满意,按照我们的搜索意图,上面的第二条结果才更贴近,因为它里面包含完整的“Bill Gates”。但结果它的评分却比第一条低(即匹配度低),排在了后面。在分析原因之前,我们换成Lucene的DisjunctionMaxQuery(在ES里面叫dis_max)来查询一下:

# 查询语句
GET dis-max-test/_search
{
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
        ]
    }
  }
}

dis_max由多个match组成,其查询条件和上面的bool-should相同,看下查询结果:

{
  "took" : 3,
  "timed_out" : false,
  "hits" : {
    "max_score" : 0.7952278,
    "hits" : [
      {
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "GO7O3W0BYOeS6h1DGlUi",
        "_score" : 0.7952278,
        "_source" : {
          "name" : "William Henry Gates III, Bill Gates",
          "introduction" : "Founder of Microsoft Corporation."
        }
      },
      {
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "Ge7O3W0BYOeS6h1DGlUi",
        "_score" : 0.59891266,
        "_source" : {
          "name" : "Melinda Gates",
          "introduction" : "Wife of Gates, a former general manager at Microsoft."
        }
      }
    ]
  }
}

可以看到,查询结果与之前的一样,区别在于完全包含“Bill Gates”一词的那条文档排在了前面,因为它的评分高于Melinda Gates的那条文档,这个结果也正是我们想要的。看到这里,你应该已经有一点感觉了,虽然dis_max和boolean-should的查询条件相近,但其对于结果的评分却不一样,似乎dis_max更贴近我们的搜索意图。下面来探索一下造成这种差别的原因。

原理分析

ES的查询中支持一个explain的参数,如果将其设置为true的话,查询结果中就会额外输出计算得分的过程(_explanation 部分)。该参数默认是false的,我们将其改为true,然后再执行一下上面的两个查询,来看看造成两种不同结果背后的细节。

先看Boolean-should查询:

# 查询语句
GET dis-max-test/_search
{
  "explain": true, 
  "query": {
    "bool": {
      "should": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
      ]
    }
  }
}

# 查询结果
{
  "took" : 5,
  "timed_out" : false,
  "hits" : {
    "max_score" : 0.8281169,
    "hits" : [
      {
        "_shard" : "[dis-max-test][0]",
        "_node" : "aIzM2bJFT_afjUgEMxWosg",
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "Ge7O3W0BYOeS6h1DGlUi",
        "_score" : 0.8281169,
        "_source" : {
          "name" : "Melinda Gates",
          "introduction" : "Wife of Gates, a former general manager at Microsoft."
        },
        "_explanation" : {
          "value" : 0.8281169,
          "description" : "sum of:",        # 注意这里!!!
          "details" : [
            {
              "value" : 0.22920427,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.22920427,
                  "description" : "weight(name:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            },
            {
              "value" : 0.59891266,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.59891266,
                  "description" : "weight(introduction:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            }
          ]
        }
      },
      {
        "_shard" : "[dis-max-test][0]",
        "_node" : "aIzM2bJFT_afjUgEMxWosg",
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "GO7O3W0BYOeS6h1DGlUi",
        "_score" : 0.7952278,
        "_source" : {
          "name" : "William Henry Gates III, Bill Gates",
          "introduction" : "Founder of Microsoft Corporation."
        },
        "_explanation" : {
          "value" : 0.7952278,
          "description" : "sum of:",
          "details" : [
            {
              "value" : 0.7952278,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.5754429,
                  "description" : "weight(name:bill in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                },
                {
                  "value" : 0.21978492,
                  "description" : "weight(name:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            }
          ]
        }
      }
    ]
  }
}

为了节省篇幅以及看的更清楚,省略了计算评分的细节,这部分后面有单独的文章介绍。在这个查询中,Melinda Gates对应文档的评分0.8281169,高于Bill Gates对应文档的评分0.7952278,即对于这个查询而言,ES认为Melinda Gates对应文档比Bill Gates对应文档更贴近我们的搜索词“Bill Gates”。其原因是这样的:

  • 对于Melinda Gates对应文档而言,它的评分0.8281169是由下面details数组里面两个子查询的评分0.22920427和0.59891266两个评分加来的(即description字段的"sum of" 含义):0.22920427这个评分是name字段中包含了Gates这个搜索词而得的,0.59891266这个评分是introduction字段中包含也包含Gates而得的。
  • 对于Bill Gates对应的文档而言,它的评分0.7952278是由下面的0.5754429和0.21978492相加而来。0.5754429是name中包含bill获得的,0.21978492是name中包含gates获得的。introduction字段中没有匹配项,所以没有得分。

这样我们就明白了为什么虽然Bill Gates的文档更贴近搜索意图,其评分却低的原因。因为对于Boolean查询而言,其总评分是多个子查询的评分相加而来的(上面查询结果json中details数组里面一个元素代表一个查询结果)。Melinda Gates文档中虽然没有bill,但却包含多个Gates,所以累加下来总评分就高。但实际中对于有些场景通过这种累加所有子查询的结果并不能准确的代表查询意图,就好比三个臭皮匠很多时候是顶不了一个诸葛亮的。

为了解决这个问题,就产生了本文的主角DisjunctionMaxQuery,看下面查询:

GET dis-max-test/_search
{
  "explain": true, 
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
        ]
    }
  }
}

# 查询结果
{
  "took" : 5,
  "timed_out" : false,
  "hits" : {
    "max_score" : 0.7952278,
    "hits" : [
      {
        "_shard" : "[dis-max-test][0]",
        "_node" : "aIzM2bJFT_afjUgEMxWosg",
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "GO7O3W0BYOeS6h1DGlUi",
        "_score" : 0.7952278,
        "_source" : {
          "name" : "William Henry Gates III, Bill Gates",
          "introduction" : "Founder of Microsoft Corporation."
        },
        "_explanation" : {
          "value" : 0.7952278,
          "description" : "max of:",        # 注意这里
          "details" : [
            {
              "value" : 0.7952278,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.5754429,
                  "description" : "weight(name:bill in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                },
                {
                  "value" : 0.21978492,
                  "description" : "weight(name:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            }
          ]
        }
      },
      {
        "_shard" : "[dis-max-test][0]",
        "_node" : "aIzM2bJFT_afjUgEMxWosg",
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "Ge7O3W0BYOeS6h1DGlUi",
        "_score" : 0.59891266,
        "_source" : {
          "name" : "Melinda Gates",
          "introduction" : "Wife of Gates, a former general manager at Microsoft."
        },
        "_explanation" : {
          "value" : 0.59891266,
          "description" : "max of:",       # 注意这里
          "details" : [
            {
              "value" : 0.22920427,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.22920427,
                  "description" : "weight(name:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            },
            {
              "value" : 0.59891266,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.59891266,
                  "description" : "weight(introduction:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            }
          ]
        }
      }
    ]
  }
}

这个和上边的类似,但是dis_max在计算最终评分的时候并不是累加各个匹配的子查询,而是取评分最高的子查询结果作为最终结果(即"description" : "max of:",这里注意区分一下,DisjunctionMaxQuery这一层取max,而子查询内层依旧使用的是sum的方式来计算评分)。

到这里,我们就明白DisjunctionMaxQuery查询的含义了,它和BooleanQuery类似,也由多个子查询组成。BooleanQuery计算一个匹配文档的总评分时,是累加所有子查询的评分,而DisjunctionMaxQuery则是取评分最高的那个子查询的评分作为文档的最终得分。

还拿臭皮匠为例,如果说诸葛亮的IQ是145,而三个臭皮匠的IQ分别为91,85,84。如果你问BooleanQuery是诸葛亮聪明还是三个臭皮匠聪明,那它会告诉你三个臭皮匠聪明,因为诸葛亮IQ是145,而三个臭皮匠的IQ是91+85+84=260。显然这样是不对的。但如果你问DisjunctionMaxQuery同样的问题,它则会告诉你诸葛亮聪明,因为诸葛亮的IQ是145,而三个臭皮匠的IQ是91.

当然呢,有时候人多还是力量大的。三个臭皮匠在一起不一定能胜过一个诸葛亮,但一般还是可以胜过他们之中任意一个人的,所以直接取最高的,忽略掉另外两个人的IQ有时候也不太合适,特别是他们如果技能领域各不相同的话。所以DisjunctionMaxQuery又提供了一个tie_breaker参数,该参数合法值范围为[0, 1],默认取0. 计算最终得分的时候,DisjunctionMaxQuery会取最高分,同时加上各个子查询的得分乘以tie_breaker的值。即不是像BooleanQuery那样粗暴相加,而是给非最高分的评分给一个权重,毕竟量变可能会引起质变,完全忽略也不是很合适。至于tie_breaker该设置多少,这个需要结合具体的使用场景。

还是上面的dis_max查询,但我们将tie_breaker由默认值0改为0.9,会发现它的查询结果也发生了变化:

# 查询
GET dis-max-test/_search
{
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
        ],
        "tie_breaker": 0.9
    }
  }
}

# 查询结果
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 0.80519646,
    "hits" : [
      {
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "Ge7O3W0BYOeS6h1DGlUi",
        "_score" : 0.80519646,
        "_source" : {
          "name" : "Melinda Gates",
          "introduction" : "Wife of Gates, a former general manager at Microsoft."
        }
      },
      {
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "GO7O3W0BYOeS6h1DGlUi",
        "_score" : 0.7952278,
        "_source" : {
          "name" : "William Henry Gates III, Bill Gates",
          "introduction" : "Founder of Microsoft Corporation."
        }
      }
    ]
  }
}

使用explain查看上述查询评分的过程:

GET dis-max-test/_search
{
  "explain": true, 
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
        ],
        "tie_breaker": 0.9
    }
  }
}

GET dis-max-test/_search
{
  "explain": true, 
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "name": "Bill Gates"} },
        { "match": { "introduction": "Bill Gates"}}
        ],
        "tie_breaker": 0.9
    }
  }
}


# 查询结果
{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 0.80519646,
    "hits" : [
      {
        "_shard" : "[dis-max-test][0]",
        "_node" : "aIzM2bJFT_afjUgEMxWosg",
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "Ge7O3W0BYOeS6h1DGlUi",
        "_score" : 0.80519646,
        "_source" : {
          "name" : "Melinda Gates",
          "introduction" : "Wife of Gates, a former general manager at Microsoft."
        },
        "_explanation" : {
          "value" : 0.80519646,
          "description" : "max plus 0.9 times others of:",
          "details" : [
            {
              "value" : 0.22920427,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.22920427,
                  "description" : "weight(name:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            },
            {
              "value" : 0.59891266,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.59891266,
                  "description" : "weight(introduction:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            }
          ]
        }
      },
      {
        "_shard" : "[dis-max-test][0]",
        "_node" : "aIzM2bJFT_afjUgEMxWosg",
        "_index" : "dis-max-test",
        "_type" : "_doc",
        "_id" : "GO7O3W0BYOeS6h1DGlUi",
        "_score" : 0.7952278,
        "_source" : {
          "name" : "William Henry Gates III, Bill Gates",
          "introduction" : "Founder of Microsoft Corporation."
        },
        "_explanation" : {
          "value" : 0.7952278,
          "description" : "max plus 0.9 times others of:",
          "details" : [
            {
              "value" : 0.7952278,
              "description" : "sum of:",
              "details" : [
                {
                  "value" : 0.5754429,
                  "description" : "weight(name:bill in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                },
                {
                  "value" : 0.21978492,
                  "description" : "weight(name:gates in 0) [PerFieldSimilarity], result of:",
                  "details" : [/* 省略计算得分的细节 */]
                }
              ]
            }
          ]
        }
      }
    ]
  }
}

本文就介绍到这里。

References

]]>
<![CDATA[Lucene系列(8)——常用Query介绍]]> http://niyanchun.com/lucene-learning-8.html 2019-10-20T08:09:00+08:00 2019-10-20T08:09:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

搜索是使用Lucene的根本目的,本文介绍Lucene提供的常用查询。下面的讲述中,会以之前《Lucene系列(2)——代码实践》文章中4首短诗的索引数据为例进行查询,你可以先阅读那篇文章构建索引。在Lucene中,Term是查询的基本单元(unit),所有查询类的父类是org.apache.lucene.search.Query,本文会介绍下图中这些主要的Query子类:

Lucene Query

DisjunctionMaxQuery主要用于控制评分机制,SpanQuery代表一类查询,有很多的实现。这两类查询不是非常常用,放在以后的文章单独介绍。本文所有、示例的完整代码见这里

TermQuery

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

看下面代码:

/**
 * 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关键字,所以程序执行结果为:

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的词:

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类的查询需要掌握两个点:

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

Edit distance

Edit distance用于描述两个字符串(词也是一种特殊的字符串)的相似度,其定义有多种,比较常用的是 Levenshtein distance 和其扩展 Damerau–Levenshtein distance。Lucene使用的就是这两种。Levenshtein distance是这样定义edit distance的:如果最少通过n个 增加(Insertion)/删除(Deletion)/替换(Substitution) 单个符号(symbol)的操作能使两个字符串相等,那这两个字符串的距离就是 n。这里有三个注意点:

  1. 只允许使用增、删、替换三种操作。
  2. 一次只能操作1个符号。如果是计算两个词的距离,那一个符号就代表一个字母;如果是计算两个句子的距离,那一个符号就代表一个词。
  3. 计算的是最少达到目标的操作数。

举几个例子:

  • cat与cut的edit distance是1,因为通过将cat的a替换为u这1个字母替换操作就可以让cat和cut相等。
  • cate与cut的edit distance是2,因为需要将cate的a替换为u,再将e删除两个操作才可以使得cate和cut相等。
  • cat与cta的edit distance是2,因为需要将cat的a替换为t,t替换为a两个操作才可以使得cat和cta相等。
  • "a bad boy"和"a good boy"的edit distance是1,因为需要将bad替换为good才能使两个句子相等(注意这里计算的是两个句子的distance,所以一个symbol就是一个词)。
  • "good boy"和"boy good"的edit distance是2,因为最少需要两步操作才可以使两个句子相等。

Damerau–Levenshtein distance对Levenshtein distance做了一个扩展:增加了一个transposition操作,定义 相邻 symbol的位置交换为1次操作,即distance为1。 这样的话在Levenshtein distance中,cat和cta的距离为2,但在Damerau–Levenshtein distance中,它们的距离就是1了;同理,"good boy"和"boy good"的距离也就是1了。

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

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的文档都可以被检索到,同时距离越小的文档评分越高。看下面例子:

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查询:["喜欢",“爱”], ["土豆","马铃薯","洋芋"],这样查出来的结果就会更全面一些。

最后来个例子:

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,比较简短的文档,千万级以下的查询也能毫秒级返回。最后看几个使用的例子:

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不成立,所以算不匹配。

最后看个例子:

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,功能和原理都很简单,我们直接看一个完整的例子吧:

/**
 * 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)方法。直接看例子:

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包装一下看下效果:

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

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

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

References

]]>
<![CDATA[Lucene系列(7)——索引存储文件介绍]]> http://niyanchun.com/lucene-learning-7.html 2019-10-19T08:00:00+08:00 2019-10-19T08:00:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

本文讨论Lucene底层索引数据存储。对于绝大数多人来说了解Lucene的上层概念足矣,无需关注底层的存储格式。所以本文虽然是讨论底层数据存储的,但也不会深入到具体的数据结构、压缩算法等。如果你有兴趣,可以查看对应版本的Lucene Java doc(8.2.0版本的链接已经附在文末)。另外,如果你对index、document、term、segment、term vector、norm等上层概念还不清楚,建议先阅读该系列文章的前几篇。

索引文件格式

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

index-file

可以看到一个索引包含了很多文件,似乎很复杂。但仔细观察之后会发现乱中似乎又有些规律:很多文件前缀一样,只是后缀不同,比如有很多_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个。

  • .siSegment Info的缩写,用于记录segment的一些元数据信息。
  • .fnmFields,用于记录fields设置类信息,比如字段的index option信息,是否存储了norm信息、DocValue等。
  • .fdtField Data,存储字段信息。当通过StoredField或者Field.Store.YES指定存储原始field数据时,这些数据就会存储在该文件中。
  • .fdxField Index.fdt文件的索引/指针。通过该文件可以快速从.fdt文件中读取field数据。
  • .docFrequencies,存储了一个documents列表,以及它们的term frequency信息。
  • .posPositions,和.doc类似,但保存的是position信息。
  • .pay:Payloads,和.doc类似,但保存的是payloads和offset信息。
  • .timTerm Dictionary,存储所有文档analyze出来的term信息。同时还包含term对应的document number以及若干指向.doc, .pos, .pay的指针,从而可以快速获取term的term vector信息。。
  • .tipTerm Index,该文件保存了Term Dictionary的索引信息,使得可以对Term Dictionary进行随机访问。
  • .nvd, .nvmNorms,这两个都是用来存储Norms信息的,前者用于存储norms的数据,后者用于存储norms的元数据。
  • .dvd, .dvmPer-Document Values,这两个都是用来存储DocValues信息的,前者用于数据,后者用于存储元数据。
  • .tvdTerm Vector Data,用于存储term vector数据。
  • .tvxTerm Vector Index,用于存储Term Vector Data的索引数据。
  • .livLive Documents,用于记录segment中哪些documents没有被删除。一般不存在该文件,表示segment内的所有document都是live的。如果有documents被删除,就会产生该文件。以前是使用一个.del后缀的文件来记录被删除的documents,现在改为使用该文件了。
  • .dim,.diiPoint 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:

  /**
   * 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做一些操作来具体感受一下。

一些例子

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

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>/<shard>/index,这里的shard就是Lucene的index。我们看下刚创建的index的目录:

-> % 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_Nwrite.lock文件,没有segment级别的文件。写入1条数据并查看索引目录的变化:

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条数据,并查看目录变化:

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一下,让内存中的数据落地:

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这条数据:

PUT nyc-test/doc/3
{
  "name": "Patric"
}

# 查看
-> % 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    0 10月 11 22:23 _1.fdt
-rw-rw-r-- 1 allan allan    0 10月 11 22:23 _1.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

# 更新数据
PUT nyc-test/doc/3?refresh=true
{
  "name": "James"
}

# 查看
-> % 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  230 10月 11 22:19 segments_2
-rw-rw-r-- 1 allan allan    0 10月 11 22:19 write.lock

可以看到,再次refresh的时候又形成了一个新的segment,并且因为update,导致删掉了1条document,所以产生了一个.liv文件。但前面的这些流程中,segments_N文件也就是segments_2一直没有变过,这是因为一直没有Lucene概念中的commit操作发生过。ES的flush操作对应的是Lucene的commit,我们触发一次Lucene commit看下变化:

# 触发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:

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自己的设计。

最后用图总结一下:

Lucene-Index-Files-Format.png

本文就介绍到这里,对于绝大多数使用者来说,只需要知道Lucene索引后台存储的组织逻辑和层次,以更好的使用Lucene及基于Lucene的产品即可。

References

]]>
<![CDATA[Lucene系列(6)——字段及其属性]]> http://niyanchun.com/lucene-learning-6.html 2019-09-25T23:24:00+08:00 2019-09-25T23:24:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

回忆一下之前文章中创建字段(Field)的一些代码片段:

// 片段1
Field pathField = new StringField("path", file.toString(),Field.Store.YES);

// 片段2
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);

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

1. Field类

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

image-20190918223509632

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

  • 是否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及其子类我们需要注意以下两点:

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

2. FieldType类

org.apache.lucene.document.FieldType类实现了org.apache.lucene.index.IndexableFieldType接口,用于描述字段的属性。之前的文章中有使用过该类来自定义字段属性的代码,这里再贴一下相关的代码片段:

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);

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

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信息。

]]>
<![CDATA[Lucene系列(5)——倒排索引、Token与词向量]]> http://niyanchun.com/lucene-learning-5.html 2019-09-20T22:17:00+08:00 2019-09-20T22:17:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

上文我们对Analyzer的原理和代码进行了分析,主要偏重流程,这篇文章来分析Analyzer的输出细节——Token。对原始数据进行Analyze的终极目的是为了更好的搜索,所以还会讨论和搜索相关的倒排索引词向量(Term Vector)

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

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

  1. a good student.
  2. a gifted student.

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

Inverted Index

Forward Index

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

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

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

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

SHE

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

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

Token

在《Lucene系列(3)——术语总结》一文中我们说了token除了包含词以外,还存在一些其它属性,下面就让我们来看看完整的token长什么样?看下面代码(源文件见AnalysisDebug.java):

package com.niyanchun;

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;

/**
 * Debug Analysis Process.
 *
 * @author NiYanchun
 **/
public class AnalysisDebug {

    public static void main(String[] args) throws Exception {
        Analyzer analyzer = new StandardAnalyzer();
//        Analyzer analyzer = new StandardAnalyzer(EnglishAnalyzer.ENGLISH_STOP_WORDS_SET);
        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();
        }
    }
}

上述代码很简单,如果你看过上篇文章《Lucene系列(4)——探秘Analyzer》的话,应该不难理解。我们借助TokenStream对象输出经过StandardAnalyzer处理的数据,程序运行结果如下:

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定义了这几种类型:<ALPHANUM>、<NUM>、<SOUTHEAST_ASIAN>、<IDEOGRAPHIC>、<HIRAGANA>、<KATAKANA>、<HANGUL>、<EMOJI>
  • 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的具体位置。

下面让我们从代码角度来验证一下上面的理论(源代码见TermVectorShow.java):

package com.niyanchun;

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;

/**
 * Show Term Vector.
 *
 * @author NiYanchun
 **/
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。

我们看一下程序的输出结果:

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信息。

最后还有个问题就是为什么词向量里面会带向量这个词呢?词向量一词并非Lucene中发明的概念,而是IR领域的一个概念,再细点就是Vector space model 文本相似度模型中的概念,做文本相关算法的朋友应该对这个比较熟悉。将term转化成向量空间之后,我们就可以使用余弦相似度(cosine similarity)来计算我们搜索语句与index中document之间的相似度了(推荐领域使用这种算法的也比较多)。这块内容比较多,后面有空专门写文章介绍吧。

]]>
<![CDATA[Lucene系列(4)——Analyzer原理及代码分析]]> http://niyanchun.com/lucene-learning-4.html 2019-09-17T21:53:00+08:00 2019-09-17T21:53:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

前面的文章中多次提到了分析器Analyzer,它就像一个数据加工厂,输入是原始的文本数据,输出是经过各种工序加工的term,然后这些terms以倒排索引的方式存储起来,形成最终用于搜索的Index。所以Analyzer也是我们控制数据能以哪些方式检索的重要点,本文就带你来了解一下Analyzer背后的奥秘。

内置的Analyzer对比

Lucene已经帮我们内置了许多Analyzer,我们先来挑几个常见的对比一下他们的分析效果吧。看下面代码(源文件见AnalyzerCompare.java):

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;

/**
 * Compare Lucene Internal Analyzers.
 *
 * @author NiYanchun
 **/
public class AnalyzerCompare {

    private static final Analyzer[] ANALYZERS = new Analyzer[]{
            new WhitespaceAnalyzer(),
            new KeywordAnalyzer(),
            new SimpleAnalyzer(),
            // 标准分词器会处理停用词,但默认其停用词库为空,这里我们使用英文的停用词
            new StandardAnalyzer(EnglishAnalyzer.getDefaultStopSet())
    };

    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),但其内置的停用词库为空,所以我们传了一个英文默认的停用词库。

运行代码之后的输出如下:

原始数据:
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"]

最后附上上面四个停用词的功能,方便大家理解上述结果:

  • WhitespaceAnalyzer:仅根据空白字符(whitespace)进行分词。
  • KeywordAnalyzer:不做任何分词,把整个原始输入作为一个token。所以可以看到输出只有1个token,就是原始句子。
  • SimpleAnalyzer:根据非字母(non-letters)分词,并且将token全部转换为小写。所以该分词的输出的terms都是由小写字母组成的。
  • StandardAnalyzer:基于JFlex进行语法分词,然后删除停用词,并且将token全部转换为小写。

Analyzer原理

前面我们说了Analyzer就像一个加工厂,包含很多道工序。这些工序在Lucene里面分为两大类:TokenizerTokenFilter

Tokenizer永远是Analyzer的第一道工序,有且只有一个。它的作用是读取输入的原始文本,然后根据工序的内部定义,将其转化为一个个token输出。

TokenFilter只能接在Tokenizer之后,因为它的输入只能是token。然后它将输入的token进行加工,输出加工之后的token。一个Analyzer中,TokenFilter可以没有,也可以有多个。

也就是说一个Analyzer内部的流水线是这样的:

Analyzer Pipeline

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

StandardAnalyzer Pipeline

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

Analyzer源码分析

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的实现如下:

public final class SimpleAnalyzer extends Analyzer {

  /**
   * Creates a new {@link SimpleAnalyzer}
   */
  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。TokenStream类文档是这样描述的:

A TokenStream enumerates the sequence of tokens, either from Fields of a Document or from query text.

Tokenizer和TokenFilter

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

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

]]>
<![CDATA[Lucene系列(3)——术语总结]]> http://niyanchun.com/lucene-learning-3.html 2019-09-15T21:42:00+08:00 2019-09-15T21:42:00+08:00 NYC https://niyanchun.com 注:本文基于Lucene 8.2.0 版本。

前两篇文章分别从理论和代码角度概览了Lucene的功能,在开始各个模块的深入学习之前,我们先来总结一下之前提到的一些概念、术语,因为这些会贯穿后面几乎所有的文章,所以有必要理解清楚。其中有些概念是Lucene定义的,有些则是通用的IR领域术语。

我画了一个索引整体的逻辑结构图,如下所示:

索引逻辑结构

我们根据图来介绍各个概念术语。

索引(Index)

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

  • 动词:做动词时,一般英文写为“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,如下:

{
    { "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,然后合并搜索结果。

以上就是Lucene中最常用的一些概念术语。

]]>