Elasticsearch之高亮进阶-高性能高亮器, 让Elasticsearch飞一下子

   
     不少应用场景下,搜索带高亮显示能够较好的改善用户体验。经常使用的企业搜索引擎Elasticsearch、Solr中均提供了高亮的功能。Elasticsearch、Solr中的高亮显示是均来源于lucene的高亮模块,luncene容许在一个或者多个字段上突出显示搜索内容,在中高亮方式上,lucene支持三种高亮显示方式highlighter, fast-vector-highlighter, postings-highlighter,  在solr中,highlighter 高亮是缺省配置高亮方式。在ElasticSearch中,highlighter 一样是默认的高亮方式。  

highlighter
html

     highlighter 高亮也叫plain高亮,该方式有必定的优势也有必定的缺点,先说说缺点。highlighter方式高亮是个实时分析处理高亮器。即用户在查询的时候,es取到了符合条件的docid后,将须要高亮的字段数据提取到内存,再调用该字段的分析器进行分词,分词完毕后采用类似度算法计算得分最高的前n组并高亮段返回数据。以ansj分析器为例,官方给出的性能在60-80万字/每秒,但实际上中服务器运行效率会小于该值(服务器主频都比较低),在生产环境下,ansj分词效率大多在 40-50万字/秒。假设用户搜索的都是比较大的文档同时须要进行高亮。按照一页查询40条(每条数据20k)的方式进行显示,即便类似度计算以及搜索排序不耗时,整个查询也会被高亮拖累到接近两秒,这种查询就有点没法忍受了。
java

   highlighter的优势也是由于其是实时分析高亮器,这种实时分析机制会让ES占用较少的io资源同时也占用较少的存储空间(词库较全的话相比fvh方式能节省一半的存储空间),其采用cpu资源来缓解io压力,在高亮字段较短(好比高亮文章的标题)时候速度较快,同时因io访问的次数少,io压力较小,有利于提升系统吞吐量。

fast-vector-highlighter :
      为解决 highlighter 高亮在大文本字段上的性能问题,lucene高亮模块提供了基于向量的高亮方式 fast-vector-highlighter。要采用fast-vector-highlighter(fvh)高亮方式,在数据建索引时候,须要配置存储词向量的词位置、词偏移量。fast-vector-highlighter在高亮时候的逻辑以下:
    1.分析高亮查询语法,提取表达式中的高亮词集合
    2.从磁盘上读取该文档字段下的词向量集合
    3.遍历词向量集合,提取自表达式中出现的词向量
    4.根据提取到目标词向量读取词频信息,根据词频获取每一个位置信息、偏移量
    5.经过类似度算法获取得分较高的前n组高亮信息
    6.读取字段内容(多字段用空格隔开),根据提取的词向量直接定位截取高亮字段(注意:lucene原生高亮存在bug,bug分别存在core 以及 highlighter工程中,以前我写过如何修改)
     因而可知,fast-vector-highlighter 省去了实时分析过程,可是多了磁盘读取,故fast-vector-highlighter 也有必定的优势以及缺点.

缺点:
算法

(1)fast-vector-highlighter  高亮方式须要存储词向量,而在词库丰富的系统中,存储词向量每每要比不存储词向量多占用一倍的空间。apache

(2)fast-vector-highlighter  高亮会比plain高亮多出至少一倍的io操做次数,读取的字节大小也多出至少一倍,大量的io请求会让搜索引擎并发能力下降。

优势:
(1)当实时分词速度小于磁盘读随机取速度的时候,从磁盘读取词向量的fast-vector-highlighter高亮有明显优点,例如: ansj分词器处理1百万字的文档耗时约两秒,而当前企业硬盘一分钟转速约为一万转,即一秒钟有160次的寻址能力,单次寻址并读取20k耗时约为7-10ms。分40次从磁盘总共读取2M内容耗时约为300毫秒,重复读取数据时候io上存在缓存,速度较快。与plain方式相比,fvh高亮在文档字段内容较大的状况下具备较大优点。

       默认plain高亮方式占用空间小,可是对大字段高亮慢,fvh对大字段高亮快,但占用空间过大,有没有一种高亮方式能够折中一些,即不要占用太大空间,对大字段分词也会太慢?固然有,lucene还提供了postings-highlighter(postings)高亮方式,postings-highlighter 高亮方式也是采用词量向量的方式进行高亮,与fvh高亮不一样的是postings高亮只存储了词向量的位置信息,并未存储词向量的偏移量,故中大字段存储中,其比fvh节省约20-30%的存储空间。在实际使用中,postings高亮的优势和缺点都不突出,故高亮时候对小字段采用highlighter高亮方式,大字段采用fast-vector-highlighter便可知足需求。
