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。
仔细想一下上面介绍的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,持久化到磁盘上面。整个流程如下图:
这里有几个问题需要说明一下。
Translog
类的Java doc说明。因为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自身进行机制进行优化,结合上面的介绍,可以很容易得出下面的一些优化手段:
优化往往都是有代价的,需要根据自己的实际场景进行评估。
本来计划是写一篇从原理、源码分析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操作为例列出调用链上的关键类和方法,供参考:
RestIndexAction#prepareRequest
:解析参数,构建IndexRequest
,然后转发给TransportBulkAction#doExecute
执行TransportBulkAction
:
doExecute
:检查是否需要创建索引,如果需要则创建executeIngestAndBulk
:检查是否需要执行pipeline,如果有且本节点是ingest节点,则执行,否则转发到ingest节点executeBulk->BulkOperation#doRun
:按需生成doc id,计算操作在哪个shard执行,按照shard进行分组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,解决了因为进程挂掉和机器宕机引发的内存数据丢失的问题,另一方面,通过副本机制解决了单点故障(当然还可以分担查询的压力。
]]>本文是Lucene系列的终篇,在这篇文章中,我们会简单聊一下Lucene的相似度评分机制。
先介绍一下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)在一个文档中(document)出现的次数(frequency),记为 $tf_{t,d}$。这是一种最简单的定义方式,实际使用中还有一些变种:
词频的优点是简单,但缺点也很显然:
一个词的逆文档频率用于衡量该词提供了多少信息,计算方式定义如下:
$$ 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结合起来,其实就是简单的相乘:
$$ tfidf(t, d)=tf_{t,d} \cdot idf_t $$
从公式可以分析出来,一个词 $t$ 在某个文档 $d$ 中的tf-idf值:
下面用一个例子来实战一下,还是以《Lucene系列(2)——代码实践》文中的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算法的结果还是能很好的表示实际结果的。
通过TF-IDF算法,我们可以计算出每个词在语料库中的权重,而通过VSM(Vector Space Model),则可以计算两个文档的相似度。
假设有两个文档:
这是原始的文档,然后通过词袋模型转化后为:
接着,分别用TF-IDF算法计算每个文档词袋中每个词的tf-idf值(值是随便写的,仅供原理说明):
如果将上面的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。
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)) $$
我们从外到内剖析一下这个公式。
$norm(t, d)$。之前的系列文章中也介绍过,查询的时候一个文档的长短也是会影响词的重要性,匹配次数一样的情况下,越长的文档评分越低。这个也好理解,比如我们搜"Alibaba",有两个文档里面都出现了一次该词,但其中一个文档总共包含100万个词,而另外一个只包含10个词,很显然,极大多数情况下,后者与搜索词的相关度是比前者高的。实际计算的时候使用的公式如下:
$$ norm(t,d) = \frac{1}{\sqrt{length}} $$
其中 $length$是文档 $d$ 的长度。
$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.
BM25全称“Best Match 25”,其中“25”是指现在BM25中的计算公式是第25次迭代优化。该算法是几位大牛在1994年TREC-3(Third Text REtrieval Conference)会议上提出的,它将文本相似度问题转化为概率模型,可以看做是TF-IDF的改良版,我们看下它是如何进行改良的。
BM25中的IDF公式为:
$$ idf_t^{BM25} = log(1+\frac{N-df_t+0.5}{df_t+0.5}) $$
注意:原版BM25的log中是没有加1的,Lucene为了防止产生负值,做了一点小优化。
虽然对公式进行了更改,但其实和原来的公式没有实质性的差异,下面是新旧函数曲线对比:
BM25中TF的公式为:
$$ tf_{t,d}^{BM25}=((k+1)*tf)/(k+tf) $$
其中$tf$是传统的词频值。先来看下改良前后的函数曲线对比吧(下图中$k=1.2$):
可以看到,传统的tf计算公式中,词频越高,tf值就越大,没有上限。但BM中的tf,随着词频的增长,tf值会无限逼近$(k+1)$,相当于是有上限的。这就是二者的区别。一般 $k$ 取 1.2,Lucene中也使用1.2作为 $k$ 的默认值。
在传统的计算公式中,还有一个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版本开始,上位成为默认的相似度评分算法。
本文介绍一个比较“特殊”的查询API——QueryParser,它的特殊之处在于定义了一些查询语法,通过这些语法几乎可以实现前文介绍的所有Query API提供的功能,但它的存在并不是为了替换那些API,而是用在一些交互式场景中。本文不会再细述Lucene各个查询的含义及用法(比如什么是edit distance),所以如果你还不熟悉,请务必先阅读《Lucene系列(8)——常用Query介绍》一文。
其实在《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。
另外有两个点需要特别注意:
hello world
,表示先将hello world分词(一般会分为hello和world两个词),然后使用TermQuery查询。如果需要全词匹配(即使用PhraseQuery),则需要将搜索词用双引号引起来,比如"hello world"
。title:hello world
这个输入,title仅对hello有效,即搜索时只会在title字段中搜索hello,然后在默认搜索字段中搜索world。如果想要在一个字段中搜索多个词或多个用双引号引起来的词组时,将这些词用小括号括起来即可,比如title:(hello world)
。下面通过一些例子看一下如何通过QueryParser提供的语法在一个输入串中实现前文介绍的各种搜索。
通配符搜索和WildcardQuery API一样,仅支持?
和*
两个通配符,前者用于匹配1个字符,后者匹配0到多个字符。输入title:te?t
,则可以匹配到title中的"test"、"text"等词。
注意:使用QueryParser中的wildcard搜索时,不允许以?
和*
开头,否则会抛异常,但直接使用WildcardQuery API时,允许以通配符开头,只是因为性能原因,不推荐使用。这样设计的原因我猜是因为QueryParser的输入是面向用户的,用户对于通配符开头造成的后果并不清楚,所以直接禁掉;而WildcardQuery是给开发者使用的,开发者在开发阶段很清楚如果允许这样做造成的后果是否可以接受,如果不能接受,也是可以通过接口禁掉开头就是用通配符的情况。
正则搜索和RegexpQuery一样,不同之处在于QueryParser中输入的正则表达式需要使用两个斜线("/")包围起来,比如匹配"moat"或"boat"的正则为/[mb]oat/
。
在QueryParser中,通过在搜索词后面加波浪字符来实现FuzzyQuery,比如love~
,默认edit distance是2,可以在波浪符后面加具体的整数值来修改默认值,合法的值为0、1、2.
PhraseQuery中可以指定slop(默认值为0,精确匹配)来实现相似性搜索,QueryParser中同样可以,使用方法与Fuzzy类似——将搜索字符串用双引号引起来,然后在末尾加上波浪符,比如"jakarta apache"~10
。这里对数edit distance没有限制,合法值为非负数,默认值为0.
QueryParser的范围搜索同时支持TermRangeQuery和数值型的范围搜索,排序使用的是字典序。开区间使用大括号,闭区间使用方括号。比如搜索修改日期介于2019年9月份和10月份的文档:mod_date:[20190901 TO 20191031]
,再比如搜索标题字段中包含hate到love的词(但不包含这两个词)的文档:title:{hate TO love}
.
查询时可以通过给搜索的关键字或双引号引起来的搜索串后面添加脱字符(^
)及一个正数来提升其计算相关性时的权重(默认为1),比如love^5 China
或"love China"^0.3
。
QueryParser中提供了5种布尔操作符:AND
、+
、OR
、NOT
、-
,所有的操作符必须大写。
OR
是默认的操作符,表示满足任意一个term即可。比如搜索love China
,love和China之间就是OR的关系,检索时文档匹配任意一个词即视为匹配。OR也可以使用可用||
代替。AND
表示必须满足所有term才可以,可以使用&&
代替。+
用在term之前,表示该term必须存在。比如+love China
表示匹配文档中必须包含love,China则可包含也可不含。-
用在term之前,表示该term必须不存在。比如-"hate China" "love China"
表示匹配文档中包含"love China",但不包含"hate China"的词。前面已经介绍过,可以使用小括号进行分组,通过分组可以表达一些复杂的逻辑。举两个例子:
(jakarta OR apache) AND website
表示匹配文档中必须包含webiste,同时需要至少包含jakarta或apache二者之一。title:(+return +"pink panther")
表示匹配文档中的title字段中必须同时存在return和"pink panther"串。从前面的介绍可知,有很多符号在QueryParser中具有特殊含义,目前所有的特殊符号包括:+- && || ! ( ) { } [ ] ^ " ~ * ? : \ /
。如果搜索关键字中存在这些特殊符号,则需要使用反斜线(\
)转义。比如搜索(1+1)*2
则必须写为\(1\+1\)\*2
。
相比于Lucene的其它搜索API,QueryParser提供了一种方式,让普通用户可以不需要写代码,只是掌握一些语法就可以进行复杂的搜索,在一些交互式检索场景中,还是非常方便的。
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条文档,每个文档包含一个 name 和 introduction 字段:
// 插入数据
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”先分词,结果是Bill、Gates,搜索的结果里面也都至少其中一个。但是有一点让人不是很满意,按照我们的搜索意图,上面的第二条结果才更贴近,因为它里面包含完整的“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”。其原因是这样的:
这样我们就明白了为什么虽然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" : [/* 省略计算得分的细节 */]
}
]
}
]
}
}
]
}
}
本文就介绍到这里。
搜索是使用Lucene的根本目的,本文介绍Lucene提供的常用查询。下面的讲述中,会以之前《Lucene系列(2)——代码实践》文章中4首短诗的索引数据为例进行查询,你可以先阅读那篇文章构建索引。在Lucene中,Term是查询的基本单元(unit),所有查询类的父类是org.apache.lucene.search.Query
,本文会介绍下图中这些主要的Query
子类:
DisjunctionMaxQuery主要用于控制评分机制,SpanQuery代表一类查询,有很多的实现。这两类查询不是非常常用,放在以后的文章单独介绍。本文所有、示例的完整代码见这里。
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用于将若干个查询按照与或的逻辑关系组织起来,支持嵌套。目前支持4个逻辑关系:
使用方式也比较简单,以下的代码使用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 Secret和Freedom and Love两首诗中均包含了love一词,但前者还包含了seek一词,所以最终的搜索结果为Freedom and Love。
PhraseQuery用于搜索term序列,比如搜索“hello world”这个由两个term组成的一个序列。对于Phrase类的查询需要掌握两个点:
Edit distance用于描述两个字符串(词也是一种特殊的字符串)的相似度,其定义有多种,比较常用的是 Levenshtein distance 和其扩展 Damerau–Levenshtein distance。Lucene使用的就是这两种。Levenshtein distance是这样定义edit distance的:如果最少通过n个 增加(Insertion)/删除(Deletion)/替换(Substitution) 单个符号(symbol)的操作能使两个字符串相等,那这两个字符串的距离就是 n。这里有三个注意点:
举几个例子:
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讲解的都比较少。但其实它的功能很简单,举个例子就明白了:我们提供两个由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
这三个查询提供模糊模糊查询的功能:
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和PhraseQuery一样,都是基于上面介绍的edit distance做匹配的,差异是在PhraseQuery中搜索词的是一个term序列,此时edit distance中定义的一个symbol就是一个词;而FuzzyQuery的搜索词就是一个term,所以它对应的edit distance中的symbol就是一个字符了。另外使用时还有几个注意点:
LevenshteinAutomata.MAXIMUM_SUPPORTED_DISTANCE
值限定),因为对于更大的edit distance会匹配出特别多的词,但FuzzyQuery的定位是解决诸如美式英语和英式英语在拼写上的细微差异。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
前面介绍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和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很简单,它的功能是将其它查询包装起来,并将它们查询结果中的评分改为一个常量值(默认为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)。
这个查询很简单,就是匹配所有文档,用于没有特定查询条件,只想预览部分数据的场景。直接看例子:
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
本文讨论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的数据目录:
可以看到一个索引包含了很多文件,似乎很复杂。但仔细观察之后会发现乱中似乎又有些规律:很多文件前缀一样,只是后缀不同,比如有很多_3c
开头的文件。回想一下之前文章的介绍,index由若干个segment组成,而一个index目录下前缀相同表示这些文件都属于同一个segment。
那各种各样的后缀又代表什么含义呢?Lucene存储segment时有两种方式:
我们先来介绍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
, .liv
4个文件了。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_N
和write.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索引后台存储的组织逻辑和层次,以更好的使用Lucene及基于Lucene的产品即可。
回忆一下之前文章中创建字段(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之后存储哪些信息,进而决定了以后我们可以使用哪些方式进行检索。本文就来介绍字段以及它的的一些常用属性。
Field对应的类是org.apache.lucene.document.Field
,该类实现了org.apache.lucene.document.IndexableField
接口,代表用于indexing的一个字段。Field类比较底层一些,所以Lucene实现了许多Field子类,用于不同的场景,比如下图是IDEA分析出来的Field的子类:
如果有某个子类能满足我们的场景,那推荐使用子类。在介绍常用子类之前,需要了解一下字段的三大类属性:
这些属性就是由之前文章中介绍的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及其子类我们需要注意以下两点:
StoredField
)都默认不存储原始数据,如果需要存储原始数据,就要额外增加一个StoredField
类型的字段,专门用于存储原始数据。注意该类也是Field的一个子类。当然也有一些子类的构造函数中提供了参数来控制是否存储原始数据,比如StringField
,创建实例时可以通过传递Field.Store.YES
参数来存储原始数据。Field
类,再配合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;
大部分属性含义已经比较清楚了,这里再简单介绍一下其含义:
org.apache.lucene.index.IndexOptions
,已经介绍过了。默认值为DOCS_AND_FREQS_AND_POSITIONS
。document个数*每个document的字段数
个字节),同时indexing的速度慢了。本文内容比较杂乱,但其实对于我们使用(包括ES、Solr等基于Lucene的软件)而言,只需要知道我们检索一个字段的时候可以控制保存哪些信息,以及这些信息在什么场景下使用,能带来什么好处,又会产生什么弊端即可。举个例子:比如我们在设计字段的时候,如果一个字段不会用来排序,也不会做聚集,那就没有必要生成DocValue,既能节省磁盘空间,又能提高写入速度。另外对于norm信息,如果你的场景只关注是否包含,那无需保存norm信息,但如果也关注相似度评分,并且文本长短是一个需要考虑的因素,那就应该保存norm信息。
]]>上文我们对Analyzer的原理和代码进行了分析,主要偏重流程,这篇文章来分析Analyzer的输出细节——Token。对原始数据进行Analyze的终极目的是为了更好的搜索,所以还会讨论和搜索相关的倒排索引和词向量(Term Vector)。
我们用一个例子来看什么是倒排索引,什么是正向索引。假设有两个文档(前面的数字为文档ID):
这两个文档经过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。
在《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
包下面,有兴趣的可以查阅。这里我们简单介绍一下这些属性:
<ALPHANUM>、<NUM>、<SOUTHEAST_ASIAN>、<IDEOGRAPHIC>、<HIRAGANA>、<KATAKANA>、<HANGUL>、<EMOJI>
。除了以上属性外,还有一个可能存在的属性就是payload,我们可以在这个字段里面存储一些信息。以上就是一个完整的Token。接下来让我们看什么是词向量。
Analyzer分析出来的Token并不会直接写入Index,还需要做一些转化:
所以,倒排索引和词向量都是从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之间的相似度了(推荐领域使用这种算法的也比较多)。这块内容比较多,后面有空专门写文章介绍吧。
]]>前面的文章中多次提到了分析器Analyzer,它就像一个数据加工厂,输入是原始的文本数据,输出是经过各种工序加工的term,然后这些terms以倒排索引的方式存储起来,形成最终用于搜索的Index。所以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"]
最后附上上面四个停用词的功能,方便大家理解上述结果:
前面我们说了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对应的实现类是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.
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的实现的源码,一定会有收获。下篇文章我们分析Analyzer处理之后的token和term的细节。
]]>前两篇文章分别从理论和代码角度概览了Lucene的功能,在开始各个模块的深入学习之前,我们先来总结一下之前提到的一些概念、术语,因为这些会贯穿后面几乎所有的文章,所以有必要理解清楚。其中有些概念是Lucene定义的,有些则是通用的IR领域术语。
我画了一个索引整体的逻辑结构图,如下所示:
我们根据图来介绍各个概念术语。
对于初学全文检索的人来说,索引这个词非常具有迷惑性,主要原因是它有两个词性:
所以,见到索引这个词,你一定要分清楚是动词还是名词。后面为了清楚,凡是作为动词的时候我使用indexing,作为名词的时候使用index。Index是Lucene中的顶级逻辑结构,它是一个逻辑概念,如果对应到具体的实物,就是一个目录,目录里面所有的文件组成一个index。注意,这个目录里面不会再嵌套目录,只会包含多个文件。具体index的构成细节后面会专门写一篇文章来介绍。对应到代码里面,就是org.apache.lucene.store.Directory
这个抽象类。最后要说明一点的是,Lucene中的Index和ElasticSearch里面的Index不是一个概念,ElasticSearch里面的shard对应的才是Lucene的Index。
一个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在第一篇文章里面已经介绍过了,存储在字段中的文本数据经过分词器分词后(准确的说是经过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)。
在上面的图中,Document分别被一些绿框括了起来,这个称为Segment。Indexing的时候,并不是将所有数据写到一起,而是再分了一层,这层就是segment。Indexing的时候,会先将Document缓存,然后定期flush到文件。每次flush就会生成一个Segment。所以一个Index包含若干个Segment,每个Segment就是一部分Document所生成的一个倒排索引。为了减少文件描述符的使用,这些小的Segment会定期的合并为(merge)大的Segment,数据量不大的时候,合并之后一个index可能只有一个Segment。搜索的时候,会搜索各个Segment,然后合并搜索结果。
以上就是Lucene中最常用的一些概念术语。
]]>