深入搜索之结构化搜索

2019-06-28 11:55:13 浏览数 (1)

结构化搜索是指针对具有内在结构的数据进行检索的过程。比如日期、时间和数字都是结构化的,它们有精确的格式。文本也是可以 格式化的,比如彩色笔的颜色可以有red、green、blue等,文章也可以有关键词,网站商品也都有id等唯一标识。 结构化查询的结果总是非是即否,要么存在结果集中,要么不在。不关心文件的相关度或评分,只有文档的包括或排除处理。

1. 精确值查找

进行精确值查找时,使用filters会有比较快的执行速度,而且不会计算相关度,跳过了整个评分的阶段,而且容易被缓存。关于过滤器的缓存,参考:https://www.elastic.co/guide/cn/elasticsearch/guide/current/filter-caching.html

1. term查询数字

elasticsearch有查询表达式---query DSL,在用于查找精确值时,使用term也能达到相同的效果,term可用于数字(numbers)、布尔值(Booleans)、日期(dates)以及文本(text)。

代码语言:javascript复制
POST /my_index2/my_type/1
{"price":10}
代码语言:javascript复制
GET /my_index2/my_type/_search
{
  "query":{
    "term": {
      "price": {
        "value": 10
      }
    }
  }
}
结果为:
{
  "took": 7,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 1,
    "hits": [
      {
        "_index": "my_index2",
        "_type": "my_type",
        "_id": "1",
        "_score": 1,
        "_source": {
          "price": 10
        }
      }
    ]
  }
}

可以看到有评分产生。

若想要不进行评分计算,只希望对文档进行包括或排除的计算,所以我们会使用 constant_score 查询以非评分模式来执行 term 查询并以一作为统一评分。 最终组合的结果是一个 constant_score 查询,它包含一个 term 查询:

代码语言:javascript复制
GET /my_index2/my_type/_search
{
  "query":{
    "constant_score": {
      "filter": {
         "term": {
            "price": {
              "value": 10
            }
          }
      }
    }
   
  }
}
返回的结果为:
{
  "took": 5,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 1,
    "hits": [
      {
        "_index": "my_index2",
        "_type": "my_type",
        "_id": "1",
        "_score": 1,
        "_source": {
          "price": 10
        }
      }
    ]
  }
}

我们用 constant_score 将 term 查询转化成为过滤器,查询置于 filter 语句内不进行评分或相关度的计算,所以所有的结果都会返回一个默认评分 1 。

2. term查询文本

文本没有被设置成not_analyzed时会被分词,如果要让字段具有精确值,需要设置成not_analyzed。在修改索引mapping时,要先删除旧索引再新建一个正确映射的新索引。 具体查询方式与上面相同:

代码语言:javascript复制
GET /my_index3/my_type/_search
{
  "query":{
    "constant_score": {
      "filter": {
         "term": {
            "title": {
              "value": "比特币"
            }
          }
      }
    }
   
  }
}

3. 内部过滤器的操作

在内部,ES会进行非评分查询时执行多个操作:

  1. 查找匹配文档: term 查询在倒排索引中查找比特币然后获取包含该 term 的所有文档。
  2. 创建bitset: 过滤器会创建一个 bitset (一个包含 0 和 1 的数组),它描述了哪个文档会包含该 term 。匹配文档的标志位是 1 。本例中,bitset 的值为 [1,0,0,0] 。在内部,它表示成一个 "roaring bitmap"(https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps),可以同时对稀疏或密集的集合进行高效编码。
  3. 迭代bitset(s) 一旦为每个查询生成了bitsets,es就会去bitsets中寻找满足所有过滤条件的匹配文档的集合。执行顺序是启发式的,但一般来说先迭代稀疏的 bitset (因为它可以排除掉大量的文档)。
  4. 增量使用计数: Elasticsearch 能够缓存非评分查询从而获取更快的访问,但是它也会不太聪明地缓存一些使用极少的东西。非评分计算因为倒排索引已经足够快了,所以我们只想缓存那些我们 知道 在将来会被再次使用的查询,以避免资源的浪费。 为了实现以上设想,Elasticsearch 会为每个索引跟踪保留查询使用的历史状态。如果查询在最近的 256 次查询中会被用到,那么它就会被缓存到内存中。当 bitset 被缓存后,缓存会在那些低于 10,000 个文档(或少于 3% 的总索引数)的段(segment)中被忽略。这些小的段即将会消失,所以为它们分配缓存是一种浪费。 实际情况并非如此(执行有它的复杂性,这取决于查询计划是如何重新规划的,有些启发式的算法是基于查询代价的),理论上非评分查询 先于 评分查询执行。非评分查询任务旨在降低那些将对评分查询计算带来更高成本的文档数量,从而达到快速搜索的目的。

