Loading

[15] Es 数据分析过程

1. 分析数据

1.1 What's analysis?

  • 倒排索引:索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为“倒排索引”。
  • 分析:文档在建立倒排索引之前,Es 让每个被分析字段所做的一系列操作
    • 字符过滤:使用字符过滤器转变字符,如大写转小写、& 变 and 等;
    • 分词:将文本切分为单个或者多个词;
    • 分词过滤:使用分词过滤器,转变每个分析;
    • 分词索引:把这些分词和指向文档的关系放进索引;

举例:

1.2 Anatomyof an Analyzer

a. 3 parts

An analyzer  — whether built-in or custom — is just a package which contains three lower-level building blocks: character filters, tokenizers, and token filters.

The built-in analyzers pre-package these building blocks into analyzers suitable for different languages and types of text. Elasticsearch also exposes the individual building blocks so that they can be combined to define new custom analyzers.

(1)Character Filters

A character filter receives the original text as a stream of characters and can transform the stream by adding, removing, or changing characters. For instance, a character filter could be used to convert Hindu-Arabic numerals (٠‎١٢٣٤٥٦٧٨‎٩‎) into their Arabic-Latin equivalents (0123456789), or to strip HTML elements like <b> from the stream.

An analyzer may have zero or more character filters, which are applied in order.

(2)Tokenizer

A tokenizer receives a stream of characters, breaks it up into individual tokens (usually individual words), and outputs a stream of tokens. For instance, a whitespace tokenizer breaks text into tokens whenever it sees any whitespace. It would convert the text "Quick brown fox!" into the terms [Quick, brown, fox!].

The tokenizer is also responsible for recording the order or position of each term and the start and end character offsets of the original word which the term represents.

An analyzer must have exactly one tokenizer.

(3)Token Filters

A token filter receives the token stream and may add, remove, or change tokens. For example, a lowercase token filter converts all tokens to lowercase, a stop token filter removes common words (stop words) like the from the token stream, and a synonym token filter introduces synonyms into the token stream.

Token filters are not allowed to change the position or character offsets of each token.

An analyzer may have zero or more token filters, which are applied in order.

b. Built-in analyzers

Elasticsearch ships with a wide range of built-in analyzers, which can be used in any index without further configuration:

(1)Standard Analyzer

The standard analyzer divides text into terms on word boundaries, as defined by the Unicode Text Segmentation algorithm. It removes most punctuation(删除大多数标点符号), lowercases terms(字母大写转小写), and supports removing stop words(删除停用词).

(2)Simple Analyzer

The simple analyzer divides text into terms whenever it encounters a character which is not a letter(遇到非字母就分词). It lowercases all terms(所有字母转小写).

(3)Whitespace Analyzer

The whitespace analyzer divides text into terms whenever it encounters any whitespace character(按空格分词). It does not lowercase terms(不转字母大小写).

(4)Stop Analyzer

The stop analyzer is like the simple analyzer(和 simple 类似), but also supports removal of stop words(过滤掉停用词).

(5)Keyword Analyzer

The keyword analyzer is a “noop” analyzer that accepts whatever text it is given and outputs the exact same text as a single term(把字段当作关键词;最好别用,直接用 keyword 类型存储就不分析字段了).

(6)Pattern Analyzer

The pattern analyzer uses a regular expression to split the text into terms(可以匹配正则表达式作为分词条件). It supports lower-casing and stop words(支持转小写和删除停用词).

(7)Language Analyzers

Elasticsearch provides many language-specific analyzers like english or french(多语言分词器,用于特定语言的字符串;34 种,但不包括中文).

(8)Fingerprint Analyzer

The fingerprint analyzer is a specialist analyzer which creates a fingerprint(生成指纹) which can be used for duplicate detection(重复检测,查重).


If you do not find an analyzer suitable for your needs, you can create a custom analyzer which combines the appropriate character filters, tokenizer, and token filters.

1.3 Char Filter

Character filters are used to preprocess the stream of characters before it is passed to the tokenizer.

(1)HTML Strip Character Filter

The html_strip character filter strips out HTML elements like <b>(剔除 HTML 标签) and decodes HTML entities like &amp;(并对 HTML 编码的符号解码).

(2)Mapping Character Filter

The mapping character filter replaces any occurrences of the specified strings(指定字符串的出现) with the specified replacements(指定的替换).

(3)Pattern Replace Character Filter

The pattern_replace character filter replaces any characters matching a regular expression with the specified replacement(正则替换).

1.4 Tokenizer

The tokenizer is also responsible for recording the following:

  • Order or position of each term (used for phrase and word proximity queries)
  • Start and end character offsets of the original word which the term represents (used for highlighting search snippets).
  • Token type, a classification of each term produced, such as <ALPHANUM>, <HANGUL>, or <NUM>. Simpler analyzers only produce the word token type.

Elasticsearch has a number of built in tokenizers which can be used to build custom analyzers.

a. Word Oriented Tokenizers

The following tokenizers are usually used for tokenizing full text into individual words:

(1)Standard Tokenizer

