ElasticSearch 处理自然语言流程
ES处理人类语言
ElasticSearch提供了很多的语言分析器,这些分析器承担以下四种角色:
-
文本拆分为单词
The quick brown foxes
→ [The
,quick
,brown
,foxes
] -
大写转小写
The
→the
-
移除常用的_停用词_:
[
The
,quick
,brown
,foxes
] → [quick
,brown
,foxes
] -
词干处理
foxes
→fox
最后得到:[quick
, brown
, fox
]。
混合语言
多语言文档主要有这几种类型:
- 每份 doucument(文档) 有自己的主语言,并包含一些其他语言的片段。
- 每个 field(域) 有自己的主语言并包含一些其他语言的片段。
- 每个 field(域) 都是混合语言。
面对不同的类型,应当保持将不同的语言分割开,在同一份倒排索引内混合多种语言可能会造成一些问题。
不合理的词干提取
为不同的语言提供同样的词干提规则 将会导致有的词的词根找的正确,有的词的词根找的不正确,有的词根本找不到词根。 提供多种的词干提取器轮流切分同一份文档的结果很有可能得到一堆垃圾,因为下一个词干提取器会尝试切分一个已经被缩减为词干的单词。
语言识别
也许一份文档来自第三方资源并且没有经过语言分类,或者是不正确的分类。这时需要一个学习算法来归类文档的主语言。
详细内容是来自 Mike McCandless 的 chromium-compact-language-detector 工具包,使用的是google开发的基于 (Apache License 2.0)的开源工具包 Compact Language Detector (CLD) 。 它小巧,快速,且精确,并能根据短短的两句话就可以检测 160+ 的语言。 它甚至能对单块文本检测多种语言。支持多种开发语言包括 Python,Perl,JavaScript,PHP,C#/.NET,和 R 。
确定用户搜索请求的语言并不是那么简单。 CLD 是为了至少 200 字符长的文本设计的。字符短的文本,例如搜索关键字,会产生不精确的结果。 这种情况下,或许采取一些简单的启发式算法会更好些,例如该国家的官方语言,用户选择的语言,和 HTTP accept-language
headers (HTTP头文件)。
文档类型
根据不同的文档类型需要不同的处理方式。
每份文档一种语言
不同语言的文档分别被存放在不同的索引中 — blogs-en
、blogs-fr
等,这样每个索引就可以使用相同的类型和相同的域,只是使用不同的分析器:
PUT /blogs-en
{
"mappings": {
"post": {
"properties": {
"title": {
"type": "string",
"fields": {
"stemmed": {
"type": "string",
"analyzer": "english"
}
}}}}}}
PUT /blogs-fr
{
"mappings": {
"post": {
"properties": {
"title": {
"type": "string",
"fields": {
"stemmed": {
"type": "string",
"analyzer": "french"
}
}}}}}}
每一种语言的文档都可被独立查询,或通过查询多种索引来查询多种语言,也可以使用indices_boost
参数为特定的语言添加优先权:
curl -X GET "localhost:9200/blogs-*/post/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"multi_match": {
"query": "deja vu",
"fields": [ "title", "title.stemmed" ]
"type": "most_fields"
}
},
"indices_boost": {
"blogs-en": 3,
"blogs-fr": 2
}
}'
blogs-en
的权重为3,blog-fr
的权重为2,相应的搜索结果会更倾向于英语。
词汇识别
没有能够处理所有人类语言的万能分析器,ES为很多语言提供了专用的分析器,其他特殊语言的分析器以插件的形式提供。
1. 标准分析器
任何全文检索的字符串域都默认使用standard
分析器,如需自定义分析器,格式如下:
{
"type": "custom",
"tokenizer": "standard",
"filter": [ "lowercase", "stop"]
}
lowercase
(小写字母) 和stop
(停用词) 都是词汇单元过滤器。standard
就是一个标准分词器。
2. 安装 ICU 插件
ES 的ICU分析器插件提供了丰富的处理 Unicode 工具。在 ES6.5 下只需要执行命令:
./bin/elasticsearch-plugin install analysis-icu
发现这一行日志,便是安装成功了:
[2019-01-14T10:10:03,071][INFO ][o.e.p.PluginsService ] [5CDrNkJ] loaded plugin [analysis-icu]
3. 整理输入文本
当输入文本是干净的时候分词器将提供最佳分词结果。 然而很多时候,我们需要处理的文本会是除了干净文本之外的任何文本。在分词之前整理文本会提升输出结果的质量。
HTML分词
将 HTML 通过 标准分词器
或 icu_分词器
分词将产生糟糕的结果。这些分词器不知道如何处理 HTML 标签。
字符过滤器 可以添加进分析器中,在将文本传给分词器之前预处理该文本。在这种情况下,我们可以用 html_strip
字符过滤器 移除 HTML 标签并编码 HTML 实体如 é
为一致的 Unicode 字符。想将它们作为分析器的一部分使用,需要把它们添加到 custom
类型的自定义分析器里:
想要使用它们,需要将它们添加到custom
类型的自定义分析器中:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_html_analyzer": {
"tokenizer": "standard",
"char_filter": [ "html_strip" ]
}
}
}
}
}'
归一化词元
把文本切割成 词元(token) 只完成了工作的一半。这些词元还需要进行归一化。
归一化这个过程会去除同一个词元(token)的无意义差别。这些都是语汇单元过滤器的工作。语汇单元过滤器接收来自分词器(tokenizer)的词元(token)流。还可以一起使用多个语汇单元过滤器。
下面就用一个例子来完成的体验下整个归一化词元的处理流程。
1. 初始化
为了在分析过程中使用token过滤器,先创建一个custom
分析器:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_html_analyzer": {
"tokenizer": "standard",
"filter": [ "lowercase" ],
"char_filter": [ "html_strip" ]
}
}
}
}
}'
2. 口音处理
为了实现对口音的处理,对于西方语言可以用asciifolding
字符过滤器来实现这个功能,像lowercase
过滤器一样,asciifolding
不需要任何配置,可以被custom
分析器直接使用:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_html_analyzer": {
"tokenizer": "standard",
"filter": [ "lowercase", "asciifolding" ],
"char_filter": [ "html_strip" ]
}
}
}
}
}'
保留原意
我们可以对文本做两次索引:一次使用原文形式,一次用去掉变音符号的形式
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_html_analyzer": {
"tokenizer": "standard",
"filter": [ "lowercase", "asciifolding" ],
"char_filter": [ "html_strip" ]
}
}
}
},
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"fields": {
"folded": {
"type": "text",
"analyzer": "folding"
}
}
}
}
}'
- 在
title
字段用standard
分析器,会保留原文的变音符号 - 在
title.folded
字段用folding
分析器,会去掉变音符号
3. Unicode的世界
当Elasticsearch在比较词元(token)的时候,它是进行字节(byte)级别的比较。 换句话说,如果两个词元(token)被判定为相同的话,他们必须是相同的字节(byte)组成的。然而,Unicode允许你用不同的字节来写相同的字符。
所以我们看上去相同的词元,在ES比较中可能就是不同的词元。对于这个问题,ES提供了解决方法:有4种Unicode 归一化形式 :nfc
,nfd
,nfkc
,nfkd
,它们都把 Unicode 字符转换成对应标准格式,把所有的字符进行字节级别的比较。
可以使用icu_normalizer
语汇单元过滤器(token filters) 来保证所有的词元(token) 是相同模式:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"filter": {
"nfkc_normalizer": {
"type": "icu_normalizer",
"name": "nfkc"
}
},
"analyzer": {
"my_normalizer": {
"tokenizer": "icu_tokenizer",
"filter": [ "nfkc_normalizer" ]
}
}
}
}
}'
- 用
nfkc
归一化(normalization) 模式来归一化(Normalize) 所有词元(token)。
4. Unicode 大小写折叠
把词条小写的核心是让他们看起来更像。在Unicode种,这个工作是大小写折叠来完成的,而不是小写化。大小写折叠把单词转换到一种形式,是让写法不会影响到单词的比较,所以拼写不需要完全正确。
换句话说, nfkc_cf
等价于 lowercase
语汇单元过滤器(token filters),但是却适用于所有的语言。 on-steroids 等价于 standard
分析器,例如:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_lowercaser": {
"tokenizer": "icu_tokenizer",
"filter": [ "icu_normalizer" ]
}
}
}
}
}'
icu_normalizer
默认是nfkc_cf
模式
5. Unicode 字符折叠
如果你有指定的字符不想被折叠,你可以使用 UnicodeSet(像字符的正则表达式) 来指定哪些Unicode才可以被折叠。例如:瑞典单词 å
,ä
, ö
, Å
, Ä
, 和 Ö
不能被折叠,你就可以设定为: [^åäöÅÄÖ]
(^
表示 不包含)。这样就会对于所有的Unicode字符生效。
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"filter": {
"swedish_folding": {
"type": "icu_folding",
"unicodeSetFilter": "[^åäöÅÄÖ]"
}
},
"analyzer": {
"swedish_analyzer": {
"tokenizer": "icu_tokenizer",
"filter": [ "swedish_folding", "lowercase" ]
}
}
}
}
}'
swedish_folding
语汇单元过滤器(token filters) 定制了icu_folding
语汇单元过滤器来不处理那些大写和小写的瑞典单词swedish
分析器首先分词,然后用swedish_folding
语汇单元过滤器来折叠单词,最后把他们转换为小写。
6. 排序和整理
analyzed
域无法排序并不是因为使用了分析器,而是因为分析器将字符串拆分成了很多词汇单元,就像一个 词汇袋 ,所以 Elasticsearch 不知道使用那一个词汇单元排序。
大小写敏感排序
使用not_analyzed
域来排序:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"mappings": {
"user": {
"properties": {
"name": {
"type": "text",
"fields": {
"raw": {
"type": "text",
"index": "not_analyzed"
}
}
}
}
}
}
}'
analyzed
,name
域用来搜索not_analyzed
,name.raw
域用来排序
Unicode 归类算法
归类是将文本按预定义顺序排序的过程。Unicode归类算法(或称为 UCA)定义了一种将字符串按照在归类单元表中定义的顺序排序的方法。
UCA 还定义了默认 Unicode 排序规则元素表(或称为 DUCET),DUCET 为无论任何语言的所有 Unicode 字符定义了默认排序。大多时候使用 DUCET 作为起点并且添加一些自定义规则用来处理每种语言的特性。
UCA 将字符串和排序规则作为输入,并输出二进制排序键。将依据指定的排序规则对字符串集合进行排序转化为对其二进制排序键的简单比较。
词根化处理
大多数语言的单词都可以 词形变化 。但是词形却干扰了检索。一个单一的词根词义可能会被很多不同的字母序列表达。词干提取 试图移除单词的变形形式之间的差别,从而达到将没歌词都提取为它的词根形式。
单词的词根形式可能并不是一个真正的单词,例如jumping
和jumpiness
的词干为jumpi
。
词干提取是一种遭受两种困扰的模糊的技术:
-
词干弱提取
无法将同样意思的单词缩减为同一个词根。例如
jumped
和jumps
被提取为jump
,而jumping
被提取为jumpi
。弱词干提取会导致搜索时无法返回相关文档。 -
词干过度提取
无法将不同含义的单词分开。例如
general
和generate
可能都被提取为gener
。词干过度提取会降低精准度:不相干的文档会在不需要他们返回的时候返回。
1. 词干提取算法
ES中的大部分 stemmers(词干提取器) 是基于算法的,提供了一些列规则用于将一个词提取为它的词根形式。提取单词词干时并不需要知道该词的任何信息。
基于算法的 stemmers 优点在于:
可以作为插件使用,速度快,占用内存少,有规律的单词处理效果好
缺点在于:
没规律的单词处理效果很差
使用基于算法的词干提取器
所有基于算法的词干提取器都暴露了用来接收语言
参数的统一接口:stemmer token filter
。可以自己自定义一个配置文件:
{
"settings": {
"analysis": {
"filter": {
"english_stop": {
"type": "stop",
"stopwords": "_english_"
},
"english_keywords": {
"type": "keyword_marker",
"keywords": []
},
"english_stemmer": {
"type": "stemmer",
"language": "english"
},
"english_possessive_stemmer": {
"type": "stemmer",
"language": "possessive_english"
}
},
"analyzer": {
"english": {
"tokenizer": "standard",
"filter": [
"english_possessive_stemmer",
"lowercase",
"english_stop",
"english_keywords",
"english_stemmer"
]
}
}
}
}
}
keyword_marker
分词过滤器列出那些不用被词干提取的单词。这个过滤器默认情况下是一个空的列表。english
分析器使用了两个词干提取器:possessive_english
词干提取器和english
词干提取器。所有格词干提取器会在任何词传递到english_stop
、english_keywords
和english_stemmer
之前去除s
。
2. 字典词干提取器
不同于应用一系列标准规则到每一个词上的算法体感提取器,字典词干提取器只是简单的在字典里查找词。一个字典词干提取器应当可以:
- 返回不规则形式如
feet
和mice
的正确词干 - 区分出词形相似但词义不同的情形,如
organ
和organization
实践中一个好的算法化词干提取器一般优于一个字典词干提取器。应该有以下两大原因:
-
字典质量
一个字典词干提取器对于字典中不存在的词无能为力,而一个基于算法的词干提取器,则会继续应用之前的相同规则,结果可能正确或错误。
-
大小和性能
字典词干提取器需要加载所有词汇、所有前缀,以及所有后缀到内存中,这回显著的消耗内存。找到一个词的正确词干,一遍比算法化词干提取器的相同过程更加复杂。
依赖于不同的字典质量,去除前后缀的过程可能会更加高效或低效。低效的情形可能会明显地拖慢整个词干提取过程。另一方面,算法化词干提取器通常更简单、轻量和快速。
3. 选择一个词干提取器
在文档 stemmer token filter 里面列出了一些针对语言的若干词干提取器。就英语:
english
:porter_stem
语汇单元过滤器light_english
:kstem
语汇单元过滤器minimal_english
: Lucene里面的EnglishMinimalStemmer
,用来移除复数lovins
: 基于 Snowball 的 Lovins 提取器,第一个词干提取器possessive_english
: Lucene里面的EnglishMinimalStemmer
,移除's
关于哪个时最好的词干提取器,不存在答案,要考虑三个因素:性能、质量、程度。
4. 控制词干提取
语汇单元过滤器keyword_marker
和stemmer_override
能够自定义词干提取过程。
阻止词干提取
语言分析器的参数stem_exclusion
允许指定一个词语列表,让他们不被词干提取。在内部,这些语言分析器使用keyword_marker
来标记这些词语列表为 keywords,用来阻止后续的词干过滤器来触碰这些词语。
例如,我们创建一个简单自定义分析器,使用 porter_stem
语汇单元过滤器,同时阻止 skies
的词干提取:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"filter": {
"no_stem": {
"type": "keyword_marker",
"keywords": [ "skies" ]
}
},
"analyzer": {
"my_english": {
"tokenizer": "standard",
"filter": [
"lowercase",
"no_stem",
"porter_stem"
]
}
}
}
}
}'
自定义提取
stemmer_override
语汇单元过滤器允许指定自定义的提取规则,同时可以处理一些不规则的形式,如mice
提取为mouse
:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"filter": {
"custom_stem": {
"type": "stemmer_override",
"rules": [
"skies=>sky",
"mice=>mouse",
"feet=>foot"
]
}
},
"analyzer": {
"my_english": {
"tokenizer": "standard",
"filter": [
"lowercase",
"custom_stem",
"porter_stem"
]
}
}
}
}
}'
5. 原型词干提取是个好主意吗
用户喜欢 原形 词干提取这个主意:“如果我可以只用一个组合字段,为什么还要分别存一个未提取词干和已提取词干的字段呢?” 但这是一个好主意吗?答案一直都是否定的。因为有两个问题:
第一个问题是无法区分精准匹配和非精准匹配。本章中,我们看到了多义词经常会被展开成相同的词干词:organs
和 organization
都会被提取为 organ
。
在 使用语言分析器 我们展示了如何整合一个已提取词干属性的查询(为了增加召回率)和一个未提取词干属性的查询(为了提升相关度)。 当提取和未提取词干的属性相互独立时,单个属性的贡献可以通过给其中一个属性增加boost值来优化(参见 语句的优先级 )。相反地,如果已提取和未提取词干的形式置于同一个属性,就没有办法来优化搜索结果了。
第二个问题是,必须搞清楚 相关度分值是否如何计算的。在 什么是相关性? 我们解释了部分计算依赖于逆文档频率(IDF)—— 即一个词在索引库的所有文档中出现的频繁程度。 在一个包含文本 jump jumped jumps
的文档上使用原形词干提取,将得到下列词项:
Pos 1: (jump)
Pos 2: (jumped,jump)
Pos 3: (jumps,jump)
jumped
和 jumps
各出现一次,所以有正确的IDF值;jump
出现了3次,作为一个搜索词项,与其他未提取词干的形式相比,这明显降低了它的IDF值。
基于这些原因,我们不推荐使用原形词干提取。
停用词
词干提取的重要性不仅是因为它让搜索的内容更广泛、让检索的能力更深入,还因为它是压缩索引空间的工具。同时,有些词的索引是完全没有必要的,因为他们没有任何实际的意思,这类词简单的分为两组:
-
低频词
在文档集合中相对出现较少的词,因为稀少,所以权重值更高
-
高频词
在索引下的文档集合中出现较多的常用词,例如
the
,and
,这些词权重小,对相关度评分影响不大。
每种语言都存在一些非常常见的单词,它们对搜索没有太大价值。在 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
这些 停用词 通常在索引前就可以被过滤掉,同时对检索的负面影响不大。
1. 使用停用词
移除停用词的工作是由stop
停用词过滤器完成的,可以通过创建自定义的分析器来使用它。
为了让标准分析器能与自定义停用词表连用,需要创建一个分析器的配置好的模板:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "standard",
"stopwords": [ "and", "the"]
}
}
}
}
}'
- 自定义的分析器名为
my_analyzer
- 这是一个标准的
standard
分析器 - 过滤掉的停用词包括
and
和the
指定停用词
停用词可以以内联的方式传入,通过指定数组:
"stopwords": [ "and", "the"]
特定语言的默认停用词,可以通过使用_lang_
符号来指定:
"stopwords": "_english_"
如果需要禁用停用词,可以设置为_none_
。
同时,停用词还可以使用一行一个单词的格式保存在文本中,并通过stopwords_path
参数设置路径:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
'{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "standard",
"stopwords": "stopwords/english.txt"
}
}
}
}
}'
2. 停用词和短语查询
一个典型的索引可能会包含以下部分或全部的数据:
-
词项字典
索引中所有文档内所有词项的有序列表,以及包含该词的文档数量
-
倒排表
包含每个词项的文档列表
-
词频
每个词在每个文档中出现的频率
-
位置
每个词项在每个文档里出现的位置,供短语查询或近似查询使用
-
偏移
每个词项在每个文档里开始与结束字符的便宜,供词语高亮使用,默认禁用
-
规范因子
用来对字段长度进行规范化处理的因子,给较短字段予更多权重
位置信息
analyzed
字符串字段的位置信息默认是开启的,所以短语查询能随时使用到它。词项出现的越频繁,用来存储它位置信息的空间就越多。
运行一个针对高频词the
的短语查询可能会导致从磁盘读取好几个G的数据。这些数据会被存储到内核文件系统的缓存中,以提高后续访问的速度,这可能会导致其他数据从缓存中删除,进而使后续查询变慢。