如何明智地结合 shingles 和 edgeNgram 以提供灵活的全文搜索?
How to wisely combine shingles and edgeNgram to provide flexible full text search?
我们有一个符合 OData 的 API,它将其部分全文搜索需求委托给 Elasticsearch 集群。
由于 OData 表达式可能会变得非常复杂,我们决定将它们简单地转换为等效的 Lucene 查询语法并将其提供给 query_string
查询。
我们确实支持一些与文本相关的 OData 过滤器表达式,例如:
startswith(field,'bla')
endswith(field,'bla')
substringof('bla',field)
name eq 'bla'
我们匹配的字段可以是 analyzed
、not_analyzed
或两者(即通过多字段)。
搜索到的文本可以是单个标记(例如 table
),也可以是其中的一部分(例如 tab
),或者是多个标记(例如 table 1.
、table 10
等)。
搜索必须不区分大小写。
以下是我们需要支持的一些行为示例:
startswith(name,'table 1')
必须匹配“Table 1”、“table 100” , "Table 1.5", "table 112层
endswith(name,'table 1')
必须匹配 "Room 1, Table 1", "Subtable 1", "table 1", "Jeff table 1"
substringof('table 1',name)
必须匹配 "Big Table 1 back", "table 1", "Table 1", "Small Table12"
name eq 'table 1'
必须匹配 "Table 1", "TABLE 1", "table 1"
所以基本上,我们接受用户输入(即传递到 startswith
/endswith
的第二个参数的内容,分别是 substringof
的第一个参数,分别是eq
) 的右侧值并尝试完全匹配它,无论标记完全匹配还是部分匹配。
现在,我们正在摆脱下面突出显示的笨拙解决方案,它工作得很好,但远非理想。
在我们的 query_string
中,我们使用 Regular Expression syntax 匹配 not_analyzed
字段。由于该字段是 not_analyzed
并且搜索必须不区分大小写,因此我们在准备正则表达式以输入查询的同时进行自己的标记化,以便得出类似的结果,即这等同于 OData filter endswith(name,'table 8')
(=> 匹配所有 name
以 "table 8" 结尾的文档)
"query": {
"query_string": {
"query": "name.raw:/.*(T|t)(A|a)(B|b)(L|l)(E|e) 8/",
"lowercase_expanded_terms": false,
"analyze_wildcard": true
}
}
因此,尽管这个解决方案工作得很好并且性能也不算太差(结果出人意料),但我们还是想以不同的方式来做,并利用分析器的全部功能来转移所有这些负担都在索引时间而不是搜索时间。然而,由于重新索引我们所有的数据需要数周时间,我们想首先调查是否有令牌过滤器和分析器的良好组合可以帮助我们实现上面列举的相同搜索要求。
我的想法是,理想的解决方案将包含 shingles(即几个标记在一起)和 edge-nGram(即在标记的开头或结尾匹配)的一些明智组合。不过,我不确定的是,是否有可能让它们一起工作以匹配多个令牌,其中一个令牌可能没有被用户完全输入)。例如,如果索引名称字段是 "Big Table 123",我需要 substringof('table 1',name)
来匹配它,所以 "table" 是一个完全匹配的标记,而“1”只是下一个标记的前缀.
在此先感谢您分享您的脑细胞。
更新 1:测试 Andrei 的解决方案后
=> 完全匹配 (eq
) 和 startswith
完美匹配。
一个。 endswith
故障
搜索 substringof('table 112', name)
得到 107 个文档。搜索更具体的案例,例如 endswith(name, 'table 112')
会产生 1525 个文档,而它应该会产生更少的文档(后缀匹配应该是子字符串匹配的子集)。更深入地检查我发现了一些不匹配,例如 "Social Club, Table 12"(不包含“112”)或 "Order 312"(既不包含 "table" 也不包含“112”)。我猜这是因为它们以“12”结尾,这是标记“112”的有效克,因此匹配。
乙。 substringof
故障
搜索 substringof('table',name)
匹配 "Party table"、"Alex on big table" 但不匹配 "Table 1"、"table 112" 等。搜索 substringof('tabl',name)
不匹配任何东西
更新 2
这有点隐含,但我忘了明确提及该解决方案必须与 query_string
查询一起使用,主要是因为 OData 表达式(无论它们多么复杂)将保持不变被翻译成他们的 Lucene 等价物。我知道我们正在用 Lucene 的查询语法来权衡 Elasticsearch Query DSL 的强大功能,后者的功能和表现力稍逊一筹,但这是我们无法真正改变的。不过,我们非常接近!
更新 3(2019 年 6 月 25 日):
ES 7.2 引入了一种名为 search_as_you_type
的新数据类型,它本身就允许这种行为。阅读更多:https://www.elastic.co/guide/en/elasticsearch/reference/7.2/search-as-you-type.html
这是一个有趣的用例。这是我的看法:
{
"settings": {
"analysis": {
"analyzer": {
"my_ngram_analyzer": {
"tokenizer": "my_ngram_tokenizer",
"filter": ["lowercase"]
},
"my_edge_ngram_analyzer": {
"tokenizer": "my_edge_ngram_tokenizer",
"filter": ["lowercase"]
},
"my_reverse_edge_ngram_analyzer": {
"tokenizer": "keyword",
"filter" : ["lowercase","reverse","substring","reverse"]
},
"lowercase_keyword": {
"type": "custom",
"filter": ["lowercase"],
"tokenizer": "keyword"
}
},
"tokenizer": {
"my_ngram_tokenizer": {
"type": "nGram",
"min_gram": "2",
"max_gram": "25"
},
"my_edge_ngram_tokenizer": {
"type": "edgeNGram",
"min_gram": "2",
"max_gram": "25"
}
},
"filter": {
"substring": {
"type": "edgeNGram",
"min_gram": 2,
"max_gram": 25
}
}
}
},
"mappings": {
"test_type": {
"properties": {
"text": {
"type": "string",
"analyzer": "my_ngram_analyzer",
"fields": {
"starts_with": {
"type": "string",
"analyzer": "my_edge_ngram_analyzer"
},
"ends_with": {
"type": "string",
"analyzer": "my_reverse_edge_ngram_analyzer"
},
"exact_case_insensitive_match": {
"type": "string",
"analyzer": "lowercase_keyword"
}
}
}
}
}
}
}
my_ngram_analyzer
用于将每个文本分成小块,小块的大小取决于您的用例。出于测试目的,我选择了 25 个字符。 lowercase
是因为你说不区分大小写。基本上,这是用于 substringof('table 1',name)
的分词器。查询很简单:
{
"query": {
"term": {
"text": {
"value": "table 1"
}
}
}
}
my_edge_ngram_analyzer
用于从头开始拆分文本,这专门用于 startswith(name,'table 1')
用例。同样,查询很简单:
{
"query": {
"term": {
"text.starts_with": {
"value": "table 1"
}
}
}
}
- 我发现这是最棘手的部分 -
endswith(name,'table 1')
的部分。为此,我定义了 my_reverse_edge_ngram_analyzer
,它使用 keyword
分词器和 lowercase
以及前后跟有 reverse
filter 的 edgeNGram
过滤器。这个 tokenizer 基本上做的是在 edgeNGrams 中拆分文本,但边缘是文本的结尾,而不是开始(就像常规 edgeNGram
一样)。
查询:
{
"query": {
"term": {
"text.ends_with": {
"value": "table 1"
}
}
}
}
- 对于
name eq 'table 1'
的情况,一个简单的 keyword
分词器和一个 lowercase
过滤器应该可以做到
查询:
{
"query": {
"term": {
"text.exact_case_insensitive_match": {
"value": "table 1"
}
}
}
}
关于query_string
,这稍微改变了解决方案,因为我指望term
不分析输入文本并准确匹配它使用索引中的一项。
但这可以是 "simulated" 和 query_string
if the appropriate analyzer
is specified for it。
解决方案将是一组如下查询(始终使用该分析器,仅更改字段名称):
{
"query": {
"query_string": {
"query": "text.starts_with:(\"table 1\")",
"analyzer": "lowercase_keyword"
}
}
}
我们有一个符合 OData 的 API,它将其部分全文搜索需求委托给 Elasticsearch 集群。
由于 OData 表达式可能会变得非常复杂,我们决定将它们简单地转换为等效的 Lucene 查询语法并将其提供给 query_string
查询。
我们确实支持一些与文本相关的 OData 过滤器表达式,例如:
startswith(field,'bla')
endswith(field,'bla')
substringof('bla',field)
name eq 'bla'
我们匹配的字段可以是 analyzed
、not_analyzed
或两者(即通过多字段)。
搜索到的文本可以是单个标记(例如 table
),也可以是其中的一部分(例如 tab
),或者是多个标记(例如 table 1.
、table 10
等)。
搜索必须不区分大小写。
以下是我们需要支持的一些行为示例:
startswith(name,'table 1')
必须匹配“Table 1”、“table 100” , "Table 1.5", "table 112层endswith(name,'table 1')
必须匹配 "Room 1, Table 1", "Subtable 1", "table 1", "Jeff table 1"substringof('table 1',name)
必须匹配 "Big Table 1 back", "table 1", "Table 1", "Small Table12"name eq 'table 1'
必须匹配 "Table 1", "TABLE 1", "table 1"
所以基本上,我们接受用户输入(即传递到 startswith
/endswith
的第二个参数的内容,分别是 substringof
的第一个参数,分别是eq
) 的右侧值并尝试完全匹配它,无论标记完全匹配还是部分匹配。
现在,我们正在摆脱下面突出显示的笨拙解决方案,它工作得很好,但远非理想。
在我们的 query_string
中,我们使用 Regular Expression syntax 匹配 not_analyzed
字段。由于该字段是 not_analyzed
并且搜索必须不区分大小写,因此我们在准备正则表达式以输入查询的同时进行自己的标记化,以便得出类似的结果,即这等同于 OData filter endswith(name,'table 8')
(=> 匹配所有 name
以 "table 8" 结尾的文档)
"query": {
"query_string": {
"query": "name.raw:/.*(T|t)(A|a)(B|b)(L|l)(E|e) 8/",
"lowercase_expanded_terms": false,
"analyze_wildcard": true
}
}
因此,尽管这个解决方案工作得很好并且性能也不算太差(结果出人意料),但我们还是想以不同的方式来做,并利用分析器的全部功能来转移所有这些负担都在索引时间而不是搜索时间。然而,由于重新索引我们所有的数据需要数周时间,我们想首先调查是否有令牌过滤器和分析器的良好组合可以帮助我们实现上面列举的相同搜索要求。
我的想法是,理想的解决方案将包含 shingles(即几个标记在一起)和 edge-nGram(即在标记的开头或结尾匹配)的一些明智组合。不过,我不确定的是,是否有可能让它们一起工作以匹配多个令牌,其中一个令牌可能没有被用户完全输入)。例如,如果索引名称字段是 "Big Table 123",我需要 substringof('table 1',name)
来匹配它,所以 "table" 是一个完全匹配的标记,而“1”只是下一个标记的前缀.
在此先感谢您分享您的脑细胞。
更新 1:测试 Andrei 的解决方案后
=> 完全匹配 (eq
) 和 startswith
完美匹配。
一个。 endswith
故障
搜索 substringof('table 112', name)
得到 107 个文档。搜索更具体的案例,例如 endswith(name, 'table 112')
会产生 1525 个文档,而它应该会产生更少的文档(后缀匹配应该是子字符串匹配的子集)。更深入地检查我发现了一些不匹配,例如 "Social Club, Table 12"(不包含“112”)或 "Order 312"(既不包含 "table" 也不包含“112”)。我猜这是因为它们以“12”结尾,这是标记“112”的有效克,因此匹配。
乙。 substringof
故障
搜索 substringof('table',name)
匹配 "Party table"、"Alex on big table" 但不匹配 "Table 1"、"table 112" 等。搜索 substringof('tabl',name)
不匹配任何东西
更新 2
这有点隐含,但我忘了明确提及该解决方案必须与 query_string
查询一起使用,主要是因为 OData 表达式(无论它们多么复杂)将保持不变被翻译成他们的 Lucene 等价物。我知道我们正在用 Lucene 的查询语法来权衡 Elasticsearch Query DSL 的强大功能,后者的功能和表现力稍逊一筹,但这是我们无法真正改变的。不过,我们非常接近!
更新 3(2019 年 6 月 25 日):
ES 7.2 引入了一种名为 search_as_you_type
的新数据类型,它本身就允许这种行为。阅读更多:https://www.elastic.co/guide/en/elasticsearch/reference/7.2/search-as-you-type.html
这是一个有趣的用例。这是我的看法:
{
"settings": {
"analysis": {
"analyzer": {
"my_ngram_analyzer": {
"tokenizer": "my_ngram_tokenizer",
"filter": ["lowercase"]
},
"my_edge_ngram_analyzer": {
"tokenizer": "my_edge_ngram_tokenizer",
"filter": ["lowercase"]
},
"my_reverse_edge_ngram_analyzer": {
"tokenizer": "keyword",
"filter" : ["lowercase","reverse","substring","reverse"]
},
"lowercase_keyword": {
"type": "custom",
"filter": ["lowercase"],
"tokenizer": "keyword"
}
},
"tokenizer": {
"my_ngram_tokenizer": {
"type": "nGram",
"min_gram": "2",
"max_gram": "25"
},
"my_edge_ngram_tokenizer": {
"type": "edgeNGram",
"min_gram": "2",
"max_gram": "25"
}
},
"filter": {
"substring": {
"type": "edgeNGram",
"min_gram": 2,
"max_gram": 25
}
}
}
},
"mappings": {
"test_type": {
"properties": {
"text": {
"type": "string",
"analyzer": "my_ngram_analyzer",
"fields": {
"starts_with": {
"type": "string",
"analyzer": "my_edge_ngram_analyzer"
},
"ends_with": {
"type": "string",
"analyzer": "my_reverse_edge_ngram_analyzer"
},
"exact_case_insensitive_match": {
"type": "string",
"analyzer": "lowercase_keyword"
}
}
}
}
}
}
}
my_ngram_analyzer
用于将每个文本分成小块,小块的大小取决于您的用例。出于测试目的,我选择了 25 个字符。lowercase
是因为你说不区分大小写。基本上,这是用于substringof('table 1',name)
的分词器。查询很简单:
{
"query": {
"term": {
"text": {
"value": "table 1"
}
}
}
}
my_edge_ngram_analyzer
用于从头开始拆分文本,这专门用于startswith(name,'table 1')
用例。同样,查询很简单:
{
"query": {
"term": {
"text.starts_with": {
"value": "table 1"
}
}
}
}
- 我发现这是最棘手的部分 -
endswith(name,'table 1')
的部分。为此,我定义了my_reverse_edge_ngram_analyzer
,它使用keyword
分词器和lowercase
以及前后跟有reverse
filter 的edgeNGram
过滤器。这个 tokenizer 基本上做的是在 edgeNGrams 中拆分文本,但边缘是文本的结尾,而不是开始(就像常规edgeNGram
一样)。 查询:
{
"query": {
"term": {
"text.ends_with": {
"value": "table 1"
}
}
}
}
- 对于
name eq 'table 1'
的情况,一个简单的keyword
分词器和一个lowercase
过滤器应该可以做到 查询:
{
"query": {
"term": {
"text.exact_case_insensitive_match": {
"value": "table 1"
}
}
}
}
关于query_string
,这稍微改变了解决方案,因为我指望term
不分析输入文本并准确匹配它使用索引中的一项。
但这可以是 "simulated" 和 query_string
if the appropriate analyzer
is specified for it。
解决方案将是一组如下查询(始终使用该分析器,仅更改字段名称):
{
"query": {
"query_string": {
"query": "text.starts_with:(\"table 1\")",
"analyzer": "lowercase_keyword"
}
}
}