The standard tokenizer divides text into terms on word boundaries(单词边界), as defined by the Unicode Text Segmentation algorithm. It removes most punctuation symbols(标点符号). It is the best choice for most languages.

(2)Letter Tokenizer

The letter tokenizer divides text into terms whenever it encounters a character which is not a letter(只要遇到不是字母就分词).

(3)Lowercase Tokenizer

The lowercase tokenizer, like the letter tokenizer(相当于字母分词器), divides text into terms whenever it encounters a character which is not a letter, but it also lowercases all terms(但是还会把词转小写).

(4)Whitespace Tokenizer

The whitespace tokenizer divides text into terms whenever it encounters any whitespace character(遇到空格、制表符、换行等空白符合就分词,注意不会去掉标点).

(5)UAX URL Email Tokenizer

The uax_url_email tokenizer is like the standard tokenizer except that it recognises URLs and email addresses as single tokens(标准分词器的基础上,会把 URL 和邮箱地址识别成一个词).

(6)Classic Tokenizer

The classic tokenizer is a grammar based tokenizer for the English Language.

(7)Thai Tokenizer

The thai tokenizer segments Thai text into words.

b. Partial Word Tokenizers

These tokenizers break up text or words into small fragments, for partial word matching:

(1)N-Gram Tokenizer(N 元语法)

The Ngram tokenizer can break up text into words when it encounters any of a list of specified characters (e.g. whitespace or punctuation), then it returns n-grams of each word: a sliding window of continuous letters(连续字母的滑动窗口), e.g. quick → [qu, ui, ic, ck].

先按空格或标点切割成单词,再把单词切成 N 个字符的片段。

(2)Edge N-Gram Tokenizer(侧边 N 元语法)

The edge_ngram tokenizer can break up text into words when it encounters any of a list of specified characters (e.g. whitespace or punctuation), then it returns n-grams of each word which are anchored to the start of the word(锚定在单词的开头), e.g. quick → [q, qu, qui, quic, quick].

从一侧开始切词,做类似前缀匹配的搜索。

c. Structured Text Tokenizers

The following tokenizers are usually used with structured text(结构化文本) like identifiers, email addresses, zip codes, and paths, rather than with full text:

(1)Keyword Tokenizer

The keyword tokenizer is a “noop” tokenizer that accepts whatever text it is given and outputs the exact same text as a single term(啥都没干). It can be combined with token filters like lowercase to normalise the analysed terms.

(2)Pattern Tokenizer

The pattern tokenizer uses a regular expression to either split text into terms whenever it matches a word separator, or to capture matching text as terms.

The default pattern is \W+, which splits text whenever it encounters non-word characters.

POST _analyze
{
  "tokenizer": "pattern",
  "text": "The foo_bar_size's default is 5."
}
-----------------------------------------------
[ The, foo_bar_size, s, default, is, 5 ]

(3)Simple Pattern Tokenizer

The simple_pattern tokenizer uses a regular expression to capture matching text as terms. It uses a restricted subset of regular expression features and is generally faster than the pattern tokenizer(简化的模式分析器,速度稍快).

This tokenizer does not support splitting the input on a pattern match, unlike the pattern tokenizer. To split on pattern matches using the same restricted regular expression subset, see the simple_pattern_split tokenizer.

PUT my-index-000001
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "my_tokenizer"
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "simple_pattern_split",
          "pattern": "_"
        }
      }
    }
  }
}

POST my-index-000001/_analyze
{
  "analyzer": "my_analyzer",
  "text": "an_underscored_phrase"
}

-----------------------------------------------

[ an, underscored, phrase ]

(4)Char Group Tokenizer

The char_group tokenizer breaks text into terms whenever it encounters a character which is in a defined set(遇到定义集中的字符时将文本分解为词). It is mostly useful for cases where a simple custom tokenization is desired, and the overhead of use of the pattern tokenizer is not acceptable(使用模式标记器的开销不可接受的情况).

POST _analyze
{
  "tokenizer": {
    "type": "char_group",
    "tokenize_on_chars": [
      "whitespace",
      "-",
      "\n"
    ]
  },
  "text": "The QUICK brown-fox"
}

-----------------------------------------------

{
  "tokens": [
    {
      "token": "The",
      "start_offset": 0,
      "end_offset": 3,
      "type": "word",
      "position": 0
    },
    {
      "token": "QUICK",
      "start_offset": 4,
      "end_offset": 9,
      "type": "word",
      "position": 1
    },
    {
      "token": "brown",
      "start_offset": 10,
      "end_offset": 15,
      "type": "word",
      "position": 2
    },
    {
      "token": "fox",
      "start_offset": 16,
      "end_offset": 19,
      "type": "word",
      "position": 3
    }
  ]
}

(5)Simple Pattern Split Tokenizer

The simple_pattern_split tokenizer uses a regular expression to split the input into terms at pattern matches. The set of regular expression features it supports is more limited than the pattern tokenizer, but the tokenization is generally faster.

This tokenizer does not produce terms from the matches themselves. To produce terms from matches using patterns in the same restricted regular expression subset, see the simple_pattern tokenizer(类似简化模式,但不同之处在于匹配中的短语是作为分隔符,而不是分词).