从概念上记住非评分计算是首先执行的,这将有助于写出高效又快速的搜索请求。

4. 组合过滤器

一般需要使用bool (布尔)过滤器。 这是个 复合过滤器(compound filter) ,它可以接受多个其他过滤器作为参数,并将这些过滤器结合成各式各样的布尔(逻辑)组合。

  • must 所有的语句都 必须(must) 匹配,与 AND 等价。
  • must_not 所有的语句都 不能(must not) 匹配,与 NOT 等价。
  • should 至少有一个语句要匹配,与 OR 等价。

就这么简单! 当我们需要多个过滤器时,只须将它们置入 bool 过滤器的不同部分进行嵌套即可。 就相当于用很多个if/else进行组合,能组合出一个很复杂的过程。可以将term过滤器、range过滤器等通过bool过滤器进行组合处理。

5. 查找多个精确值

term查询对单个值非常有用,如果要查找价格字段值为20或30的文档时,可以使用多个term查询,也可以使用terms查询。

代码语言:javascript复制
GET /my_index2/my_type/_search
{
  "query":{
    "constant_score": {
      "filter": {
         "terms": {
            "price": [20,30]
          }
      }
    }
   
  }
}

需要注意的是,term和terms是包含匹配,而不是等值判断,也就是说除了能匹配上的term,也允许其他term存在。

在索引数组数据时,如果需要根据数组数量匹配,可以多索引一个字段,用来保存数量。

代码语言:javascript复制
{ "tags" : ["search", "open_source"], "tag_count" : 2 }

搜索时也要传入数量:
GET /my_index/my_type/_search
{
    "query": {
        "constant_score" : {
            "filter" : {
                 "bool" : {
                    "must" : [
                        { "term" : { "tags" : "search" } }, 
                        { "term" : { "tag_count" : 1 } } 
                    ]
                }
            }
        }
    }
}

6. 范围

range 查询可同时提供包含(inclusive)和不包含(exclusive)这两种范围表达式,可供组合的选项如下:

  • gt: > 大于(greater than)
  • lt: < 小于(less than)
  • gte: >= 大于或等于(greater than or equal to)
  • lte: <= 小于或等于(less than or equal to)
1. 数字范围

查询20到40之间价格的:

代码语言:javascript复制
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "range" : {
                    "price" : {
                        "gte" : 20,
                        "lt"  : 40
                    }
                }
            }
        }
    }
}
2. 日期范围
代码语言:javascript复制
(1) 直接日期查询:
"range" : {
    "timestamp" : {
        "gt" : "2014-01-01 00:00:00",
        "lt" : "2014-01-07 00:00:00"
    }
}

(2)对日期计算(date math):
"range" : {
    "timestamp" : {
        "gt" : "now-1h"
    }
}
这个过滤器会一直查找时间戳在过去一个小时内的所有文档,让过滤器作为一个时间 滑动窗口(sliding window) 来过滤文档。

(3)针对具体时间计算:
"range" : {
    "timestamp" : {
        "gt" : "2014-01-01 00:00:00",
        "lt" : "2014-01-01 00:00:00|| 1M" 
    }
}
只要在某个日期后加上一个双管符号 (||) 并紧跟一个日期数学表达式。

更多关于日期的可以参考:https://www.elastic.co/guide/en/elasticsearch/reference/5.6/mapping-date-format.html

3. 字符串范围

字符串范围可采用 字典顺序(lexicographically) 或字母顺序(alphabetically)。在倒排索引中的词项就是采取字典顺序(lexicographically)排列的,这也是字符串范围可以使用这个顺序来确定的原因。

执行效率:

数字和日期字段的索引方式使高效地范围计算成为可能。 但字符串却并非如此,要想对其使用范围过滤,Elasticsearch 实际上是在为范围内的每个词项都执行 term 过滤器,这会比日期或数字的范围过滤慢许多。 字符串范围在过滤 低基数(low cardinality) 字段(即只有少量唯一词项)时可以正常工作,但是唯一词项越多,字符串范围的计算会越慢。

7. 处理Null值

null, [] (空数组)和 [null] 所有这些都是无法存于倒排索引中。针对这些字段,在ES中是什么都不存的。 在查询时,需要进行处理。

  1. 存在查询: 用exists关键字查询
  2. 缺失查询: 用missing查询

对于空值,感觉需要在业务上进行处理,尽量避免添加空值null或字符串null的情况。

exists与missing可以处理普通字段和一个对象的内部字段。

代码语言:javascript复制
{
   "name" : {
      "first" : "John",
      "last" :  "Smith"
   }
}

