世界上大多数语言都是屈折变化的,意思是词语可以通过变形来表达不同的含义:
尽管屈折有助于表达,但它会影响可检索性,因为一个词根的义(或意思)可能由多种不同的字母序列表达。英语是一种弱屈折的语言(我们可以忽略屈折仍然得到合理的搜索结果),但某些其他语言是高度屈折化的,要想获得高质量的搜索结果需要额外的功夫。
词干提取法,通过将每个单词缩减至它们词根的方式,试图消除这种屈折词之间的差异。例如 foxes
可以被缩减成词根 fox
,这种消除单复数差异的方法同样可以被应用于消除大小写之间的差异。
一个词语的词根形式可能甚至根本就不是实际存在的词。词语 jumping
和 jumpiness
可能被同时提取成 jumpi
。这并不重要,只要索引时和搜索时生成的词项是相同的,搜索就能正常工作。
如果词干提前像我们说的这样简单,那么只需要一个实现就可以了。不幸的是,词干提取不是一门完备的科学,它存在两个问题:提取不足(understemming)和提取过度(overstemming)。
提取不足是指无法将具有相同意义词语缩减成相同词根。例如,jumped
和 jumps
可能会被缩减成 jump
,而 jumping
会被缩减成 jumpi
。提取不足会降低相关文档的检索效果。
提取过度是指无法将两个有区别意义的词区分开。例如,general
和 generate
可能会被提取成 gener
。提取过度降低了精确度:不相关的文档会在它们不应该出现时被搜索出来。
词形还原(Lemmatization)
词元(lemma)标准或字典定义的一组相关词的表示形式(
paying
、paid
和pays
的词元都是pay
)。通常用词元和它相关的词长的很像,但有些时候却并非这样(如:is
、was
、am
和being
的词元是be
)。词形还原,像词干提取一样,试图将相关的词语集合起来,但它比词干提取更进一步,它试图按照词义(或意思)将词语分组。同样的词可能表达两个意思,例如 wake 可以是 wake up 睡醒的意思,也可以是 funeral 葬礼的意思。词形还原会尝试区分这两种词义,但词干提取可能会错误的将它们合并。
词形还原是一个更加复杂而且更加昂贵的过程,它需要理解词语出现的语境才能决定它们想要表达的含义。在实际应用中,词干提取和词形还原具有相似的效果,但前者代价更低
本章首先会讨论 Elasticsearch 里提供两类词干提取器:算法提取器 和 字典提取器,然后在 提取器的选择 中介绍如何选择合适的提取器,最后,我们会在 控制词干提取 和 词干原处存储 中讨论如何对词干提取进行量身定制的选择。
elasticsearch版本: elasticsearch-2.x
Elasticsearch 提供的大多数词干提取器都是采用算法提取,因为它们会对单词应用一些列的规则,从而将它们缩减到它们词根的形式,例如剔除复数词尾的 s
或 es
,它们并不需要知道每个单词的具体信息。
算法提取器有它们自身的优势,它们是自带功能,运行速度快,占用内存小,而且对于一般单词能很好工作。不足之处在于它们无法处理一些特殊单词,如:be
、are
与 am
,或者 mice
与 mouse
。
最早的词干提取算法来自于波特的英语提取器,至今它仍是推荐的英语提取器。马丁波特随后投入到创造一种用来创建词干提取器算法的 雪球语言(Snowball language),Elasticsearch 里也提供了一些用 Snowball 写成的提取器。
小贴士
kstem
标记过滤器 是一种将算法与内建字典结合的提取器。字典里有一个词根与意外列表,用来避免不正确的合并发生。kstem
相对波特提取器要更温和。
我们可以直接使用 porter_stem
或 kstem
标记提取器,或者用 snowball
标记提取器创建一个与语言相关的雪球提取器,所有这些算法提取器都是通过统一的接口暴露的:stemmer
标记过滤器,它接受 language
参数。
例如,或许我们发现 english
分析器使用的默认提取器太过于激进,我们想要弱化它。第一步需要查看语言分析器文档(language analyzer documentation)里 english
分析器的配置,显示如下:
{
"settings": {
"analysis": {
"filter": {
"english_stop": {
"type": "stop",
"stopwords": "_english_"
},
"english_keywords": {
"type": "keyword_marker", #1
"keywords": []
},
"english_stemmer": {
"type": "stemmer",
"language": "english" #2
},
"english_possessive_stemmer": {
"type": "stemmer",
"language": "possessive_english" #3
}
},
"analyzer": {
"english": {
"tokenizer": "standard",
"filter": [
"english_possessive_stemmer",
"lowercase",
"english_stop",
"english_keywords",
"english_stemmer"
]
}
}
}
}
}
#1 keyword_marker
标记过滤器列出了不应被提取的词。默认是空的。
#2 #3 english
分析器使用两个提取器:possessive_english
和 english
提取器。所有格提取器(possessive stemmer)在将内容传递给 english_stop
、english_keywords
和 english_stemmer
之前移除所有词的 's
。
查看了当前配置后,只需要以下这些改变,我们就可以用它作为基础创建新的分析器:
english_stemmer
从 english
(与 porter_stem
标记过滤器对应) 改成light_english
(与较温和的 kstem
标记过滤器对应)。asciifolding
标记过滤器移除外来词的变音。keyword_marker
标记过滤器,因为我们不需要它。(我们会在 控制词干提取(Controlling Stemming) 中对其讨论)我们新自定义的分析器如下:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"english_stop": {
"type": "stop",
"stopwords": "_english_"
},
"light_english_stemmer": {
"type": "stemmer",
"language": "light_english" #1
},
"english_possessive_stemmer": {
"type": "stemmer",
"language": "possessive_english"
}
},
"analyzer": {
"english": {
"tokenizer": "standard",
"filter": [
"english_possessive_stemmer",
"lowercase",
"english_stop",
"light_english_stemmer", #2
"asciifolding" #3
]
}
}
}
}
}
#1 #2 将 english
提取器替换成温和的 light_english
提取器。
#3 增加 asciifolding
标记过滤器。
字典提取器(Dictionary Stemmers) 与 算法提取器(Algorithmic Stemmers) 的工作方式大不相同。它不是为每个词应用一组标准的规则,而是简单的在字典中查找每个词。理论上说,它们可以比算法提取器生成更好的结果。一个字典提取器应该具备以下功能:
feet
和 mice
。organ
和 organization
。在实际应用中,一个好的算法提取器会优于一个字典提取器,原因如下:
字典提取器的质量和它字典的质量密切相关。牛津英语字典网站上预测英语单词的数量大概在 750,000 词(包括屈折词)。多数计算机提供的英语字典有其中的十分之一。
词语的意思随着时间会发生变化,曾经将 mobility
提取成 mobil
是合理的,但现在 mobility
的意思和移动电话(mobile phone)相关。字典需要与时俱进是一件非常耗时的事情。有时,当一本字典出版的时候,其中某些词已经过时了。
如果字典提取器碰见一个字典里不存在的词,它就不知如何处理。但对于算法提取器来说,无论正确与否,它始终会应用相同的规则。
一个字典提取器需要加载所有的词、前缀、后缀到内存,这会消耗大量内存。在字典中找到一个词的合适词干要比算法提取器对应的过程复杂许多。
由于字典的质量各不相同,移除前后缀过程的效率可能会或多或少有所差异。低效的方法对提取过程有着巨大负面影响。
这样看来,算法提取器反而通常是简单、轻量且快速的。
小贴士
如果特定语言有一个高质量的算法提取器,那么通常来说它会比字典提取器更优。对于那些没有良好算法提取器的语言,我们可以使用 Hunspell 字典提取器,下一节就会介绍它。
Elasticsearch 通过 hunspell
标记过滤器 提供了基于字典的提取方式。Hunspell hunspell.sourceforge.net 是一个拼写检查工具。很多开源闭源项目都使用它:Open Office
、 LibreOffice
、Chrome
、Firefox
、Thunderbird
。
Hunspell 字典可以通过以下途径获取:
.oxt
文件。.xpi
插件。.zip
文件。Hunspell 字典由两个名字相同(如:en_US
)但后缀不同的文件组成:
.dic
包括所有词根,以字母序排列,以及一个表示所有可能前缀和后缀的编码(统称 词缀)
.aff
包括 .dic
文件中每个编码实际的前缀或后缀转换形式
Hunspell 标记过滤器在专门的 Hunspell 路径下查找字典,默认路径是:./config/hunspell/
。.dic
和 .aff
文件应该被置于代表语言和地区的目录内。例如,我们可以根据以下结构为美式英语创建一个 Hunspell 提取器:
config/
└ hunspell/ #1
└ en_US/ #2
├ en_US.dic
├ en_US.aff
└ settings.yml #3
#1 Hunspell 路径的位置可以通过设置 config/elasticsearch.yml
文件里的 indices.analysis.hunspell.dictionary.location
值来改变。
#2 en_US
是我们传给 hunspell
标记过滤器的地域或名称的名字。
#3 语言级别的设置会在下一小节介绍。
文件 settings.yml
包括了当前语言路径下应用于所有字典的设置,像如下这样:
---
ignore_case: true
strict_affix_parsing: true
这些设置的含义如下:
ignore_case
Hunspell 字典默认是大小写敏感的:姓氏 Booker
与 名词 booker
是不同的词,它们应该按照不同方式提取。在大小写敏感模式下使用 hunspell
提取器看上去是个不错的想法,但这会将事情复杂化:
将 ignore_case
设置成 true
总体来说是个明智的选择。
strict_affix_parsing
字典的质量大相径庭。有些在线字典的 .aff
文件里有畸形规则。默认情况下,如果 Lucene 无法解析这些词缀规则会抛错。如果要处理还有破损词缀规则的文件,我们可以将 strict_affix_parsing
设置成 false
告诉 Lucene 忽略这些规则。
Custom Dictionaries
如果在相同路径下存在多个字典文件(.dic 文件),它们会在加载的时候合并。这使我们可以定制结合下载的字典与我们自定义的词语列表:
config/ └ hunspell/ └ en_US/ ├ en_US.dic ├ en_US.aff ├ custom.dic └ settings.yml
#1
custom
和en_US
字典会合并。
#2 多个.aff
文件是禁止的,因为它们的规则可能相冲突。
.dic
和.aff
文件的格式会在 Hunspell字典格式(Hunspell Dictionary Format) 中讨论。
一旦所有节点都成功安装字典后,我们就能使用它们来定义 hunspell
标记过滤器:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"en_US": {
"type": "hunspell",
"language": "en_US"
}
},
"analyzer": {
"en_US": {
"tokenizer": "standard",
"filter": [ "lowercase", "en_US" ]
}
}
}
}
}
#1 language
与字典所在目录名一直。
用 analyze
API测试我们新的分析器,并将其与 english
分析器进行比较:
GET /my_index/_analyze?analyzer=en_US #1
reorganizes
GET /_analyze?analyzer=english #2
reorganizes
#1 返回 organize
#2 返回 reorgan
hunspell
提取器有个十分有趣的特性,即在前例中,它可以同时移除前缀和后缀。大多数算法提取器只能移除后缀。
小贴士
Hunspell 字典可能消耗一些内存,幸运的是 Elasticsearch 为每个节点只创建单个字典实例,所有分片都共享同一实例并使用同一个 Hunspell 分析器。
对于使用 hunspell 标记器来说,了解 Hunspell 字典的格式并非必要,但了解它的格式有助于我们创建自定义的字典。这并非难事。
例如,在美式英语字典中,en_US.dic
文件包含下面词条:
analyze/ADSG
en_US.aff
文件包含对前缀或后缀的处理规则,用 A
、G
、D
和 S
标志标记。每个标志由多个规则组成,只须匹配其中的一个。每条规则以下面格式表示:
[type] [flag] [letters to remove] [letters to add] [condition]
例如,以下前缀(SFX
)规则D
。它的意思是:当一个词以辅音字母结尾(除 a
、e
、i
、o
或 u
)并跟随 y
字母时,它可以将 y
移除并将 ied
加在其结尾处。(如:ready
→ readied
)
SFX D y ied [^aeiou]y
前面提及 A
、G
、D
和 S
标志的规则如下:
SFX D Y 4
SFX D 0 d e #1
SFX D y ied [^aeiou]y
SFX D 0 ed [^ey]
SFX D 0 ed [aeiou]y
SFX S Y 4
SFX S y ies [^aeiou]y
SFX S 0 s [aeiou]y
SFX S 0 es [sxzh]
SFX S 0 s [^sxzhy] #2
SFX G Y 2
SFX G e ing e #3
SFX G 0 ing [^e]
PFX A Y 1
PFX A 0 re . #4
#1 分析 analyze
以 e
结尾的词,analyzed
后添加 d
。
#2 分析 analyze
不以 s
、x
、z
、h
或 y
结尾的词,analyzed
后添加 s
。
#3 分析 analyze
以 e
结尾的词,analyzed
移除 e
并 添加 ing
。
#4 添加前缀 re
以生成词 reanalyze
。这个规则可以与后缀规则同时使用,生成 reanalyzes
、 reanalyzed
、reanalyzing
。
Hunspell 语法规则的更多信息可以在 Hunspell 文档网站 找到。
stemmer
标记过滤器文档为一些语言列出了多个提取器,英语有如下选择:
english
porter_stem
标记过滤器。
light_english
kstem
标记过滤器。
minimal_english
Lucene 中的 EnglishMinimalStemmer
,移除复数形式。
lovins
基于 Lovins
的 Snowball
,用它生成的首个提取器。
porter
基于 Porter
的 Snowball
。
porter2
基于 Porter2
的 Snowball
。
possessive_english
Lucene里的 EnglishPossessiveFilter
,移除 's
。
除了以上列表,Hunspell 提取器还可以与各种英语字典联用。
有件事情可以确定:当一个问题有不止一个解决方案时,往往没有解决方案能恰当处理这个问题。词干提取也同样适用,每个提取器都采用不同的方式,只是提取的程度或高或低。
stemmer
文档页高亮了为每种语言推荐的提取器,因为它们通常都能在性能和质量之间提供一个合理的折中方案。这也就是说,推荐的提取器并不适用于所有应用场景,最佳提取器的选择依赖于我们的需求,没有唯一正确的答案。做选择时有三个因素可以考虑:性能、质量和程度。
算法提取器的处理速度通常是 Hunspell 提取器的 4 到 5 倍。“自制的”算法提取器通常(但不总是)比对应的Snowball 提取器要快。例如,porter_stem
标记过滤器可以完爆 Snowball 实现的 Porter 过滤器。
Hunspell 提取器必须将所有的词语、前缀后缀加载到内存中,这通常会消耗几兆内存。与之相对,算法提取器代码量小,消耗内存也非常少。
除世界语之外,所有语言都是不规则的。尽管更多的词会遵循一个规范,但最常用词往往是不规范的。有些提取算法已历经多年的研究才能产生合理的高质量结果。其他的都是快速拼凑而成,鲜有深入研究,所以它们只能处理大多数情况。
Hunspell 为精确处理非常规词提供了希望,但在实际应用中却不太可行。字典提取器只有使用好的字典才能达到好的效果。如果 Hunspell 遇到字典里不存在的词语,它便无可奈何。Hunspell 依赖一个内容丰富、高质量、与时俱进的字典来生成好的结果;这类水准的字典往往稀少罕见。相对的,算法过滤器可以很好处理在算法创建时都不存在的新词。
如果语言能够提供高质量的算法提取器,那么使用它比使用 Hunspell 更合理。它处理更快,消耗内存更小,与 Hunspell 比有过之而无不及。
如果准确性和可定制性很重要,我们需要(而且有资源)来维护一个自定义字典,这样 Hunspell 能为我们提供比算法提取器更大的灵活性。(参见 控制词干提取(Controlling Stemming) 如何使用提取器的自定义技术)
不同的提取器或多或少都会提取过度或提取不足。light_
提取器比标准提取器要温和,minimal_
更加温和,Hunspell 提取最激进。
是否采用激进或温和的提取方式取决于我们的应用场景。如果搜索结果是被集群算法使用,我们或许偏好匹配能更全面(也就是说,提取更激进)。如果搜索结果是供人查看的,更温和的提取方式通常能得到更好的结果。对于名词和形容词的提取通常比对动词的提取对搜索更为重要,但这也取决于语言。
其他可以考虑的因素是文档集合的规模,对于有 10,000 个产品的集合,我们很可能希望使用更激进的提取方式来确保至少能匹配一些文档。如果集合很大,我们可能希望能使用更温和的提取方式以期获得好的匹配结果。
首先使用推荐的词干提取器。如果它的效果足够好,就没有必要做更改。如果不好,我们就需要花些时间来比较该语言提供的提取器,从而找到一个最合适的选择。
现存的词干提取方案永远都不可能是完美的。算法分析器会轻松的将它们的规则应用与每个词,也可能会将我们期望分开的词合并。也许在我们的应用场景下,将 skies
和 skiing
分开比将它们提取成 ski
更重要哦(这很可能在使用 english
分析器的时候出现)。
keyword_marker
和 stemmer_override
标记过滤器让我们可以对词干提取过程进行定制。
语言分析器的 stem_exclusion
参数(参见配置语言分析器(Configuring Language Analyzers))允许我们指定一个不想做词干提取的列表。在内部,这些语言分析器用 keyword_marker
标记过滤器将列表里的词标记成关键字,这可以防止后续提取过滤器修改这些词。
例如,我们可以创建一个简单的自定义分析器,它使用 porter_stem
标记过滤器,但它可以防止 skies
被提取:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"no_stem": {
"type": "keyword_marker",
"keywords": [ "skies" ] #1
}
},
"analyzer": {
"my_english": {
"tokenizer": "standard",
"filter": [
"lowercase",
"no_stem",
"porter_stem"
]
}
}
}
}
}
#1 keywords
参数可以接受多个词。
用 analyze
API 对其测试,显示词 skies
在提取时被排除:
GET /my_index/_analyze?analyzer=my_english
sky skies skiing skis #1
#1 返回:sky
、skies
、ski
、ski
小贴士
语言分析器让我们可以在
stem_exclusion
参数指定一个词的数组,keyword_marker
标记过滤器也可以接受keywords_path
参数让我们将所有的关键字存在文件中。这个文件需要每行一个单词,而且必须存于集群的每个节点。参见 更新停用词(Updating Stopwords) 查看如何更新这个文件。
在前例中,我们将 skies
排除在提取过程之外,但可能我们希望将其提取成 sky
。stemmer_override
标记过滤器允许我们指定自定义的提取规则。同时,我们可以处理一些不规则形式,如将 mice
提取成 mouse
,feet
提取成 foot
:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"custom_stem": {
"type": "stemmer_override",
"rules": [ #1
"skies=>sky",
"mice=>mouse",
"feet=>foot"
]
}
},
"analyzer": {
"my_english": {
"tokenizer": "standard",
"filter": [
"lowercase",
"custom_stem", #2
"porter_stem"
]
}
}
}
}
}
GET /my_index/_analyze?analyzer=my_english
The mice came down from the skies and ran over my feet #3
#1 规则以 original=>stem
形式表示。
#2 stemmer_override
过滤器必须放在提取器之前。
#3 返回 the
、mouse
、came
、down
、from
、the
、sky
、and
、ran
、over
、my
、foot
。
小贴士
作为
keyword_marker
标记过滤器,规则可以存于文件中,路径可以通过参数rules_path
指定。
为了完整,我们会以介绍如何将提取的词存入未提取词的同一字段来结束本章的内容。下面有个例子,分析 quick foxes jumped 会生成以下词项:
Pos 1: (the)
Pos 2: (quick)
Pos 3: (foxes,fox) #1
Pos 4: (jumped,jump) #2
#1 #2 提取和未提取形式的词在同一位置。
警告
在使用之前请阅读:存在同一个地方是好主意吗(Is Stemming in situ a Good Idea)
为了将提取词存在同一个地方,我们使用 keyword_repeat
标记过滤器,和 keyword_marker
标记过滤器很像(参见 防止提取(Preventing Stemming)),它将每个词项都标记成一个关键字防止后续提取器修改它。但是,它还会在同一位置再次将词项写入,而且这个再次写入的词项是被提取过的。
只使用 keyword_repeat
标记过滤器会得到以下结果:
Pos 1: (the,the) #1
Pos 2: (quick,quick) #2
Pos 3: (foxes,fox)
Pos 4: (jumped,jump)
#1 提取形式和未提取形式相同,所以这个重复是不必要的。
为了防止以上这种重复,我们可以增加一个 unique
标记过滤器:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"unique_stem": {
"type": "unique",
"only_on_same_position": true #1
}
},
"analyzer": {
"in_situ": {
"tokenizer": "standard",
"filter": [
"lowercase",
"keyword_repeat", #2
"porter_stem",
"unique_stem" #3
]
}
}
}
}
}
#1 unique
标记过滤器是为了移除相同位置的重复标记。
#2 keyword_repeat
标记过滤器必须出现在提取器之前。
#3 unique_stem
过滤器移除重复的词项。
人们喜欢词干原处提取这个点子:“我们可以合并成一个字段,为什么需要提取和未提取两个字段?”但这真的是个好主意吗?答案几乎总是否定的,原因有二。
第一是因为难以区分精确匹配和不精确匹配。本章中,我们已经看到不同含义的单词会时常被合并到同一个词干:organs
和 organization
都被提取成 organ
。
在 使用语言分析器(Using Language Analyzers) 中,我们呈现了如何合并查询使用一个提取字段(为了提高召回率)同时使用未提取字段(为了提高相关度)。当提取和未提取字段是独立的时候,每个字段的贡献可以通过调整权重提升值做到(参见 语句的优先级(Prioritizing Clauses))如果提取和未提取的形式都出现在同一字段,我们就没有在调整搜索结果的途径了。
第二个问题在于相关度评分是如何计算的。在 什么是相关度(What Is Relevance?) 中,我们解释它的计算是依赖于逆向文档平率的(即一个词在索引内所有文档里出现的频率)。对包含 jump
jumped
jumps
的文档使用词干原处提取的方式会有以下结果:
Pos 1: (jump)
Pos 2: (jumped,jump)
Pos 3: (jumps,jump)
jumped
和 jumps
各只出现一次,所以 IDF 可能是正确的,但当 jump
出现三次时,则会大大降低它的值,因为搜索的词项是与未提取的形式进行比较的。
因为如上这些原因,我们并不推荐使用词干原处提取这种方式。
elastic.co: Reducing Words to Their Root Form