This tokenizer uses Lucene regular expressions. For an explanation of the supported features and syntax, see Regular Expression Syntax.

The default pattern is the empty string, which produces one term containing the full input. This tokenizer should always be configured with a non-default pattern.

(6)Path Tokenizer

The path_hierarchy tokenizer takes a hierarchical value like a filesystem path, splits on the path separator, and emits a term for each component in the tree, e.g. /foo/bar/baz → [/foo, /foo/bar, /foo/bar/baz ].

1.5 Token Filter

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-tokenfilters.html

Apostroph( [əˈpɒstrəfi] 撇号)
ASCII folding
CJK bigram
CJK width
Classic
Common grams
Conditional
Decimal digit
Delimited payload
Dictionary decompounder
Edge n-gram
Elision
Fingerprint
Flatten graph
Hunspell
Hyphenation decompounder
Keep types
Keep words
Keyword marker
Keyword repeat
KStem
Length
Limit token count
Lowercase
MinHash
Multiplexer
N-gram
Normalization
Pattern capture
Pattern replace
Phonetic
Porter stem
Predicate script
Remove duplicates
Reverse
Shingle
Snowball
Stemmer
Stemmer override
Stop
Synonym
Synonym graph
Trim
Truncate
Unique
Uppercase
Word delimiter
Word delimiter graph

1.6 Others

a. 配置分析器

  • 创建索引的时候配置分析器;
  • 使用 template 配置分析器;
  • 在 Es 的配置里设置全局默认分析器;
  • 全文检索类的搜索语句可以指定分析器,优先级如下:
    • query 参数里指定的;
    • 被搜字段的 search_analyzer 指定的;
    • 被搜字段的 analyzer 指定的;
    • index 配置里 default_search 指定的;
    • index 配置里 default 指定的;
    • Standard Analyzer

b. 使用分析 API

  • _analyze
    • 对指定字符串使用指定分析器进行分析,直接展示分析结果;
    • 可以指定各种预定义分析器、自定义分析器;
    • 甚至可以分别指定字符过滤器、分词器、分词过滤器;
  • _termvectors
    • 查看某个具体的文档的具体索引信息;
    • 这个文档有哪些分析,以及每个分词的词频、位置、开始和结束位置等;

2. 相关性计算/评分机制

2.1 TF/IDF

Relevance Score 算法,简单来说就是计算出一个索引中的文本与搜索文本,他们之间的关联匹配程度。

Es 使用的是 Term Frequency(词频)/Inverse Document Frequency(逆向文件频率) 算法,简称为 TF/IDF 算法。

  • Term Frequency:搜索文本中的各个词条在 field 文本中出现了多少次,出现次数越多,就越相关;
  • Inverse Document Frequency:搜索文本中的各个词条在整个索引的所有文档中出现了多少次,出现的次数越多就越不相关;
  • Field-length Norm:包含搜索内容分词的 field 越长,相关度越弱(你在 title 中命中和在 body 中命中)。

2.2 分析相关 API

  1. _score 是如何被计算出来的
    GET /book/_search?explain=true
    {
      "query": {
        "match": {
          "description": "Java程序员"
        }
      }
    }
    
  2. 分析一个 document 是如何被匹配上的
    GET /book/_explain/1101
    {
      "query": {
        "match": {
          "description": "Java程序员"
        }
      }
    }
    

3. 聚集

3.1 什么是聚集?

大概可以理解为分类统计,比如对一组数据的某个词条进行计数、或者计算某个数值型字段的平均值。

在 Kibana 上随处可见,各种 visualize 都是基于此。

分为“度量聚集”和“桶聚集”。

对比搜索最大的不同:(1)不能使用倒排索引,需要用到字段数据(未被分析的字段的数据);(2)聚集时会将倒排索引反转回字段数据,塞进内存,因此如果聚集操作频繁,就需要大量内存。

后过滤器:

  • 正常情况下过滤查询是先执行的,聚集在此基础上运行;
  • 有时候需要先对所有数据进行聚集,再过滤查询出一些数据展示;
  • 后过滤器是在聚集之后运行,和聚集操作相对独立,需要注意性能。

3.2 度量聚集

// TODO

【近似计算】普通的聚集操作都要全部遍历查询范围内的所有文档,如果数据量巨大,需要很昂贵的代价,尤其是内存。很多时候并不需要精确地统计,可以牺牲部分精确性,来节省消耗的资源。

3.3 桶聚集

// TODO

4. 提升性能

https://www.elastic.co/guide/en/elasticsearch/reference/6.8/tune-for-search-speed.html

a. 提升写入性能

(1)用 bulk 接口批量写入

  • 可以节省重复创建连接的网络开销;
  • 要通过测试才能知道最佳的一次批处理量,并不是越大越好,太大了会占用内存;
  • bulk 有个处理队列,过慢的 index 会导致队列满而丢弃后面的请求;

(2)配置慢一点的刷新频率

  • Es 是准实时系统,新写入的分段需要被刷新才被完全创建,才可用于查询;
  • 慢的刷新频率可以降低分段合并频率,分段合并十分耗资源;
  • 默认刷新频率是 1s,对 index 修改 index.refresh.inverval 即可立即生效;