可以对name.first 与 name.last进行非空校验

8. 关于缓存

其核心实际是采用一个 bitset 记录与过滤器匹配的文档。Elasticsearch 积极地把这些 bitset 缓存起来以备随后使用。一旦缓存成功,bitset 可以复用任何已使用过的相同过滤器,而无需再次计算整个过滤器。 这些 bitsets 缓存是“智能”的:它们以增量方式更新。当我们索引新文档时,只需将那些新文档加入已有 bitset,而不是对整个缓存一遍又一遍的重复计算。和系统其他部分一样,过滤器是实时的,我们无需担心缓存过期问题。

Elasticsearch 会基于使用频次自动缓存查询。如果一个非评分查询在最近的 256 次查询中被使用过(次数取决于查询类型),那么这个查询就会作为缓存的候选。但是,并不是所有的片段都能保证缓存 bitset 。只有那些文档数量超过 10,000 (或超过总文档数量的 3% )才会缓存 bitset 。因为小的片段可以很快的进行搜索和合并,这里缓存的意义不大。

一旦缓存了,非评分计算的 bitset 会一直驻留在缓存中直到它被剔除。剔除规则是基于 LRU 的:一旦缓存满了,最近最少使用的过滤器会被剔除。

Roaring Bitmaps介绍

参考:https://www.elastic.co/cn/blog/frame-of-reference-and-roaring-bitmaps

详细代码如下,关于代码解析部分,请关注后面的文章:

代码语言:javascript复制
/**
 * This is a cache for {@link BitDocIdSet} based filters and is unbounded by size or time.
 * <p>
 * Use this cache with care, only components that require that a filter is to be materialized as a {@link BitDocIdSet}
 * and require that it should always be around should use this cache, otherwise the
 * {@link org.elasticsearch.index.cache.query.QueryCache} should be used instead.
 */
public final class BitsetFilterCache extends AbstractIndexComponent implements IndexReader.ClosedListener, RemovalListener<IndexReader.CacheKey, Cache<Query, BitsetFilterCache.Value>>, Closeable {

    public static final Setting<Boolean> INDEX_LOAD_RANDOM_ACCESS_FILTERS_EAGERLY_SETTING =
        Setting.boolSetting("index.load_fixed_bitset_filters_eagerly", true, Property.IndexScope);

    private final boolean loadRandomAccessFiltersEagerly;
    private final Cache<IndexReader.CacheKey, Cache<Query, Value>> loadedFilters;
    private final Listener listener;

    public BitsetFilterCache(IndexSettings indexSettings, Listener listener) {
        super(indexSettings);
        if (listener == null) {
            throw new IllegalArgumentException("listener must not be null");
        }
        this.loadRandomAccessFiltersEagerly = this.indexSettings.getValue(INDEX_LOAD_RANDOM_ACCESS_FILTERS_EAGERLY_SETTING);
        this.loadedFilters = CacheBuilder.<IndexReader.CacheKey, Cache<Query, Value>>builder().removalListener(this).build();
        this.listener = listener;
    }

2. 全文搜索

在title属性上搜索"比特币"进行全文搜索:

代码语言:javascript复制
GET /my_index3/my_type/_search
{
  "query":{
  		"match":{
  			"title":"比特币"
  		}
  }
}

结果默认按照相关性得分(也就是每个文档跟查询的匹配程度)排序。这点和传统关系型数据库完全不同,数据库中的记录要么匹配要么不匹配。

3. 短语搜索

看如下查询:

代码语言:javascript复制
GET /my_index3/my_type/_search
{
  "query":{
  		"match":{
  			"title":"比特币 以太坊"
  		}
  }
}

GET /my_index3/my_type/_search
{
  "query":{
  		"match_phrase":{
  			"title":"比特币 以太坊"
  		}
  }
}

上面必须完全匹配短语"比特币 以太坊","比特币"和"以太坊"必须同时匹配,而且二者以短语的形式紧挨着。

4. 高亮搜索

想要某些片段高亮显示时,在执行查询时需要增加一个新的highlight参数:

代码语言:javascript复制
GET /my_index3/my_type/_search
{
  "query":{
  		"match":{
  			"title":"比特币 以太坊"
  		}
  },
  "highlight":{
  		"fields":{
  			"title":{}
  		}
  }
}

返回结果为:
{
   ...
   "hits": {
      "total":      1,
      "max_score":  0.23013961,
      "hits": [
         {
            ...
            "_score":         0.23013961,
            "_source": {
               "title":       "比特币 以太坊"
            },
            "highlight": {
               "title": [
                  "<em>比特币</em> <em>以太坊</em>" 
               ]
            }
         }
      ]
   }
}

0 人点赞