警告:此功能处于技术预览阶段,可能会在未来版本中更改或删除。语法可能会在正式发布之前发生变化。Elastic 将努力修复任何问题,但技术预览中的功能不受官方正式发布功能的支持 SLA 约束。
倒数排序融合 (reciprocal rank fusion - RRF) 是一种将具有不同相关性指标的多个结果集组合成单个结果集的方法。RRF 无需调整,并且不同的相关性指标不必相互关联即可获得高质量的结果。
注意:在今天的文章中,RFF 有别于之前版本。这个描述是从 8.14.0 开始的。在这个版本之前,请参阅 “Elasticsearch:倒数排序融合 - Reciprocal rank fusion (RRF)”。8.13.0 版本的描述在地址可以看到。在它里面它使用 sub_searches 而不是 rertievers。
RRF 使用以下公式来确定对每个文档进行排名的分数:
1. score = 0.0
2. for q in queries:
3. if d in result(q):
4. score += 1.0 / ( k + rank( result(q), d ) )
5. return score
7. # where
8. # k is a ranking constant
9. # q is a query in the set of queries
10. # d is a document in the result set of q
11. # result(q) is the result set of q
12. # rank( result(q), d ) is d's rank within the result(q) starting from 1
一个例子是:
倒数排序融合 API
你可以将 RRF 用作 search 的一部分,使用来自使用 RRF 检索器的子检索器(child retrievers)组合的独立顶级文档集(结果集)来组合和排名文档。排名至少需要两个子检索器。
RRF 检索器是一个可选对象,定义为搜索请求的检索器参数(retriever parameter)的一部分。 RRF 检索器对象包含以下参数:
参数 | 描述 |
---|---|
retrievers | (必需,检索器对象数组) 子检索器列表,用于指定哪些返回的顶级文档集将应用 RRF 公式。每个子检索器作为 RRF 公式的一部分具有相等的权重。需要两个或更多个子检索器。 |
rank_constant | (可选,整数) 此值决定每个查询中单个结果集中的文档对最终排名结果集的影响程度。值越高,表示排名较低的文档影响力越大。此值必须大于或等于 1。默认为 60。 |
window_size | (可选,整数) 此值决定每个查询的单个结果集的大小。较高的值将提高结果相关性,但会降低性能。最终排名的结果集将缩减为搜索请求的大小。window_size 必须大于或等于 size 且大于或等于 1。默认为 size 参数。 |
使用 RRF 的示例请求:
1. GET example-index/_search
2. {
3. "retriever": {
4. "rrf": {
5. "retrievers": [
6. {
7. "standard": {
8. "query": {
9. "term": {
10. "text": "shoes"
11. }
12. }
13. }
14. },
15. {
16. "knn": {
17. "field": "vector",
18. "query_vector": [
19. 1.25,
20. 2,
21. 3.5
22. ],
23. "k": 50,
24. "num_candidates": 100
25. }
26. }
27. ],
28. "window_size": 50,
29. "rank_constant": 20
30. }
31. }
32. }
在上面的例子中,我们独立执行 knn 和标准检索器。然后我们使用 rrf 检索器来合并结果。
- 首先,我们执行 knn 检索器指定的kNN搜索以获取其全局前 50 个结果。
- 其次,我们执行 standard 检索器指定的查询以获取其全局前 50 个结果。
- 然后,在协调节点上,我们将 kNN 搜索热门文档与查询热门文档相结合,并使用来自 rrf 检索器的参数根据 RRF 公式对它们进行排序,以使用默认 size 为 10 获得组合的顶级文档。
注意,如果 knn 搜索中的 k 大于 window_size,则结果将被截断为 window_size。如果 k 小于 window_size,则结果为 k 大小。
倒数排序融合支持的特征
rrf 检索器支持:
rrf 检索器目前不支持:
在使用 rrf 检索器进行搜索时使用不受支持的功能会导致异常。
使用多个 standard 检索器的倒数排序融合
rrf 检索器提供了一种组合和排名多个标准检索器的方法。主要用例是组合来自传统 BM25 查询和 ELSER 查询的顶级文档,以提高相关性。
使用 RRF 和多个 standard 检索器的示例请求:
1. GET example-index/_search
2. {
3. "retriever": {
4. "rrf": {
5. "retrievers": [
6. {
7. "standard": {
8. "query": {
9. "term": {
10. "text": "blue shoes sale"
11. }
12. }
13. }
14. },
15. {
16. "standard": {
17. "query": {
18. "text_expansion": {
19. "ml.tokens": {
20. "model_id": "my_elser_model",
21. "model_text": "What blue shoes are on sale?"
22. }
23. }
24. }
25. }
26. }
27. ],
28. "window_size": 50,
29. "rank_constant": 20
30. }
31. }
32. }
在上面的例子中,我们分别独立执行两个 standard 检索器。然后我们使用 rrf 检索器来合并结果。
- 首先,我们使用标准 BM25 评分算法运行 standard 检索器,指定 “blue shoes sales” 的术语查询。
- 接下来,我们使用 ELSER 评分算法运行 standard 检索器,指定 “What blue shoes are on sale?”的文本扩展查询。
- rrf 检索器允许我们将完全独立的评分算法生成的两个顶级文档集以相等的权重组合在一起。
这不仅消除了使用线性组合确定适当权重的需要,而且 RRF 还显示出比单独查询更高的相关性。
使用子搜索的倒数排学融合
使用子搜索的 RRF 不再受支持。请改用 retriever API。请参阅使用多个标准检索器的示例。
相互排名融合完整示例
我们首先创建一个带有文本字段、向量字段和整数字段的索引映射,并索引多个文档。对于此示例,我们将使用只有一个维度的向量,以便更容易解释排名。
1. PUT example-index
2. {
3. "mappings": {
4. "properties": {
5. "text": {
6. "type": "text"
7. },
8. "vector": {
9. "type": "dense_vector",
10. "dims": 1,
11. "index": true,
12. "similarity": "l2_norm"
13. },
14. "integer": {
15. "type": "integer"
16. }
17. }
18. }
19. }
21. PUT example-index/_doc/1
22. {
23. "text" : "rrf",
24. "vector" : [5],
25. "integer": 1
26. }
28. PUT example-index/_doc/2
29. {
30. "text" : "rrf rrf",
31. "vector" : [4],
32. "integer": 2
33. }
35. PUT example-index/_doc/3
36. {
37. "text" : "rrf rrf rrf",
38. "vector" : [3],
39. "integer": 1
40. }
42. PUT example-index/_doc/4
43. {
44. "text" : "rrf rrf rrf rrf",
45. "integer": 2
46. }
48. PUT example-index/_doc/5
49. {
50. "vector" : [0],
51. "integer": 1
52. }
54. POST example-index/_refresh
我们现在使用 rrf 检索器执行搜索,其中 standard 检索器指定 BM25 查询,knn 检索器指定 kNN 搜索,以及术语聚合
1. GET example-index/_search
2. {
3. "retriever": {
4. "rrf": {
5. "retrievers": [
6. {
7. "standard": {
8. "query": {
9. "term": {
10. "text": "rrf"
11. }
12. }
13. }
14. },
15. {
16. "knn": {
17. "field": "vector",
18. "query_vector": [
19. 3
20. ],
21. "k": 5,
22. "num_candidates": 5
23. }
24. }
25. ],
26. "window_size": 5,
27. "rank_constant": 1
28. }
29. },
30. "size": 3,
31. "aggs": {
32. "int_count": {
33. "terms": {
34. "field": "integer"
35. }
36. }
37. }
38. }
我们收到了带有排名 hits 和术语聚合结果的响应。请注意,_score 为空,我们改用 _rank 来显示排名靠前的文档。
1. {
2. "took": 14,
3. "timed_out": false,
4. "_shards": {
5. "total": 1,
6. "successful": 1,
7. "skipped": 0,
8. "failed": 0
9. },
10. "hits": {
11. "total": {
12. "value": 5,
13. "relation": "eq"
14. },
15. "max_score": null,
16. "hits": [
17. {
18. "_index": "example-index",
19. "_id": "1",
20. "_score": null,
21. "_rank": 1,
22. "_source": {
23. "text": "rrf",
24. "vector": [
25. 5
26. ],
27. "integer": 1
28. }
29. },
30. {
31. "_index": "example-index",
32. "_id": "3",
33. "_score": null,
34. "_rank": 2,
35. "_source": {
36. "text": "rrf rrf rrf",
37. "vector": [
38. 3
39. ],
40. "integer": 1
41. }
42. },
43. {
44. "_index": "example-index",
45. "_id": "2",
46. "_score": null,
47. "_rank": 3,
48. "_source": {
49. "text": "rrf rrf",
50. "vector": [
51. 4
52. ],
53. "integer": 2
54. }
55. }
56. ]
57. },
58. "aggregations": {
59. "int_count": {
60. "doc_count_error_upper_bound": 0,
61. "sum_other_doc_count": 0,
62. "buckets": [
63. {
64. "key": 1,
65. "doc_count": 3
66. },
67. {
68. "key": 2,
69. "doc_count": 2
70. }
71. ]
72. }
73. }
74. }
让我们分析一下这些命中结果的排名方式。我们首先分别运行指定查询的标准检索器和指定 kNN 搜索的 knn 检索器,以收集它们各自的命中结果。
首先,我们查看 standard 检索器中查询的命中结果。
1. GET example-index/_search
2. {
3. "query": {
4. "term": {
5. "text": {
6. "value": "rrf"
7. }
8. }
9. }
10. }
1. "hits" : [
2. {
3. "_index" : "example-index",
4. "_id" : "4",
5. "_score" : 0.16152832,
6. "_source" : {
7. "integer" : 2,
8. "text" : "rrf rrf rrf rrf"
9. }
10. },
11. {
12. "_index" : "example-index",
13. "_id" : "3",
14. "_score" : 0.15876243,
15. "_source" : {
16. "integer" : 1,
17. "vector" : [3],
18. "text" : "rrf rrf rrf"
19. }
20. },
21. {
22. "_index" : "example-index",
23. "_id" : "2",
24. "_score" : 0.15350538,
25. "_source" : {
26. "integer" : 2,
27. "vector" : [4],
28. "text" : "rrf rrf"
29. }
30. },
31. {
32. "_index" : "example-index",
33. "_id" : "1",
34. "_score" : 0.13963442,
35. "_source" : {
36. "integer" : 1,
37. "vector" : [5],
38. "text" : "rrf"
39. }
40. }
41. ]
- rank 1, _id 4
- rank 2, _id 3
- rank 3, _id 2
- rank 4, _id 1
请注意,我们的第一个结果没有向量字段的值。现在,我们来看看 knn 检索器的 kNN 搜索的结果。
1. GET example-index/_search
2. {
3. "knn": {
4. "field": "vector",
5. "query_vector": [
6. 3
7. ],
8. "k": 5,
9. "num_candidates": 5
10. }
11. }
1. "hits" : [
2. {
3. "_index" : "example-index",
4. "_id" : "3",
5. "_score" : 1.0,
6. "_source" : {
7. "integer" : 1,
8. "vector" : [3],
9. "text" : "rrf rrf rrf"
10. }
11. },
12. {
13. "_index" : "example-index",
14. "_id" : "2",
15. "_score" : 0.5,
16. "_source" : {
17. "integer" : 2,
18. "vector" : [4],
19. "text" : "rrf rrf"
20. }
21. },
22. {
23. "_index" : "example-index",
24. "_id" : "1",
25. "_score" : 0.2,
26. "_source" : {
27. "integer" : 1,
28. "vector" : [5],
29. "text" : "rrf"
30. }
31. },
32. {
33. "_index" : "example-index",
34. "_id" : "5",
35. "_score" : 0.1,
36. "_source" : {
37. "integer" : 1,
38. "vector" : [0]
39. }
40. }
41. ]
- rank 1, _id 3
- rank 2, _id 2
- rank 3, _id 1
- rank 4, _id 5
我们现在可以获得两个单独排名的结果集,并使用 rrf 检索器的参数对它们应用 RRF 公式以获得最终排名。
1. # doc | query | knn | score
2. _id: 1 = 1.0/(1+4) + 1.0/(1+3) = 0.4500
3. _id: 2 = 1.0/(1+3) + 1.0/(1+2) = 0.5833
4. _id: 3 = 1.0/(1+2) + 1.0/(1+1) = 0.8333
5. _id: 4 = 1.0/(1+1) = 0.5000
6. _id: 5 = 1.0/(1+4) = 0.2000
我们根据 RRF 公式对文档进行排序,window_size 为 5,截断 RRF 结果集中 size 为 3 的底部 2 个文档。最终结果为 _id:3 作为 _rank:1,_id:2 作为 _rank:2,_id:4 作为 _rank:3。此排名与原始 RRF 搜索的结果集匹配,符合预期。
RRF 中的分页
使用 rrf 时,你可以使用 from 参数对结果进行分页。由于最终排名完全取决于原始查询排名,因此为了确保分页时的一致性,我们必须确保虽然 from 发生变化,但我们已经看到的顺序保持不变。为此,我们使用固定的 window_size 作为可以进行分页的整个可用结果集。这本质上意味着,如果:
- from + size ≤ window_size :我们可以从最终的 rrf 排名结果集中返回 results[from: from+size] 文档
- from + size > window_size :我们将得到 0 个结果,因为请求超出了可用的 window_size 大小的结果集。
这里要注意的一件重要事情是,由于 window_size 是我们将从各个查询组件中看到的所有结果,因此分页保证了一致性,即,当且仅当 window_size 保持不变时,不会跳过或重复多个页面中的文档。如果 window_size 发生变化,那么结果的顺序也可能会发生变化,即使是相同的排名。
为了说明上述所有内容,让我们考虑以下简化的示例,其中我们有两个查询,queryA 和 queryB 以及它们的排名文档:
1. | queryA | queryB |
2. _id: | 1 | 5 |
3. _id: | 2 | 4 |
4. _id: | 3 | 3 |
5. _id: | 4 | 1 |
6. _id: | | 2 |
对于 window_size=5,我们将看到来自 queryA 和 queryB 的所有文档。假设 rank_constant=1,rrf 分数将是:
1. # doc | queryA | queryB | score
2. _id: 1 = 1.0/(1+1) + 1.0/(1+4) = 0.7
3. _id: 2 = 1.0/(1+2) + 1.0/(1+5) = 0.5
4. _id: 3 = 1.0/(1+3) + 1.0/(1+3) = 0.5
5. _id: 4 = 1.0/(1+4) + 1.0/(1+2) = 0.533
6. _id: 5 = 0 + 1.0/(1+1) = 0.5
因此,最终排名结果集将是 [1, 4, 2, 3, 5],我们将对其进行分页,因为 window_size == len(results)。在这种情况下,我们将有:
- from=0, size=2 将返回文档 [1, 4],排名为 [1, 2]
- from=2, size=2 将返回文档 [2, 3],排名为 [3, 4]
- from=4, size=2 将返回文档 [5],排名为 [5]
- from=6, size=2 将返回一个空结果集,因为没有更多结果可以迭代
现在,如果我们的 window_size=2,我们只能分别看到查询 queryA 和 queryB 的 [1, 2] 和 [5, 4] 文档。计算一下,我们会发现结果现在会略有不同,因为我们不知道这两个查询中位置 [3: end] 的文档。
1. # doc | queryA | queryB | score
2. _id: 1 = 1.0/(1+1) + 0 = 0.5
3. _id: 2 = 1.0/(1+2) + 0 = 0.33
4. _id: 4 = 0 + 1.0/(1+2) = 0.33
5. _id: 5 = 0 + 1.0/(1+1) = 0.5
最终排序的结果集将是 [1, 5, 2, 4],并且我们将能够对顶部的 window_size 结果进行分页,即 [1, 5]。因此,对于与上述相同的参数,我们现在将有:
- from=0, size=2 将返回 [1, 5],排名为 [1, 2]
- from=2, size=2 将返回一个空结果集,因为它超出了可用的 window_size 结果范围。