(3)初始化性值的大量写入

  • 比如 reindex 或者是导入基础数据这种一次性批量索引操作;
  • 可以配置成不刷新,并且把副本数也配置成 0,完了之后再设置成正常值;
  • 每一次写入都要等所有副本都报告写入完成才算完,副本数量越多写入越慢;

(4)关闭 OS 的 swapping

  • OS 会自动把不常用的内存交换到磁盘(虚拟内存);
  • Es 是运行于 JVM 的,这个操作可能会导致 GC;

(5)使用内部 id

  • 默认是指明文档 id 的,但这样的话 Es 需要先判断一下这个 id 的文档是否已经存在以做一些合并或者更新操作;
  • 如果用自生成的 id,则可以跳过这个步骤节省开支;

(6)合理设置分片和副本数量

  • 分片数量影响到分段数量,分片少的话允许的分段量也会少(小分片会导致小分段),从而会增加分段合并的频率,消耗性能;
  • 如果写入规模巨大,要控制 index 的规模(按月、按周、按天适当分,或自动滚动),同时根据集群节点数量设置合适的分片数,使得每个分片的数据量有限;
  • 副本数量越多,写入越慢;

(7)合理设置字段 mapping

  • 不需要分析的字段就不要分析

b. 提升查询性能

(1)使用过滤上下文

  • 不计算得分可以减少资源消耗
  • 过滤器还可以缓存

(2)避免脚本

  • 脚本非常好性能,因为每次计算且无法缓存;
  • 如果非用不可,用 painless 或 expression;

(3)提前索引字段

  • 比如某个字段经常被 range 查询或聚集,那在索引字段的时候,就把 range 范围确定好,比如 15 属于 10~100

(4)合理 mapping

  • 使用适当的分析器,如果对查询速度要求很高,就要在索引的时候牺牲性能;
  • 数字是存在另外的地方,所以有时候数字可以存成 keyword 而不是 numeric 会更快;

(5)有意识地使用更轻量的查询

  • 比如 term 查询比 query 查询更省资源,query 会被分析,衍生出很多子查询;
  • 通配符查询很费性能,尤其是通配符放在很前面;

(6)不要使用任何的关联查询

  • 不管是嵌套还是父子,都会使查询量倍增;
  • 通过冗余数据,以空间换时间,存储地成本很低。

(7)增加副本数量

  • 可以均衡查询负载

(8)分配感知

  • 如果 Es 按时间分索引,你又恰好知道它在哪个时间段,精确地查询到这个索引而不是查一大片,显然会更快;
  • 索引的时候有一个 _route 参数,可以控制某个文档索引到哪个分片,如果你的一个查询的所有结果都从一个分片获取,就能减少数据合并的开销,如果分片在不同的机器,还能节省网络开销;
  • 节点的配置有一个 allocation awareness,可以根据 rack、group、zone 来配置节点,使得分片均匀分布,从而降低单点热度,同一个分片的副本不在一起,还可以容灾。

(9)按时间查询的时候对时间取整

  • 可能更容易命中缓存。

(10)如果 index 不再写入可以合并分段

  • 分段越少,查询越快,因为每次查询都要拆到所有分段取处理,再合并结果;
  • 有一个 _forcemerge 接口,可以把分段弄成 1。同理,甚至可以合并分片(reindex 或 shink)。

(11)给文件系统预留足够内存

  • 机器内存最多分一半给 Es,剩下留给文件系统;
  • 因为 Es 非常依赖 OS 的文件缓存,尤其是查询操作。

(12)用 SSD 磁盘而且别用远程

  • Es 需要频繁读取磁盘。

c. 节省磁盘空间

(1)关闭不需要的 mapping 特性

  • 不被用来查询的字段,不索引;
  • 不做全文检索,不分词(keyword);
  • 不关注文档相关性,关闭 norms;
  • 不需要短语检索,关闭位置索引;

(2)不要使用自动 mapping

  • 默认会对 string 字段做两次索引(text 和 keyword)

(3)留意分片大小

  • 分片越大,存储效率越高;
  • 滚动存储,使其大小可控;
  • 使用收缩 API 收缩分片;

(4)关闭不用的字段

  • _all
  • _source

(5)配置压缩存储

(6)分段合并

(7)数字类型的字段用最小类型

  • byte < short < integer < long

5. 零停机索引重建

5.1 外部数据导入

a. 整体介绍

系统架构设计中,有关系型数据库用来存储数据,Es 在系统架构里起到查询加速的作用,如果遇到索引重建的操作,待系统模块发布新版本后,可以从数据库将数据查询出来,重新灌到 Es 即可。

b. 执行步骤