缓存

       目前,lucene提供的默认plain高亮方式占用空间小,可是对大文本操做速度又太慢,fvh速度快,但占用磁盘空间和io操做又太多,在生产环境下,系统的吞吐量以及存储量都没法达到一个满意的水平。为了达到空间占用与默认的高亮其相同,速度比fast-vector-highlighter 高亮速度快,根据lucene高亮器的实现结构,我本身写了个高亮器,名称为fast-highlighter

 fast-highlighter 由几部分组成:
 1.es环境调用插件 FastPlainHighlighter,用于环境变量处理。
 2.TreeAnalysis分析器,能以FieldQuery类分析出的词做为词典的高性能分析器。
 3.高亮段计算以及编解码处理类

 
 编写难点:
 1. 短语高亮(带引号高亮,短语会被分红多个词,高亮时候只有位置连续符合的才被高亮)

 2. 最优化返回(须要计算最符合或者高亮词数最多的钱n段)服务器

 3. 高亮时候容许不区分大小写匹配,不区分全角半角匹配高亮
并发


 代码测试。app

测试样本:elasticsearch

   (1)单条约10K的文本数据索引(索引总大小1.5g)。ide

   (2)检索文本中包含 “国美电器” 关键词文章并高亮返回40条


测试结果:

采用fast-vector-highlighter 高亮方式耗时336毫秒:

