从早期的信息检索到如今, 我们已习惯于磁盘空间和内存被限制为很小一部分,所以 必须使你的索引尽可能小。 每个字节都意味着巨大的性能提升。 (查看 将单词还原为词根 ) 词干提取的重要性不仅是因为它让搜索的内容更广泛、让检索的能力更深入,还因为它是压缩索引空间的工具。
一种最简单的减少索引大小的方法就是 索引更少的词。 有些词要比其他词更重要,只索引那些更重要的词来可以大大减少索引的空间。
那么哪些词条可以被过滤呢? 我们可以简单分为两组:
低频词(Low-frequency terms)
在文档集合中相对出现较少的词,因为它们稀少,所以它们的权重值更高。
高频词(High-frequency terms)
在索引下的文档集合中出现较多的常用词,例如 the、and、和is
。 这些词的权重小,对相关度评分影响不大。
当然,频率实际上是个可以衡量的标尺而不是非 高 即 低 的标签。我们可以在标尺的任何位置选取一个标准,低于这个标准的属于低频词,高于它的属于高频词。
词项到底是低频或是高频取决于它们所处的文档。单词 and 如果在所有都是中文的文档里可能是个低频词。在关于数据库的文档集合里,单词 database 可能是一个高频词项,它对搜索这个特定集合毫无帮助。
每种语言都存在一些非常常见的单词,它们对搜索没有太大价值。在 Elasticsearch 中,英语默认的停用词为:
a, an, and, are, as, at, be, but, by, for, if, in, into, is, it,
no, not, of, on, or, such, that, the, their, then, there, these,
they, this, to, was, will, with
这些 停用词 通常在索引前就可以被过滤掉,同时对检索的负面影响不大。但是这样做真的是一个较好的解决方案?
停用词的优缺点
现在我们拥有更大的磁盘空间,更多内存,并且还有更好的压缩算法。 将之前的 33 个常见词从索引中移除,每百万文档只能节省 4MB 空间。 所以使用停用词减少索引大小不再是一个有效的理由。 (不过这种说法还有一点需要注意,我们在 停用词与短语查询 讨论。)
在此基础上,从索引里将这些词移除会使我们降低某种类型的搜索能力。将前面这些所列单词移除会让我们难以完成以下事情:
- 区分 happy 和 not happy。
- 搜索乐队名称 The The。
- 查找莎士比亚的名句 “To be, or not to be” (生存还是毁灭)。
- 使用挪威的国家代码: no。
移除停用词的最主要好处是性能,假设我们在个具有上百万文档的索引中搜索单词 fox。或许 fox 只在其中 20 个文档中出现,也就是说 Elasticsearch 需要计算 20 个文档的相关度评分 _score
从而排出前十。现在我们把搜索条件改为 `the OR fox,几乎所有的文件都包含 the 这个词,也就是说 Elasticsearch 需要为所有一百万文档计算评分 _score。 由此可见第二个查询肯定没有第一个的结果好。
幸运的是,我们可以用来保持常用词搜索,同时还可以保持良好的性能。首先我们一块学习如何使用停用词。
使用停用词
移除停用词的工作是由 stop 停用词过滤器完成的,可以通过创建自定义的分析器来使用它(参见 使用停用词过滤器stop 停用词过滤器)。但是,也有一些自带的分析器预置使用停用词过滤器:
语言分析器
每个语言分析器默认使用与该语言相适的停用词列表,例如:english 英语分析器使用 english 停用词列表。
standard 标准分析器
默认使用空的停用词列表:none ,实际上是禁用了停用词。
pattern 模式分析器
默认使用空的停用词列表:为 none ,与 standard 分析器类似。
停用词和标准分析器(Stopwords and the Standard Analyzer)
为了让标准分析器能与自定义停用词表连用,我们要做的只需创建一个分析器的配置好的版本,然后将停用词列表传入:
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "standard",
"stopwords": [ "and", "the" ]
}
}
}
}
}
- 自定义的分析器名称为 my_analyzer 。
- 这个分析器是一个标准 standard 分析器,进行了一些自定义配置。
- 过滤掉的停用词包括 and 和 the 。
保持位置(Maintaining Positions)
analyzer API的输出结果很有趣:
GET /my_index/_analyze
{
"analyzer" : "my_analyzer",
"text" : "The quick and the dead"
}
{
"tokens": [
{
"token": "quick",
"start_offset": 4,
"end_offset": 9,
"type": "<ALPHANUM>",
"position": 1
},
{
"token": "dead",
"start_offset": 18,
"end_offset": 22,
"type": "<ALPHANUM>",
"position": 4
}
]
}
- position 标记每个词汇单元的位置。
停用词如我们期望被过滤掉了,但有趣的是两个词项的位置 position 没有变化:quick 是原句子的第二个词,dead 是第五个。这对短语查询十分重要,因为如果每个词项的位置被调整了,一个短语查询 quick dead 会与以上示例中的文档错误匹配。
指定停用词(Specifying Stopwords)
停用词可以以内联的方式传入,就像我们在前面的例子中那样,通过指定数组:
"stopwords": [ "and", "the" ]
特定语言的默认停用词,可以通过使用 lang 符号来指定:
"stopwords": "_english_"
TIP: Elasticsearch 中预定义的与语言相关的停用词列表可以在文档"languages", “predefined stopword lists for”)stop 停用词过滤器 中找到。
停用词可以通过指定一个特殊列表 none 来禁用。例如,使用 english 分析器而不使用停用词,可以通过以下方式做到:
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"my_english": {
"type": "english",
"stopwords": "_none_"
}
}
}
}
}
- my_english 分析器是基于 english 分析器。
- 但禁用了停用词。
最后,停用词还可以使用一行一个单词的格式保存在文件中。此文件必须在集群的所有节点上,并且通过 stopwords_path 参数设置路径:
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"my_english": {
"type": "english",
"stopwords_path": "stopwords/english.txt"
}
}
}
}
}
- 停用词文件的路径,该路径相对于 Elasticsearch 的 config 目录。
使用停用词过滤器(Using the stop Token Filter)
当你创建 custom 分析器时候,可以组合多个 stop 停用词过滤器 分词器。例如:我们想要创建一个西班牙语的分析器:
- 自定义停用词列表
- light_spanish 词干提取器
- 在 asciifolding 词汇单元过滤器中除去附加符号
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"spanish_stop": {
"type": "stop",
"stopwords": [ "si", "esta", "el", "la" ]
},
"light_spanish": {
"type": "stemmer",
"language": "light_spanish"
}
},
"analyzer": {
"my_spanish": {
"tokenizer": "spanish",
"filter": [
"lowercase",
"asciifolding",
"spanish_stop",
"light_spanish"
]
}
}
}
}
}
- 停用词过滤器采用与 standard 分析器相同的参数 stopwords 和 stopwords_path 。
- 参见 算法提取器(Algorithmic Stemmers)。
- 过滤器的顺序非常重要,下面会进行解释。
- spanish 分词器不存在
我们将 spanish_stop 过滤器放置在 asciifolding 过滤器之后.这意味着以下三个词组 esta 、ésta 、++está++ ,先通过 asciifolding 过滤器过滤掉特殊字符变成了 esta ,随后使用停用词过滤器会将 esta 去除。 如果我们只想移除 esta 和 ésta ,但是 ++está++ 不想移除。必须将 spanish_stop 过滤器放置在 asciifolding 之前,并且需要在停用词中指定 esta 和 ésta 。
更新停用词(Updating Stopwords)
想要更新分析器的停用词列表有多种方式, 分析器在创建索引时,当集群节点重启时候,或者关闭的索引重新打开的时候。
如果你使用 stopwords 参数以内联方式指定停用词,那么你只能通过关闭索引,更新分析器的配置update index settings API,然后在重新打开索引才能更新停用词。
如果你使用 stopwords_path 参数指定停用词的文件路径 ,那么更新停用词就简单了。你只需更新文件(在每一个集群节点上),然后通过两者之中的任何一个操作来强制重新创建分析器:
- 关闭和重新打开索引 (参考 索引的开与关),
- 一一重启集群下的每个节点。
当然,更新的停用词不会改变任何已经存在的索引。这些停用词的只适用于新的搜索或更新文档。如果要改变现有的文档,则需要重新索引数据。参加 重新索引你的数据 。
停用词与性能
保留停用词最大的缺点就影响搜索性能。使用 Elasticsearch 进行全文搜索,它需要为所有匹配的文档计算相关度评分 _score 从而返回最相关的前 10 个文档。
通常大多数的单词在所有文档中出现的频率低于0.1%,但是有少数词(例如 the )几乎存在于所有的文档中。假设有一个索引含有100万个文档,查询 quick brown fox 词组,能够匹配上的可能少于1000个文档。但是如果查询 the quick brown fox 词组,几乎需要对索引中的100万个文档进行评分和排序,只是为了返回前 10 名最相关的文档。
问题的关键是 the quick brown fox 词组实际是查询 the 或 quick 或 brown 或 fox— 任何文档即使它什么内容都没有而只包含 the 这个词也会被包括在结果集中。因此,我们需要找到一种降低待评分文档数量的方法。
and 操作符 (and Operator)
我们想要减少待评分文档的数量,最简单的方式就是在and 操作符 match 查询时使用 and 操作符,这样可以让所有词都是必须的。
以下是 match 查询:
{
"match": {
"text": {
"query": "the quick brown fox",
"operator": "and"
}
}
}
上述查询被重写为 bool 查询如下:
{
"bool": {
"must": [
{ "term": { "text": "the" }},
{ "term": { "text": "quick" }},
{ "term": { "text": "brown" }},
{ "term": { "text": "fox" }}
]
}
}
bool 查询会智能的根据较优的顺序依次执行每个 term 查询:它会从最低频的词开始。因为所有词项都必须匹配,只要包含低频词的文档才有可能匹配。使用 and 操作符可以大大提升多词查询的速度。
最少匹配数(minimum_should_match)
在精度匹配控制精度的章节里面,我们讨论过使用 minimum_should_match 配置去掉结果中次相关的长尾。虽然它只对这个目的奏效,但是也为我们从侧面带来一个好处,它提供 and 操作符相似的性能。
{
"match": {
"text": {
"query": "the quick brown fox",
"minimum_should_match": "75%"
}
}
}
在上面这个示例中,四分之三的词都必须匹配,这意味着我们只需考虑那些包含最低频或次低频词的文档。 相比默认使用 or 操作符的简单查询,这为我们带来了巨大的性能提升。不过我们有办法可以做得更好……
词项的分别管理
在查询字符串中的词项可以分为更重要(低频词)和次重要(高频词)这两类。 只与次重要词项匹配的文档很有可能不太相关。实际上,我们想要文档能尽可能多的匹配那些更重要的词项。
match 查询接受一个参数 cutoff_frequency ,从而可以让它将查询字符串里的词项分为低频和高频两组。低频组(更重要的词项)组成 bulk 大量查询条件,而高频组(次重要的词项)只会用来评分,而不参与匹配过程。通过对这两组词的区分处理,我们可以在之前慢查询的基础上获得巨大的速度提升。
领域相关的停用词(Domain-Specific Stopwords)
cutoff_frequency 配置的好处是,你在 特定领域 使用停用词不受约束。例如,关于电影网站使用的词 movie 、 color 、 black 和 white ,这些词我们往往认为几乎没有任何意义。使用 stop 词汇单元过滤器,这些特定领域的词必须手动添加到停用词列表中。然而 cutoff_frequency 会查看索引里词项的具体频率,这些词会被自动归类为 高频词汇 。
以下面查询为例:
{
"match": {
"text": {
"query": "Quick and the dead",
"cutoff_frequency": 0.01
}
}
- 任何词项出现在文档中超过1%,被认为是高频词。cutoff_frequency 配置可以指定为一个分数( 0.01 )或者一个正整数( 5 )。
此查询通过 cutoff_frequency 配置,将查询条件划分为低频组( quick , dead )和高频组( and , the )。然后,此查询会被重写为以下的 bool 查询:
{
"bool": {
"must": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "dead" }}
]
}
},
"should": {
"bool": {
"should": [
{ "term": { "text": "and" }},
{ "term": { "text": "the" }}
]
}
}
}
}
- 必须匹配至少一个低频/更重要的词项。
- 高频/次重要性词项是非必须的。
must 意味着至少有一个低频词— quick 或者 dead —必须出现在被匹配文档中。所有其他的文档被排除在外。 should 语句查找高频词 and 和 the ,但也只是在 must 语句查询的结果集文档中查询。 should 语句的唯一的工作就是在对如 Quick and the dead 和 The quick but dead 语句进行评分时,前者得分比后者高。这种方式可以大大减少需要进行评分计算的文档数量。
控制精度
minimum_should_match 参数可以与 cutoff_frequency 组合使用,但是此参数仅适用与低频词。如以下查询:
{
"match": {
"text": {
"query": "Quick and the dead",
"cutoff_frequency": 0.01,
"minimum_should_match": "75%"
}
}
将被重写为如下所示:
{
"bool": {
"must": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "dead" }}
],
"minimum_should_match": 1
}
},
"should": {
"bool": {
"should": [
{ "term": { "text": "and" }},
{ "term": { "text": "the" }}
]
}
}
}
}
- 因为只有两个词,原来的75%向下取整为 1 ,意思是:必须匹配低频词的两者之一。 <2> 高频词仍可选的,并且仅用于评分使用。
高频词
当使用 or 查询高频词条,如— To be, or not to be —进行查询时性能最差。只是为了返回最匹配的前十个结果就对只是包含这些词的所有文档进行评分是盲目的。我们真正的意图是查询整个词条出现的文档,所以在这种情况下,不存低频所言,这个查询需要重写为所有高频词条都必须:
{
"bool": {
"must": [
{ "term": { "text": "to" }},
{ "term": { "text": "be" }},
{ "term": { "text": "or" }},
{ "term": { "text": "not" }},
{ "term": { "text": "to" }},
{ "term": { "text": "be" }}
]
}
}
对常用词使用更多控制(More Control with Common Terms)
尽管高频/低频的功能在 match 查询中是有用的,有时我们还希望能对它有更多的控制,想控制它对高频和低频词分组的行为。 match 查询针对 common 词项查询提供了一组功能。
例如,我们可以让所有低频词都必须匹配,而只对那些包括超过 75% 的高频词文档进行评分:
{
"common": {
"text": {
"query": "Quick and the dead",
"cutoff_frequency": 0.01,
"low_freq_operator": "and",
"minimum_should_match": {
"high_freq": "75%"
}
}
}
}
停用词与短语查询
所有查询中 短语匹配 大约占到5%,但是在慢查询里面它们又占大部分。 短语查询性能相对较差,特别是当短语中包括常用词的时候,如 “To be, or not to be” 短语全部由停用词组成,这是一种极端情况。原因在于几乎需要匹配全量的数据。
在 停用词的两面 停用词的优缺点,中,我们提到移除停用词只能节省倒排索引中的一小部分空间。这句话只部分正确,一个典型的索引会可能包含部分或所有以下数据:
词项字典(Terms dictionary)
索引中所有文档内所有词项的有序列表,以及包含该词的文档数量。
倒排表(Postings list)
包含每个词项的文档(ID)列表。
词频(Term frequency)
每个词项在每个文档里出现的频率。
位置(Positions)
每个词项在每个文档里出现的位置,供短语查询或近似查询使用。
偏移(Offsets)
每个词项在每个文档里开始与结束字符的偏移,供词语高亮使用,默认是禁用的。
规范因子(Norms)
用来对字段长度进行规范化处理的因子,给较短字段予以更多权重。
将停用词从索引中移除会节省 词项字典 和 倒排表 里的少量空间,但 位置 和 偏移 是另一码事。位置和偏移数据很容易变成索引大小的两倍、三倍、甚至四倍。
位置信息
analyzed 字符串字段的位置信息默认是开启的, 所以短语查询能随时使用到它。 词项出现的越频繁,用来存储它位置信息的空间就越多。在一个大的文档集合中,对于那些非常常见的词,它们的位置信息可能占用成百上千兆的空间。
运行一个针对高频词 the 的短语查询可能会导致从磁盘读取好几G的数据。这些数据会被存储到内核文件系统的缓存中,以提高后续访问的速度,这看似是件好事,但这可能会导致其他数据从缓存中被剔除,进一步使后续查询变慢。
这显然是我们需要解决的问题。
索引选项
我们首先应该问自己:是否真的需要使用短语查询或 近似查询?
答案通常是:不需要。在很多应用场景下,比如说日志,我们需要知道一个词 是否 在文档中(这个信息由倒排表提供)而不是关心词的位置在哪里。或许我们要对一两个字段使用短语查询,但是我们完全可以在其他 analyzed 字符串字段上禁用位置信息。
index_options 参数 允许我们控制索引里为每个字段存储的信息。 可选值如下:
docs
只存储文档及其包含词项的信息。这对 not_analyzed 字符串字段是默认的。
freqs
存储 docs 信息,以及每个词在每个文档里出现的频次。词频是完成TF/IDF 相关度计算的必要条件,但如果只想知道一个文档是否包含某个特定词项,则无需使用它。
positions
存储 docs 、 freqs 、 analyzed ,以及每个词项在每个文档里出现的位置。 这对 analyzed 字符串字段是默认的,但当不需使用短语或近似匹配时,可以将其禁用。
offsets
存储 docs,freqs,positions, 以及每个词在原始字符串中开始与结束字符的偏移信息( postings highlighter )。这个信息被用以高亮搜索结果,但它默认是禁用的。
我们可以在索引创建的时候为字段设置 index_options 选项,或者在使用 put-mapping API新增字段映射的时候设置。我们无法修改已有字段的这个设置:
PUT /my_index
{
"mappings": {
"properties": {
"title": {
"type": "text"
},
"content": {
"type": "text",
"index_options": "freqs"
}
}
}
}
- title 字段使用默认的 positions 设置,所以它适于短语或近似查询。
- content 字段的位置设置是禁用的,所以它无法用于短语或近似查询。
停用词
删除停用词是能显著降低位置信息所占空间的一种方式。 一个被删除停用词的索引仍然可以使用短语查询,因为剩下的词的原始位置仍然被保存着,这正如 保持位置(Maintaining Positions) 中看到的那样。 尽管如此,将词项从索引中排除终究会降低搜索能力,这使我们难以区分 Man in the moon 与 Man on the moon 这两个短语。
幸运的是,鱼与熊掌是可以兼得的:请查看 common_grams 过滤器。
common_grams 过滤器
common_grams 过滤器是针对短语查询能更高效的使用停用词而设计的。 它与 shingles 过滤器类似(参见 查找相关词(寻找相关词)), 为每个相邻词对生成 ,用示例解释更为容易。
common_grams 过滤器根据 query_mode 设置的不同而生成不同输出结果:false (为索引使用) 或 true (为搜索使用),所以我们必须创建两个独立的分析器:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"index_filter": {
"type": "common_grams",
"common_words": "_english_"
},
"search_filter": {
"type": "common_grams",
"common_words": "_english_",
"query_mode": true
}
},
"analyzer": {
"index_grams": {
"tokenizer": "standard",
"filter": [ "lowercase", "index_filter" ]
},
"search_grams": {
"tokenizer": "standard",
"filter": [ "lowercase", "search_filter" ]
}
}
}
}
}
- 首先我们基于 common_grams 过滤器创建两个过滤器: index_filter 在索引时使用(此时 query_mode 的默认设置是 false ), search_filter 在查询时使用(此时 query_mode 的默认设置是 true )。
- common_words 参数可以接受与 stopwords 参数同样的选项(参见 指定停用词 指定停用词(Specifying Stopwords) )。这个过滤器还可以接受参数 common_words_path ,使用存于文件里的常用词。
- 然后我们使用过滤器各创建一个索引时分析器和查询时分析器。
有了自定义分析器,我们可以创建一个字段在索引时使用 index_grams 分析器:
PUT /my_index/_mapping
{
"properties": {
"text": {
"type": "text",
"analyzer": "index_grams",
"search_analyzer": "standard"
}
}
}
- text 字段索引时使用 index_grams 分析器,但是在搜索时默认使用 standard 分析器,稍后我们会解释其原因。
索引时(At Index Time)
如果我们对短语 The quick and brown fox 进行拆分,它生成如下词项:
Pos 1: the_quick
Pos 2: quick_and
Pos 3: and_brown
Pos 4: brown_fox
新的 index_grams 分析器生成以下词项:
Pos 1: the, the_quick
Pos 2: quick, quick_and
Pos 3: and, and_brown
Pos 4: brown
Pos 5: fox
所有的词项都是以 unigrams 形式输出的(the、quick 等等),但是如果一个词本身是常用词或者跟随着常用词,那么它同时还会在 unigram 同样的位置以 bigram 形式输出:the_quick , quick_and , and_brown 。
单字查询(Unigram Queries)
因为索引包含 unigrams ,可以使用与其他字段相同的技术进行查询,例如:
GET /my_index/_search
{
"query": {
"match": {
"text": {
"query": "the quick and brown fox",
"cutoff_frequency": 0.01
}
}
}
}
上面这个查询字符串是通过为文本字段配置的 search_analyzer 分析器 --本例中使用的是 standard 分析器-- 进行分析的, 它生成的词项为: the , quick , and , brown , fox 。
因为 text 字段的索引中包含与 standard 分析去生成的一样的 unigrams ,搜索对于任何普通字段都能正常工作。
二元语法短语查询(Bigram Phrase Queries)
但是,当我们进行短语查询时,我们可以用专门的 search_grams 分析器让整个过程变得更高效:
GET /my_index/_search
{
"query": {
"match_phrase": {
"text": {
"query": "The quick and brown fox",
"analyzer": "search_grams"
}
}
}
}
- 对于短语查询,我们重写了默认的 search_analyzer 分析器,而使用 search_grams 分析器。
search_grams 分析器会生成以下词项:
Pos 1: the_quick
Pos 2: quick_and
Pos 3: and_brown
Pos 4: brown
Pos 5: fox
析器排除了所有常用词的 unigrams,只留下常用词的 bigrams 以及低频的 unigrams。如 the_quick 这样的 bigrams 比单个词项 the 更为少见,这样有两个好处:
- the_quick 的位置信息要比 the 的小得多,所以它读取磁盘更快,对系统缓存的影响也更小。
- 词项 the_quick 没有 the 那么常见,所以它可以大量减少需要计算的文档。
两词短语(Two-Word Phrases)
我们的优化可以更进一步,因为大多数的短语查询只由两个词组成,如果其中一个恰好又是常用词,例如:
GET /my_index/_search
{
"query": {
"match_phrase": {
"text": {
"query": "The quick",
"analyzer": "search_grams"
}
}
}
}
那么 search_grams 分析器会输出单个语汇单元:the_quick 。这将原来昂贵的查询(查询 the 和 quick )转换成了对单个词项的高效查找。
停用词与相关性
在结束停用词相关内容之前,最后一个话题是关于相关性的。在索引中保留停用词会降低相关度计算的准确性,特别是当我们的文档非常长时。
正如我们在 词频饱和度 已经讨论过的, 原因在于 词频饱和度 并没有强制对词频率的影响设置上限 。 基于逆文档频率的影响,非常常用的词可能只有很低的权重,但是在长文档中,单个文档出现的绝对数量很大的停用词会导致这些词被不自然的加权。
可以考虑对包含停用词的较长字段使用 Okapi BM25 相似度算法,而不是默认的 Lucene 相似度。