建议的功能方案:数据库 + MQ + 应用模块 + Elasticsearch,可以在 MQ 控制台发送 MQ 消息来触发重导数据,按批次对数据进行导入,整个过程异步化处理,请求操作示意如下所示:

  1. 发送指定的 MQ 消息;
  2. MQ 消息被微服务模块的消费者消费,触发 Es 数据重新导入功能;
  3. 微服务模块从数据库里查询数据的总数及批次信息,并将每个数据批次的分页信息重新发送给 MQ 消息,分页信息包含查询条件和偏移量,此 MQ 消息还是会被微服务的 MQ 消息者接收处理;
  4. 微服务根据接收的查询条件和分页信息,从数据库获取到数据后,根据索引结构的定义,将数据组装成 Es 支持的 JSON 格式,并执行 bulk 命令,将数据发送给 Es 集群。

这样就可以完成索引的重建工作。

c. 方案特点

MQ 中间件的选型不做具体要求,常见的 RabitMQ、ActiveMQ、RocketMQ 等均可。

在微服务模块方面,提供 MQ 消息处理接口、数据处理模块需要事先开发的,一般是创建新的索引时,配套把重建的功能也一起做好。整体功能共用一个 topic,针对每个索引,有单独的结构定义和 MQ 消息处理 tag,代码尽可能复用。处理的批次大小需要根据实际的情况设置。

微服务模块实例会部署多个,数据是分批处理的,批次信息会一次性全部先发送给 MQ,各个实例处理的数据相互不重叠,利用 MQ 消息的异步处理机制,可以充分利用并发的优势,加快数据重建的速度。

缺点:

  1. 对数据库造成读取压力,短时间内大量的读操作,会占用数据库的硬件资源,严重时可能引起数据库性能下降;
  2. 网络带宽占用多,数据毕竟是从一个库传到另一个库,虽说是内网,但大量的数据传输带宽占用也需要注意;
  3. 数据重建时间稍长,跟迁移的数据量大小有关。

5.2 scroll+bulk+索引别名

a. 整体介绍

利用 Es 自带的一些工具完成索引的重建工作,当然在方案实际落地时,可能也会依赖客户端的一些功能,比如用 Java 客户端持续的做 scroll 查询、bulk 命令的封装等。数据完全自给自足,不依赖其他数据源。

b. 执行步骤

假设原索引名称是 book,新的索引名称为 book_new,Java 客户端使用别名 book_alias 连接 Es,该别名指向原索引 book。

  1. 若 Java 客户端没有使用别名,需要给客户端分配一个:PUT /book/_alias/book_alias;
  2. 新建索引 book_new,将 mapping 信息,settings 信息等按新的要求全部定义好;
  3. 使用 scroll API 将数据批量查询出来。为了使用 scroll,初始搜索请求应该在查询中指定 scroll 参数,这可以告诉 Es 需要保持搜索的上下文环境多久,1m 就是 1 分钟。
    GET /book/_search?scroll=1m
    {
        "query": {
            "match_all": {}
        },
        "sort": ["_doc"],
        "size": 2
    }
    
  4. 采用 bulk api 将 scroll 查出来的一批数据,批量写入新索引;
    POST /_bulk
    {
        "index": {
            "_index": "book_new",
            "_id": "对应的 id 值"
        }
        { 查询出来的数据值 }
    }
    
  5. 反复执行修改后的 step3 和 step4,查询一批导入一批,以后可以借助 Java Client 或其他语言的 API 支持(注意做 step3 时需要指定上一次查询的 scroll_id);
    GET /_search/scroll
    {
        "scroll": "1m",
        "scroll_id" : "step3 中查询出来的值"
    }
    
  6. 切换别名 book_alias 到新的索引 book_new 上面,此时 Java 客户端仍然使用别名访问,也不需要修改任何代码,不需要停机。
    POST /_aliases
    {
        "actions": [
            { "remove": { "index": "book", "alias": "book_alias" }},
            { "add": { "index": "book_new", "alias": "book_alias" }}
        ]
    }
    

c. 方案特点

在数据传输上基本自给自足,不依赖于其他数据源,Java 客户端不需要停机等待数据迁移,网络传输占用带宽较小。只是 scroll 查询和 bulk 提交这部分,数据量大时需要依赖一些客户端工具。

补充一点,在 Java 客户端或其他客户端访问 Es 集群时,使用别名是一个好习惯。

5.3 API:reindex

Elasticsearch v6.3.1 已经支持 Reindex API,它对 scroll、bulk 做了一层封装,能够对文档重建索引而不需要任何插件或外部工具。

a. 基础命令

POST _reindex
{
    "source": {
        "index": "book"
    },
    "dest": {
        "index": "book_new"
    }
}

如果不手动创建新索引 book_new 的 mapping 信息,那么 Es 将启动自动映射模板对数据进行类型映射,可能不是期望的类型,这点要注意一下!

b. version_type

使用 reindex API 也是创建快照后再执行迁移的,这样目标索引的数据可能会与原索引有差异,version_type 属性可以决定乐观锁并发处理的规则。

reindex api 可以设置 version_type 属性,如下:

POST _reindex
{
    "source": {
        "index": "book"
    },
    "dest": {
        "index": "book_new",
        "version_type": "internal"
    }
}

version_type 属性含义如下:

  • internal:默认;直接拷贝文档到目标索引,对相同的 type、文档 ID 直接进行覆盖;
  • external:迁移文档到目标索引时,保留 version 信息,对目标索引中不存在的文档进行创建,已存在的文档按 version 进行更新,遵循乐观锁机制。

