什么是自动补全(autocomplete)功能呢?我们举一个很常见的例子。 每当你去谷歌并开始打字时,就会出现一个下拉列表,其中列出了建议。 这些建议与查询相关并帮助用户完成查询。
Autocomplete 正如维基百科所说的:
Autocomplete 或单词完成是一个功能,应用程序预测使用的其余单词正在键入
它也知道你键入或键入前方搜索。 它通过提示用户在键入文本的可能性和替代方案来帮助他们导航或指导用户。 它减少用户在执行任何搜索操作之前需要输入的字符数量,从而增强用户的搜索体验。
可以通过使用任何数据库来实现 Autocomplete。 在这篇文章中,我们将使用 Elasticsearch 构建自动补全功能。
Elasticsearch 是免费及开发,分布式和基于JSON的搜索引擎,建立在Lucene的顶部。更多关于 Elasticsearch 的介绍,请阅读文章 “Elasticsearch 简介”。
方法
在 Elasticsearch 中,可能有多种构建自动完成功能的方法。 我们将讨论以下方法。
- Prefix query
- Edge ngram
- Completion suggester
Prefiex query
这种方法涉及使用针对自定义字段的前缀查询(prefix query)。 该字段的值可以作为 keyword 存储,因此将多个 terms(单词)存储在一起作为一个术语。 可以使用关键字分词器(keyword tokenizer)来完成这一点。 这种方法遭受了缺点:
- 由于只支持词首匹配,不能匹配正文中间的 query。
- 这种类型的查询未针对大型数据集进行优化,可能会导致延迟增加。
- 由于这是一个查询,因此不会过滤掉重复的结果。 处理此方法的一种解决方法是使用聚合查询对结果进行分组,然后过滤掉结果。 这涉及服务器端的一些处理
Edge Ngrams
有关 edge ngrams 的介绍请参阅之前的文章 “Elasticsearch: Ngrams, edge ngrams, and shingles”。
这种方法涉及在索引和搜索时使用不同的分析器。 索引文档时,可以应用带有 edge n-gram 过滤器的自定义分析器。 在搜索时,可以应用标准分析器。 这可以防止查询被拆分。
Edge N-gram tokeniser 首先将文本分解为自定义字符(空格、特殊字符等)上的单词,然后仅从字符串的开头保留 n-gram。
这种方法也适用于匹配文本中间的查询。 这种方法通常查询速度很快,但可能会导致索引速度变慢和索引存储量变大。
Completion suggester
Elasticsearch 附带一个名为 Completion Suggester 的内部解决方案。 它使用称为有限状态传感器 (Finite State Transducer - FST) 的内存数据结构。 Elasticsearch 以每个段为基础存储 FST,这意味着建议会随着更多新节点的添加而水平扩展。
实施 Completion Suggester 时要记住的一些事情
- Autosuggest 项应将 completion 类型作为其字段类型。
- 输入字段可以为单个术语具有各种规范名称或别名。
- 可以为每个文档定义权重以控制它们的排名。
- 以小写形式存储所有术语有助于不区分大小写的匹配。
- 可以启用上下文 suggesters 以支持按特定标准进行过滤或提升。
这种方法是实现自动完成功能的理想方法,但是,它也有一些缺点
- 匹配总是从文本的开头开始。 所以在 movies 数据集中搜索 america 不会产生任何结果。 一种克服方法是在空格上标记输入文本并将所有短语保留为规范名称。 这样 Captain America: Civil War:内战将被存储为:
1. Captain America: Civil War
2. America: Civil War
3. Civil War
4. War
不支持突出(highlight)显示匹配的词。
没有可用的排序机制。 对建议进行排序的唯一方法是通过权重。 当需要任何自定义排序(如字母排序或按上下文排序)时,这会产生问题。
实现
让我们在 Elasticsearch 中实现上述方法。 我们将使用 movies 数据来构建我们的示例索引。 为了便于参考,我们使用如下的命令来创建 movies 索引:
1. PUT movies
2. {
3. "settings": {
4. "index": {
5. "analysis": {
6. "filter": {},
7. "analyzer": {
8. "keyword_analyzer": {
9. "filter": [
10. "lowercase",
11. "asciifolding",
12. "trim"
13. ],
14. "char_filter": [],
15. "type": "custom",
16. "tokenizer": "keyword"
17. },
18. "edge_ngram_analyzer": {
19. "filter": [
20. "lowercase"
21. ],
22. "tokenizer": "edge_ngram_tokenizer"
23. },
24. "edge_ngram_search_analyzer": {
25. "tokenizer": "lowercase"
26. }
27. },
28. "tokenizer": {
29. "edge_ngram_tokenizer": {
30. "type": "edge_ngram",
31. "min_gram": 2,
32. "max_gram": 5,
33. "token_chars": [
34. "letter"
35. ]
36. }
37. }
38. }
39. }
40. },
41. "mappings": {
42. "properties": {
43. "name": {
44. "type": "text",
45. "fields": {
46. "keywordstring": {
47. "type": "text",
48. "analyzer": "keyword_analyzer"
49. },
50. "edgengram": {
51. "type": "text",
52. "analyzer": "edge_ngram_analyzer",
53. "search_analyzer": "edge_ngram_search_analyzer"
54. },
55. "completion": {
56. "type": "completion"
57. }
58. },
59. "analyzer": "standard"
60. }
61. }
62. }
63. }
如果我们看到映射,我们会发现 name 是一个 multi-fields 字段,其中包含多个字段,每个字段都以不同的方式进行分析。
- 使用关键字分词器分析 Fieldname.keywordstring,因此它将用于前缀查询方法
- 字段 name.edgengram 使用 Edge Ngram 分词器进行分析,因此它将用于 Edge Ngram 方法。
- Field name.completion 存储为 completion 类型,因此它将用于 Completion Suggester。
我们使用如下命令索引所有的电影:
1. POST movies/_bulk
2. { "index" : {"_id" : "1"} }
3. { "name" : "Spider-Man: Homecoming" }
4. { "index" : {"_id" : "2"} }
5. { "name" : "Ant-man and the Wasp" }
6. { "index" : {"_id" : "3"} }
7. { "name" : "Avengers: Infinity War Part 2" }
8. { "index" : {"_id" : "4"} }
9. { "name" : "Captain Marvel" }
10. { "index" : {"_id" : "5"} }
11. { "name" : "Black Panther" }
12. { "index" : {"_id" : "6"} }
13. { "name" : "Avengers: Infinity War" }
14. { "index" : {"_id" : "7"} }
15. { "name" : "Thor: Ragnarok" }
16. { "index" : {"_id" : "8"} }
17. { "name" : "Guardians of the Galaxy Vol 2" }
18. { "index" : {"_id" : "9"} }
19. { "name" : "Doctor Strange" }
20. { "index" : {"_id" : "10"} }
21. { "name" : "Captain America: Civil War" }
22. { "index" : {"_id" : "11"} }
23. { "name" : "Ant-Man" }
24. { "index" : {"_id" : "12"} }
25. { "name" : "Avengers: Age of Ultron" }
26. { "index" : {"_id" : "13"} }
27. { "name" : "Guardians of the Galaxy" }
28. { "index" : {"_id" : "14"} }
29. { "name" : "Captain America: The Winter Soldier" }
30. { "index" : {"_id" : "15"} }
31. { "name" : "Thor: The Dark World" }
32. { "index" : {"_id" : "16"} }
33. { "name" : "Iron Man 3" }
34. { "index" : {"_id" : "17"} }
35. { "name" : "Marvel’s The Avengers" }
36. { "index" : {"_id" : "18"} }
37. { "name" : "Captain America: The First Avenger" }
38. { "index" : {"_id" : "19"} }
39. { "name" : "Thor" }
40. { "index" : {"_id" : "20"} }
41. { "name" : "Iron Man 2" }
42. { "index" : {"_id" : "21"} }
43. { "name" : "The Incredible Hulk" }
44. { "index" : {"_id" : "22"} }
45. { "name" : "Iron Man" }
让我们从 prefix query 方法开始,尝试查找以 th 开头的电影。
查询将是:
1. GET movies/_search?filter_path=**.hits
2. {
3. "query": {
4. "prefix": {
5. "name.keywordstring": {
6. "value": "th"
7. }
8. }
9. }
10. }
查询的结果是:
1. {
2. "hits": {
3. "hits": [
4. {
5. "_index": "movies",
6. "_id": "7",
7. "_score": 1,
8. "_source": {
9. "name": "Thor: Ragnarok"
10. }
11. },
12. {
13. "_index": "movies",
14. "_id": "15",
15. "_score": 1,
16. "_source": {
17. "name": "Thor: The Dark World"
18. }
19. },
20. {
21. "_index": "movies",
22. "_id": "19",
23. "_score": 1,
24. "_source": {
25. "name": "Thor"
26. }
27. },
28. {
29. "_index": "movies",
30. "_id": "21",
31. "_score": 1,
32. "_source": {
33. "name": "The Incredible Hulk"
34. }
35. }
36. ]
37. }
38. }
结果是公平的,但是像 Captain America: The Winter Soldier,Guardians of the Galaxy 这样的电影被遗漏了,因为前缀查询只匹配文本的开头而不是中间。
让我们尝试寻找另一部以 am 开头的电影。
1. GET movies/_search?filter_path=**.hits
2. {
3. "query": {
4. "prefix": {
5. "name.keywordstring": {
6. "value": "am"
7. }
8. }
9. }
10. }
这里我们没有得到任何结果,尽管 Captain America 满足这个条件。 这就印证了Prefix query 不能用于正文中间匹配的一点。
让我们运行相同的搜索,但使用 Edge Ngram 方法。
1. GET movies/_search?filter_path=**.hits
2. {
3. "query": {
4. "match": {
5. "name.edgengram": "am"
6. }
7. }
8. }
上面运行的结果是:
1. {
2. "hits": {
3. "hits": [
4. {
5. "_index": "movies",
6. "_id": "10",
7. "_score": 1.5922177,
8. "_source": {
9. "name": "Captain America: Civil War"
10. }
11. },
12. {
13. "_index": "movies",
14. "_id": "14",
15. "_score": 1.3930962,
16. "_source": {
17. "name": "Captain America: The Winter Soldier"
18. }
19. },
20. {
21. "_index": "movies",
22. "_id": "18",
23. "_score": 1.3930962,
24. "_source": {
25. "name": "Captain America: The First Avenger"
26. }
27. }
28. ]
29. }
30. }
让我们再次尝试寻找 Captain America,但这次使用更大的短语 captain america the:
1. GET movies/_search?filter_path=**.hits
2. {
3. "query": {
4. "match": {
5. "name.edgengram": "captain america the"
6. }
7. }
8. }
使用 Edge N-gram 方法,我们得到以下电影:
1. {
2. "hits": {
3. "hits": [
4. {
5. "_index": "movies",
6. "_id": "21",
7. "_score": 1.0249562,
8. "_source": {
9. "name": "The Incredible Hulk"
10. }
11. },
12. {
13. "_index": "movies",
14. "_id": "17",
15. "_score": 0.9822227,
16. "_source": {
17. "name": "Marvel’s The Avengers"
18. }
19. },
20. {
21. "_index": "movies",
22. "_id": "2",
23. "_score": 0.94290996,
24. "_source": {
25. "name": "Ant-man and the Wasp"
26. }
27. },
28. {
29. "_index": "movies",
30. "_id": "13",
31. "_score": 0.94290996,
32. "_source": {
33. "name": "Guardians of the Galaxy"
34. }
35. },
36. {
37. "_index": "movies",
38. "_id": "15",
39. "_score": 0.906623,
40. "_source": {
41. "name": "Thor: The Dark World"
42. }
43. },
44. {
45. "_index": "movies",
46. "_id": "8",
47. "_score": 0.8730254,
48. "_source": {
49. "name": "Guardians of the Galaxy Vol 2"
50. }
51. },
52. {
53. "_index": "movies",
54. "_id": "14",
55. "_score": 0.7365507,
56. "_source": {
57. "name": "Captain America: The Winter Soldier"
58. }
59. },
60. {
61. "_index": "movies",
62. "_id": "18",
63. "_score": 0.7365507,
64. "_source": {
65. "name": "Captain America: The First Avenger"
66. }
67. }
68. ]
69. }
70. }
如果我们观察我们的短语,只有两个建议是有意义的。 匹配这么多术语的原因是 match 子句的功能。 匹配包括所有包含 captain OR america OR the 的文件。 由于该字段是使用 ngram 分析的,因此也会包含更多建议(如果存在)。
让我们尝试使用针对相同短语 captain america the 的 suggest 查询。 建议查询的编写方式略有不同。
1. GET movies/_search?filter_path=suggest
2. {
3. "suggest": {
4. "movie-suggest": {
5. "prefix": "captain america the",
6. "completion": {
7. "field": "name.completion"
8. }
9. }
10. }
11. }
结果我们得到以下电影:
1. {
2. "suggest": {
3. "movie-suggest": [
4. {
5. "text": "captain america the",
6. "offset": 0,
7. "length": 19,
8. "options": [
9. {
10. "text": "Captain America: The First Avenger",
11. "_index": "movies",
12. "_id": "18",
13. "_score": 1,
14. "_source": {
15. "name": "Captain America: The First Avenger"
16. }
17. },
18. {
19. "text": "Captain America: The Winter Soldier",
20. "_index": "movies",
21. "_id": "14",
22. "_score": 1,
23. "_source": {
24. "name": "Captain America: The Winter Soldier"
25. }
26. }
27. ]
28. }
29. ]
30. }
31. }
让我们尝试相同的查询,但这次使用了错别字 captain america the。
1. GET movies/_search?filter_path=suggest
2. {
3. "suggest": {
4. "movie-suggest": {
5. "prefix": "captain amrica the",
6. "completion": {
7. "field": "name.completion"
8. }
9. }
10. }
11. }
上面的电影建议没有返回结果,因为不支持模糊性。 我们可以通过以下方式更新查询以包含对模糊性的支持:
1. GET movies/_search?filter_path=suggest
2. {
3. "suggest": {
4. "movie-suggest": {
5. "prefix": "captain amrica the",
6. "completion": {
7. "field": "name.completion",
8. "fuzzy": {
9. "fuzziness": 1
10. }
11. }
12. }
13. }
14. }
以上查询返回以下结果:
1. {
2. "suggest": {
3. "movie-suggest": [
4. {
5. "text": "captain amrica the",
6. "offset": 0,
7. "length": 18,
8. "options": [
9. {
10. "text": "Captain America: The First Avenger",
11. "_index": "movies",
12. "_id": "18",
13. "_score": 10,
14. "_source": {
15. "name": "Captain America: The First Avenger"
16. }
17. },
18. {
19. "text": "Captain America: The Winter Soldier",
20. "_index": "movies",
21. "_id": "14",
22. "_score": 10,
23. "_source": {
24. "name": "Captain America: The Winter Soldier"
25. }
26. }
27. ]
28. }
29. ]
30. }
31. }
结论
可以使用多种方法在 ElasticSearch 中实现自动完成功能。 Completion Suggester 涵盖了实现功能齐全且快速的自动完成所需的大多数情况。