{
  • "took": 336,
  • "timed_out": false,
  • "_shards": {
    • "total": 1,
    • "successful": 1,
    • "failed": 0
    },
  • "hits": {
    • "total": 115,
    • "max_score": 0.19190195,
    • "hits": [
      • {
        • "_index": "test_v1",
        • "_type": "test",
        • "_id": "51000508",
        • "_score": 0.19190195,
        • "highlight": {
          • "text": [
            • "且主要品类零售额增速均高于上年同期水平。(2) 6 月 12 日,<b>国美</b><b>电器</b>宣布,其股东特别大会已经过公司改名议案。中文名称由“<b>国美</b><b>电器</b>控股有限公司”更改成“<b>国美</b>零售控股有限公司”。同日公司宣布正式推出全球首家专业 VR 影院,地点位于国美旗下大中<b>电器</b>北京马甸店。<b>国美</b> VR 影院将打破售票入场形式,采用“时间售卖”的方式,正式对外营业后一小时将收费"
            ]
          }
        }



采用fast--highlighter 高亮耗时132毫秒:

{

  • "took": 132,
  • "timed_out": false,
  • "_shards": {
    • "total": 1,
    • "successful": 1,
    • "failed": 0
    },
  • "hits": {
    • "total": 115,
    • "max_score": 0.19190195,
    • "hits": [
      • {
        • "_index": "test_v2",
        • "_type": "test",
        • "_id": "51000508",
        • "_score": 0.19190195,
        • "highlight": {
          • "text": [
            • "家用<b>电器</b>类零售额同比增加 1.6%,相比上年同期加快了 11.8 个百分点。(2) 6 月 12 日,<b>国美</b><b>电器</b>宣布,其股东特别大会已经过公司改名议案。中文名称由“<b>国美</b><b>电器</b>控股有限公司”更改成“<b>国美</b>零售控股有限公司”。同日,公司宣布正式推出全球首家专业 VR 影院,地点位于<b>国美</b>旗下大中<b>电器</b>北京马甸店。<b>国美</b>"
            ]
          }
        }

从结果能够看到,本身实现的高亮器能够比fast高亮器性能提升一倍以上,以下为fast-highlighter 的核心代码。

package org.elasticsearch.search.highlight;

import com.google.common.collect.Maps;
import org.apache.lucene.search.highlight.*;
import org.apache.lucene.search.vectorhighlight.BoundaryScanner;
import org.apache.lucene.search.vectorhighlight.CustomFieldQuery;
import org.apache.lucene.search.vectorhighlight.FieldQuery;
import org.apache.lucene.search.vectorhighlight.SimpleBoundaryScanner;
import org.apache.lucene.search.vectorhighlight.FieldQuery.Phrase;
import org.apache.lucene.util.BytesRefHash;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.common.text.Text;
import org.elasticsearch.index.mapper.FieldMapper;
import org.elasticsearch.search.fetch.FetchPhaseExecutionException;
import org.elasticsearch.search.fetch.FetchSubPhase;
import org.elasticsearch.search.internal.SearchContext;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * 
 * @author jkuang.nj
 *
 */
public class FastPlainHighlighter implements Highlighter
{
	private static final String CACHE_KEY = "highlight-fast";
	public static final char mark = 0;
	private static final SimpleBoundaryScanner DEFAULT_BOUNDARY_SCANNER = new SimpleBoundaryScanner();

	@Override
	public HighlightField highlight(HighlighterContext highlighterContext)
	{
		SearchContextHighlight.Field field = highlighterContext.field;
		SearchContext context = highlighterContext.context;
		FetchSubPhase.HitContext hitContext = highlighterContext.hitContext;
		FieldMapper mapper = highlighterContext.mapper;
		Encoder encoder = field.fieldOptions().encoder().equals("html") ? HighlightUtils.Encoders.HTML : HighlightUtils.Encoders.DEFAULT;

		if (!hitContext.cache().containsKey(CACHE_KEY))
		{
			hitContext.cache().put(CACHE_KEY, new HighlighterEntry());
		}

		HighlighterEntry cache = (HighlighterEntry) hitContext.cache().get(CACHE_KEY);
		try
		{
			FieldQuery fieldQuery;
			if (field.fieldOptions().requireFieldMatch())
			{
				if (cache.fieldMatchFieldQuery == null)
				{
					cache.fieldMatchFieldQuery = new CustomFieldQuery(highlighterContext.query, hitContext.topLevelReader(), true,
							field.fieldOptions().requireFieldMatch());
				}
				fieldQuery = cache.fieldMatchFieldQuery;
			}
			else
			{
				if (cache.noFieldMatchFieldQuery == null)
				{
					cache.noFieldMatchFieldQuery = new CustomFieldQuery(highlighterContext.query, hitContext.topLevelReader(), true,
							field.fieldOptions().requireFieldMatch());
				}
				fieldQuery = cache.noFieldMatchFieldQuery;

			}
			if (!cache.analysises.containsKey(field.field()))
			{
				cache.setPhrases(field.field(), fieldQuery.getPhrases(field.field()));
				cache.setWords(field.field(), fieldQuery.getTermSet(field.field()));
			}
			FastHighlighter entry = cache.mappers.get(mapper);
			if (entry == null)
			{

				BoundaryScanner boundaryScanner = DEFAULT_BOUNDARY_SCANNER;
				if (field.fieldOptions().boundaryMaxScan() != SimpleBoundaryScanner.DEFAULT_MAX_SCAN
						|| field.fieldOptions().boundaryChars() != SimpleBoundaryScanner.DEFAULT_BOUNDARY_CHARS)
				{
					boundaryScanner = new SimpleBoundaryScanner(field.fieldOptions().boundaryMaxScan(), field.fieldOptions().boundaryChars());
				}
				CustomFieldQuery.highlightFilters.set(field.fieldOptions().highlightFilter());
				entry = new FastHighlighter(encoder, boundaryScanner);
				cache.mappers.put(mapper, entry);
			}

			String[] fragments;
			int numberOfFragments = field.fieldOptions().numberOfFragments() == 0 ? 1 : field.fieldOptions().numberOfFragments();
			int fragmentCharSize = field.fieldOptions().numberOfFragments() == 0 ? 50 : field.fieldOptions().fragmentCharSize();
			List