c. op_type / conflicts

如果 op_type 设置为 create,那么迁移时只在目标索引中创建 ID 不存在的文档,已存在的文档,会提示错误,如下请求:

POST _reindex
{
    "source": {
        "index": "book"
    },
    "dest": {
        "index": "book_new",
        "op_type": "create"
    }
}

如果加上 "conflicts": "proceed" 配置项,那么冲突信息将不展示,只展示冲突的文档数量,请求和响应结果将变成这样:

POST _reindex
{
    "conflicts": "proceed",
    "source": {
        "index": "book"
    },
    "dest": {
        "index": "book_new",
        "op_type": "create"
    }
}

d. query

reindex API 支持数据过滤、数据排序、size 设置、_source 选择等,也支持脚本执行,这里提供一个简单示例:

POST _reindex
{
    "size": 100,
    "source": {
        "index": "book",
        "query": {
            "term": {
                "language": "english"
            }
        },
        "sort": {
            "likes": "desc"
        }
    },
    "dest": {
        "index": "book_new"
    }
}

5.4 小结

零停机索引重建操作的 3 个方案,从自研功能、scroll+bulk 到 reindex,我们作为 Es 的使用者,三个方案的参与度是逐渐弱化的,但稳定性却是逐渐上升的,我们需要清楚地去了解各个方案的优劣,适宜的场景,然后根据实际的情况去权衡,哪个方案更适合我们的业务模型。

6. Suggester 智能搜索建议

6.1 引入

现代的搜索引擎,一般会具备“Suggest As You Type”功能,即在用户输入搜索的过程中,进行自动补全或者纠错。 通过协助用户输入更精准的关键词,提高后续全文搜索阶段文档匹配的程度。例如在京东上输入部分关键词,甚至输入拼写错误的关键词时,它依然能够提示出用户想要输入的内容:

如果自己亲手去试一下,可以看到京东在用户刚开始输入的时候是自动补全的,而当输入到一定长度,因为单词拼写错误无法补全,就开始尝试提示相似的词。

那么类似的功能在 Es 里如何实现呢? 答案就在 Suggesters API。 Suggesters 基本的运作原理是将输入的文本分解为 token,然后在索引的字典里查找相似的 term 并返回。 根据使用场景的不同,Es 里设计了 4 种类别的 Suggester,分别是

  • Term Suggester
  • Phrase Suggester
  • Completion Suggester
  • Context Suggester

6.2 用法

在官方的参考文档里,对这 4 种 Suggester API 都有比较详细的介绍,下面的案例将在 Elasticsearch 7.x 上通过示例讲解 Suggester 的基础用法,希望能帮助部分国内开发者快速用于实际项目开发。

准备一个叫做 blogs 的索引,配置一个 text 字段:

PUT /blogs/
{
    "mappings": {
        "properties": {
            "body": {
                "type": "text"
            }
        }
    }
}

通过 bulk api 写入几条文档:

POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs" } }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs" } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs" } }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs" } }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs" } }
{ "body": "elk rocks"}
{ "index" : { "_index" : "blogs"} }
{ "body": "elasticsearch is rock solid"}

此时 blogs 索引里已经有一些文档了,可以进行下一步的探索。为帮助理解,我们先看看哪些 term 会存在于词典里。

将输入的文本分析一下:

POST _analyze
{
    "text": [
        "Lucene is cool",
        "Elasticsearch builds on top of lucene",
        "Elasticsearch rocks",
        "Elastic is the company behind ELK stack",
        "elk rocks",
        "elasticsearch is rock solid"
    ]
}

这些分出来的 token 都会成为词典里一个 term,注意有些 token 会出现多次,因此在倒排索引里记录的词频会比较高,同时记录的还有这些 token 在原文档里的偏移量和相对位置信息。

执行一次 Suggester 搜索看看效果:

POST /blogs/_search
{
    "suggest": {
        "my-suggestion": {
            "text": "lucne rock",
            "term": {
                "suggest_mode": "missing",
                "field": "body"
            }
        }
    }
}

a. Term Suggester

suggest 就是一种特殊类型的搜索,DSL 内部的 "text" 指的是 api 调用方提供的文本,也就是通常用户界面上用户输入的内容。这里的“lucne”是错误的拼写,模拟用户输入错误。"term" 表示这是一个 Term Suggester。"field" 指定 Suggester 针对的字段,另外有一个可选的 "suggest_mode"。 范例里的“missing”实际上就是缺省值,它是什么意思?先看看返回结果吧:

{
    "took": 1,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "failed": 0
    },
    "hits": {
        "total": 0,
        "max_score": 0,
        "hits":
    },
    "suggest": {
        "my-suggestion": [
            {
                "text": "lucne",
                "offset": 0,
                "length": 5,
                "options": [
                    {
                        "text": "lucene",
                        "score": 0.8,
                        "freq": 2
                    }
                ]
            },
            {
                "text": "rock",
                "offset": 6,
                "length": 4,
                "options": []
            }
        ]
    }
}

在返回结果里 "suggest" → "my-suggestion" 部分包含了一个数组,每个数组项对应从输入文本分解出来的 token(存放在 "text" 这个 key 里)以及为该 token 提供的建议词项(存放在 options 数组里)。 示例里返回了 "lucne"、"rock" 这 2 个词的建议项(options),其中 "rock" 的 options 是空的,表示没有可以建议的选项,为什么? 上面提到了,我们为查询提供的 "suggest_mode" 是 "missing",由于 "rock" 在索引的词典里已经存在了,够精准,就不建议啦。

只有词典里找不到词,才会为其提供相似的选项。如果将 "suggest_mode" 换成 "popular" 会是什么效果?尝试一下,重新执行查询,返回结果里 "rock" 这个词的 option[] 不再是空的,而是建议了 "rocks"。

回想一下,"rock" 和 "rocks" 在索引词典里都是有的。 不难看出即使用户输入的 token 在索引的词典里已经有了,但是因为存在一个词频更高的相似项,这个相似项可能是更合适的,就被挑选到 options 里了。

最后还有一个 "always" mode,其含义是不管 token 是否存在于索引词典里都要给出相似项。有人可能会问,两个 term 的相似性是如何判断的? Es 使用了一种叫做「Levenstein Edit Distance」的算法,其核心思想就是一个词改动多少个字符就可以和另外一个词一致。 Term suggester 还有其他很多可选参数来控制这个相似性的模糊程度,这里就不一一赘述了。

b. Phrase Suggester

Phrase Suggester 在 Term Suggester 的基础上,会考量多个 term 之间的关系,比如是否同时出现在索引的原文里,相邻程度,以及词频等等。看个范例就比较容易明白了:

POST /blogs/_search
{
    "suggest": {
        "my-suggestion": {
            "text": "lucne and elasticsear rock",
            "phrase": {
                "field": "body",
                "highlight": {
                    "pre_tag": "<em>",
                    "post_tag": "</em>"
                }
            }
        }
    }
}

返回结果:

"suggest": {
    "my-suggestion": [
        {
            "text": "lucne and elasticsear rock",
            "offset": 0,
            "length": 26,
            "options": [
                {
                    "text": "lucene and elasticsearch rock",
                    "highlighted": "<em>lucene</em> and <em>elasticsearch</em> rock",
                    "score": 0.004993905
                },
                {
                    "text": "lucne and elasticsearch rock",
                    "highlighted": "lucne and <em>elasticsearch</em> rock",
                    "score": 0.0033391973
                },
                {
                    "text": "lucene and elasticsear rock",
                    "highlighted": "<em>lucene</em> and elasticsear rock",
                    "score": 0.0029183894
                }
            ]
        }
    ]
}

options 直接返回一个 phrase 列表,由于加了 highlight 选项,被替换的 term 会被高亮。因为 "lucene" 和 "elasticsearch" 曾经在同一条原文里出现过,同时替换 2 个 term 的可信度更高,所以打分较高,排在第 1 位返回。Phrase Suggester 有相当多的参数用于控制匹配的模糊程度,需要根据实际应用情况去挑选和调试。

c. Completion Suggester

下面来谈一下 Completion Suggester,它主要针对的应用场景就是 "Auto Completion"。 此场景下用户每输入一个字符的时候,就需要即时发送一次查询请求到后端查找匹配项,在用户输入速度较高的情况
下对后端响应速度要求比较苛刻。因此实现上它和前面两个 Suggester 采用了不同的数据结构,索引并
非通过倒排来完成,而是将 analyze 过的数据编码成 FST 和索引一起存放。对于一个 open 状态的索引,FST 会被 Es 整个装载到内存里的,进行前缀查找速度极快。但是 FST 只能用于前缀查找,这也是 Completion Suggester 的局限所在。

为了使用 Completion Suggester,字段的类型需要专门定义如下:

PUT /blogs_completion/
{
    "mappings": {
        "properties": {
            "body": {
                "type": "completion"
            }
        }
    }
}

用 bulk API 索引些数据:

POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "the elk stack rocks"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "elasticsearch is rock solid"}

查找:

POST /blogs_completion/_search?pretty
{
    "size": 0,
    "suggest": {
        "blog-suggest": {
            "prefix": "elastic i",
            "completion": {
                "field": "body"
            }
        }
    }
}

结果:

"suggest": {
    "blog-suggest": [
        {
            "text": "elastic i",
            "offset": 0,
            "length": 9,
            "options": [
                {
                    "text": "Elastic is the company behind ELK stack",
                    "_index": "blogs_completion",
                    "_type": "_doc",
                    "_id": "7WIhOnQB-DBpPI60CSK-",
                    "_score": 1.0,
                    "_source": {
                        "body": "Elastic is the company behind ELK stack"
                    }
                }
            ]
        }
    ]
}

值得注意的一点是 Completion Suggester 在索引原始数据的时候也要经过 analyze 阶段,取决于选用的 analyzer 不同,某些词可能会被转换,某些词可能被去除,这些会影响 FST 编码结果,也会影响查找匹配的效果。

比如我们删除上面的索引,重新设置索引的 mapping,将 analyzer 更改为 "english":

PUT /blogs_completion
{
    "mappings": {
        "properties": {
            "body": {
                "type": "completion",
                "analyzer": "english"
            }
        }
    }
}

POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs_completion" } }
{ "body": "the elk stack rocks"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "elasticsearch is rock solid"}

bulk API 索引同样的数据后,执行下面的查询:

POST /blogs_completion/_search?pretty

{
    "size": 0,
    "suggest": {
        "blog-suggest": {
            "prefix": "elastic i",
            "completion": {
                "field": "body"
            }
        }
    }
}

居然没有匹配结果了! 原来我们用的 english analyzer 会剥离掉 stop word,而 "is" 就是其中一个,被剥离掉了。用 analyze API 测试一下:

POST _analyze
{
    "text": "elasticsearch is rock solid",
    "analyzer":"english"
}
-------------------------------------------
{
    "tokens": [
        {
            "token": "elasticsearch",
            "start_offset": 0,
            "end_offset": 13,
            "type": "<ALPHANUM>",
            "position": 0
        },
        {
            "token": "rock",
            "start_offset": 17,
            "end_offset": 21,
            "type": "<ALPHANUM>",
            "position": 2
        },
        {
            "token": "solid",
            "start_offset": 22,
            "end_offset": 27,
            "type": "<ALPHANUM>",
            "position": 3
        }
    ]
}

FST(Finite State Transducers)只编码了这 3 个 token,并且默认的还会记录他们在文档中的位置和分隔符。 用户输入 "elastic i" 进行查找的时候,输入被分解成 "elastic" 和 "i",FST 没有编码这个 "i", 匹配失败。

好吧,如果你现在还足够清醒的话,试一下搜索 "elastic is",会发现又有结果,why?!因为这次输入的 text 经过 english analyzer 的时候 "is" 也被剥离了,只需在 FST 里查询 "elastic" 这个前缀,自然就可以匹配到了。

其他能影响 Completion Suggester 结果的,还有如 "preserve_separators"、"preserve_position_increments" 等等 mapping 参数来控制匹配的模糊程度,以及搜索时可以选用 Fuzzy Queries,使得上面例子里的 "elastic i" 在使用 english analyzer 的情况下依然可以匹配到结果。

  • "preserve_separators":false(设置为 false 将忽略空格之类的分隔符)
  • "preserve_position_increments": true,如果建议词第一个词是停用词并且我们使用了过滤停用词的分析器,需要将此设置为 false。

因此用好 Completion Suggester 并不是一件容易的事,实际应用开发过程中,需要根据数据特性和业务需要,灵活搭配 analyzer 和 mapping 参数,反复调试才可能获得理想的补全效果。

回到篇首京东或者百度搜索框的补全/纠错功能,如果用 Es 怎么实现呢?我能想到的一个的实现方式:

  • 在用户刚开始输入的过程中,使用 Completion Suggester 进行关键词前缀匹配,刚开始匹配项会比较多,随着用户输入字符增多,匹配项越来越少。如果用户输入比较精准,可能 Completion Suggester 的结果已经够好,用户已经可以看到理想的备选项了;
  • 如果 Completion Suggester 已经到了零匹配,那么可以猜测是否用户有输入错误,这时候可以尝试一下 Phrase Suggester;
  • 如果 Phrase Suggester 没有找到任何 option,开始尝试 Term Suggester。
  • 精准程度(Precision)上看: Completion > Phrase > term, 而召回率(Recall)上则反之。从性能上看,Completion Suggester 是最快的,如果能满足业务需求,只用 Completion Suggester 做前缀匹配是最理想的。 Phrase 和 Term 由于是做倒排索引的搜索,相比较而言性能应该要低不少,应尽量控制 Suggester 用到的索引的数据量,最理想的状况是经过一定时间预热后,索引可以全量 map 到内存。
召回率(Recall)     = 系统检索到的相关文件 / 系统所有相关的文件总数
准确率(Precision)  = 系统检索到的相关文件 / 系统所有检索到的文件总数

从一个大规模数据集合中检索文档时,可把文档分成 4 组:

- 系统检索到的相关文档(A)
- 系统检索到的不相关文档(B)
- 相关但是系统没有检索到的文档(C)
- 不相关且没有被系统检索到的文档(D)

则:
- 召回率R:用实际检索到相关文档数作为分子,所有相关文档总数作为分母,即R = A / ( A + C )
- 精度P:用实际检索到相关文档数作为分子,所有检索到的文档总数作为分母,即P = A / ( A + B )

例:一个数据库有 1000 个文档,其中有 50 个文档符合相关定义的问题
    系统检索到 75 个文档,但其中只有 45 个文档被检索出。
  - [精 度]   P = 45/75 = 60%
  - [召回率]  R = 45/50 = 90%

d. Context Suggester

Completion Suggester 的扩展。可以在搜索中加入更多的上下文信息,然后根据不同的上下文信息,对相同的输入,比如对 "star" 提供不同的建议值:

  • 咖啡相关:starbucks
  • 电影相关:star wars
posted @ 2022-01-13 23:34  tree6x7  阅读(423)  评论(0编辑  收藏  举报