Reciprocal rank fusion
출처: 문서
본문
Reciprocal rank fusion
2.19에 도입됨 {: .label .label-purple }
Reciprocal rank fusion(RRF)은 문서의 관련성 점수 대신 각 결과 리스트에서의 위치를 기준으로 여러 쿼리 절의 결과를 결합해요. 여러 결과 리스트의 상위권에 나타나는 문서는 하나의 리스트에서만 1위를 차지한 문서보다 더 높은 결합 점수를 받아요.
RRF는 순위만 사용하기 때문에 개별 쿼리 절이 산출하는 관련성 점수가 서로 비교 가능할 필요가 전혀 없어요. BM25 점수 12.4와 벡터 유사도 0.87은 서로 다른 스케일에서 서로 다른 양을 측정하지만, 순위 1은 두 리스트 모두에서 최상위 결과를 가리켜요. 따라서 키워드 관련성과 의미적 관련성에 각각 어떤 가중치를 둘지 아직 정하지 못했다면 RRF가 하이브리드 검색의 합리적인 출발점이 돼요.
OpenSearch는 문서 $$d$$의 RRF 점수를 다음과 같이 계산해요:
$$score(d) = \sum_{q \in Q} \frac{1}{k + rank_q(d)}$$
이 수식에서 사용하는 변수는 다음과 같아요:
- $$Q$$는
hybrid쿼리에 포함된 쿼리 절의 집합이에요. - $$rank_q(d)$$는 쿼리 절 $$q$$의 결과에서 $$d$$의 위치로,
1부터 시작해요. $$d$$가 나타나지 않는 쿼리 절은 아무런 기여도 하지 않아요. - $$k$$는 순위 상수로,
rank_constant파라미터로 설정해요.
결합 방식 선택
하이브리드 검색은 서브쿼리 결과를 결합하는 두 가지 search phase results processor를 제공해요. 어느 쪽을 설정할지는 다음 표를 보고 결정하세요.
| Score ranker processor(순위 기반) | Normalization processor(점수 기반) | |
|---|---|---|
| 결합 입력 | 문서 순위 | 문서 관련성 점수 |
| 기법 | rrf |
arithmetic_mean, geometric_mean, harmonic_mean(min_max, l2, z_score 정규화 이후에 적용) |
| 점수 크기 | 버려짐. 다음 결과를 근소한 차이로 앞서는 문서와 큰 차이로 앞서는 문서가 동일하게 취급됨 | 보존됨. 두 문서 사이의 점수 차이가 결합 점수에 영향을 줌 |
| 튜닝 파라미터 | rank_constant와 선택 사항인 weights |
정규화 기법, 결합 기법, weights, 선택 사항인 점수 경계 |
| 도입 버전 | 2.19 | 2.10 |
쿼리 절들의 점수 분포를 먼저 측정하지 않고도 동작하는 설정을 원한다면 RRF부터 시작하세요. 다음과 같은 경우에는 보통 RRF가 더 나은 선택이에요:
- 쿼리 절들이 직접 비교할 수 없는 스케일의 점수를 산출하는 경우예요. 예를 들어 멀티모달 파이프라인에서 텍스트 관련성은 넓은 값 범위를, 시각적 유사도는 좁은 값 범위를 가지는 경우가 있어요.
- 과학 코퍼스나 로그 데이터에서 흔히 그렇듯 데이터에 이상치가 있거나 점수 분산이 큰 경우예요. min-max와 L2 정규화는 극단값에 민감하지만, 순위 집계(rank aggregation)는 하나의 이상치가 결합 순위를 왜곡하는 것을 막아 줘요.
- 전자상거래 카탈로그의 클릭·구매 데이터처럼 관련성 신호가 희소한 경우예요. 위치 기반 순위 매기기는 의미적 관련성이나 메타데이터 관련성이 강한 니치 아이템의 위치를 보존해요.
- 데이터가 끊임없이 변하는 경우예요. 정규화는 현재 결과 집합의 점수 분포에 의존하기 때문에 스트리밍 데이터는 재보정이 필요한데, RRF에는 그런 재보정이 필요하지 않아요.
- 여러 쿼리 절에서 고르게 좋은 순위를 기록한 문서에 보상을 주고 싶은 경우예요. L2 정규화에는 둘 이상의 결과 리스트에 나타나는 문서를 우선시하는 메커니즘이 없어요.
점수 차이에 최종 순위에 반드시 보존해야 할 정보가 담겨 있다면 normalization processor를 사용하세요. 예를 들어 한 쿼리 절이 강한 매치 하나와 그 뒤를 따르는 약한 매치들을 반환하고, 최종 순위가 그 차이를 반영해야 하는 경우가 있어요. 세 가지 정규화 기법은 이러한 점수 차이를 서로 다르게 보존해요. 자세한 내용은 정규화 기법을 참조하세요.
RRF의 트레이드오프는 관련성이 조금 낮아진다는 점이에요. 6개의 BEIR 데이터셋에 대한 벤치마크에서 RRF는 점수 기반 하이브리드 파이프라인보다 NDCG@10이 평균 3.86% 낮았고, 검색 지연 시간과 코디네이터 노드 CPU 사용률은 비슷한 수준이었어요. 전체 결과는 Introducing reciprocal rank fusion for hybrid search를 참조하세요. 두 프로세서를 여러분의 데이터와 judgment list로 비교하려면 다양한 파라미터 값에 대해 둘 모두를 평가해 주는 Search Relevance Workbench를 사용하세요.
score-ranker-processor 요청 필드의 전체 목록은 Score ranker processor를 참조하세요.
예제: 키워드 결과와 벡터 결과 융합
다음 예제는 제품 인덱스를 만들고, 키워드 쿼리 절과 벡터 쿼리 절을 각각 따로 실행해서 각자의 순위를 보여 준 다음, RRF로 이 둘을 융합해요.
이 예제는 2차원 벡터를 사용하면서 각 문서에 벡터를 직접 지정하기 때문에 계산 과정을 직접 손으로 검증할 수 있어요. 프로덕션 인덱스에서는 머신 러닝 모델로 임베딩을 생성하세요. 자세한 내용은 임베딩 자동 생성을 참조하세요. {: .note}
인덱스는 순위가 재현 가능하도록 단일 샤드를 사용해요. 샤드 수가 RRF에 어떤 영향을 주는지에 대한 자세한 내용은 샤드 수가 RRF 결과에 미치는 영향을 참조하세요. {: .note}
1단계: 인덱스 생성
키워드 매칭용 텍스트 필드와 의미적 매칭용 벡터 필드를 가진 인덱스를 생성해요:
PUT /products
{
"settings": {
"index.knn": true,
"number_of_shards": 1
},
"mappings": {
"properties": {
"item_name": {
"type": "text"
},
"item_vector": {
"type": "knn_vector",
"dimension": 2,
"space_type": "l2",
"method": {
"name": "hnsw",
"engine": "lucene"
}
}
}
}
}
{% include copy-curl.html %}
2단계: 문서 색인
다섯 개의 제품을 색인해요:
POST /_bulk?refresh=true
{ "index": { "_index": "products", "_id": "1" } }
{ "item_name": "kids running shoes", "item_vector": [0.2, 0.8] }
{ "index": { "_index": "products", "_id": "2" } }
{ "item_name": "mens lightweight running shoes for road racing", "item_vector": [0.9, 0.1] }
{ "index": { "_index": "products", "_id": "3" } }
{ "item_name": "trail runners", "item_vector": [0.8, 0.2] }
{ "index": { "_index": "products", "_id": "4" } }
{ "item_name": "athletic socks", "item_vector": [0.5, 0.5] }
{ "index": { "_index": "products", "_id": "5" } }
{ "item_name": "winter parka", "item_vector": [0.0, 1.0] }
{% include copy-curl.html %}
3단계: 각 쿼리 절 따로 실행
키워드 쿼리 절을 단독으로 실행해요:
GET /products/_search
{
"_source": ["item_name"],
"query": {
"match": {
"item_name": "running shoes"
}
}
}
{% include copy-curl.html %}
두 개의 문서가 두 용어를 모두 포함하고 있어요. 문서 1은 item_name 필드가 더 짧아서 더 높은 BM25 점수가 나오기 때문에 1위예요:
| 순위 | 문서 ID | item_name |
점수 |
|---|---|---|---|
| 1 | 1 |
kids running shoes | 0.81676 |
| 2 | 2 |
mens lightweight running shoes for road racing | 0.53566 |
벡터 쿼리 절을 단독으로 실행해요:
GET /products/_search
{
"_source": ["item_name"],
"query": {
"knn": {
"item_vector": {
"vector": [0.9, 0.1],
"k": 3
}
}
}
}
{% include copy-curl.html %}
k가 3이기 때문에 쿼리 절은 세 개의 문서를 반환해요:
| 순위 | 문서 ID | item_name |
점수 |
|---|---|---|---|
| 1 | 2 |
mens lightweight running shoes for road racing | 1.0 |
| 2 | 3 |
trail runners | 0.98039 |
| 3 | 4 |
athletic socks | 0.75758 |
문서 2는 두 쿼리 절 모두가 반환하는 유일한 문서지만, 어느 쪽에서도 1위가 아니에요.
4단계: 검색 파이프라인 생성
rrf 결합 기법을 사용하는 score-ranker-processor가 들어 있는 검색 파이프라인을 생성해요. 이 예제에서는 결과 점수를 읽기 쉽게 만들려고 rank_constant를 1로 설정해요. 기본값은 60이에요:
PUT /_search/pipeline/rrf-pipeline
{
"description": "Post processor for hybrid RRF search",
"phase_results_processors": [
{
"score-ranker-processor": {
"combination": {
"technique": "rrf",
"rank_constant": 1
}
}
}
]
}
{% include copy-curl.html %}
5단계: hybrid 쿼리 실행
두 쿼리 절을 hybrid 쿼리로 결합하고 검색 파이프라인을 적용해요:
GET /products/_search?search_pipeline=rrf-pipeline
{
"_source": {
"excludes": ["item_vector"]
},
"query": {
"hybrid": {
"queries": [
{
"match": {
"item_name": "running shoes"
}
},
{
"knn": {
"item_vector": {
"vector": [0.9, 0.1],
"k": 3
}
}
}
]
}
}
}
{% include copy-curl.html %}
응답에는 융합된 순위가 담겨 있어요:
{
"took": 5,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 4,
"relation": "eq"
},
"max_score": 0.8333334,
"hits": [
{
"_index": "products",
"_id": "2",
"_score": 0.8333334,
"_source": {
"item_name": "mens lightweight running shoes for road racing"
}
},
{
"_index": "products",
"_id": "1",
"_score": 0.5,
"_source": {
"item_name": "kids running shoes"
}
},
{
"_index": "products",
"_id": "3",
"_score": 0.33333334,
"_source": {
"item_name": "trail runners"
}
},
{
"_index": "products",
"_id": "4",
"_score": 0.25,
"_source": {
"item_name": "athletic socks"
}
}
]
}
}
각 _score는 해당 문서의 역순위(reciprocal rank)들의 합이에요:
| 문서 ID | 키워드 순위 | 벡터 순위 | RRF 계산 | _score |
|---|---|---|---|---|
2 |
2 | 1 | 1 / (1 + 2) + 1 / (1 + 1) | 0.8333334 |
1 |
1 | -- | 1 / (1 + 1) | 0.5 |
3 |
-- | 2 | 1 / (1 + 2) | 0.33333334 |
4 |
-- | 3 | 1 / (1 + 3) | 0.25 |
문서 2는 키워드 관련성으로는 2위이고 문서 3보다 벡터 점수 차이도 작지만, 두 쿼리 절 모두에서 반환되는 유일한 문서이기 때문에 1위를 차지해요. 문서 5는 어느 쿼리 절에서도 반환되지 않았기 때문에 응답에 나타나지 않아요. RRF는 서브쿼리 결과들의 합집합에만 순위를 매겨요.
융합 깊이 제어
RRF가 여러 결과 리스트에 나타나는 문서에 보상을 줄 수 있으려면, 그 문서가 각 리스트에서 융합 단계까지 도달하는 부분 안에 포함되어 있어야 해요. 이 부분의 범위를 제한하는 설정은 두 가지예요:
knn쿼리 절의k값은 샤드당 해당 절이 반환하는 문서 수를 제한하므로, 벡터 순위를 받는 문서 수도 함께 제한해요. 앞선 예제에서k가3이었기 때문에 문서1과5에는 벡터 순위가 없었어요.size는 샤드당 각 쿼리 절이 기여하는 결과 수를 제한해요. 기본적으로 모든 쿼리 절은 융합되기 전에size개 결과로 잘려요.
예상치 못한 결과를 가장 자주 만들어 내는 것은 size 제한이에요. 앞선 쿼리를 size를 1로 설정해서 다시 실행하면 문서 2가 아니라 문서 1이 반환돼요:
GET /products/_search?search_pipeline=rrf-pipeline
{
"size": 1,
"_source": {
"excludes": ["item_vector"]
},
"query": {
"hybrid": {
"queries": [
{
"match": {
"item_name": "running shoes"
}
},
{
"knn": {
"item_vector": {
"vector": [0.9, 0.1],
"k": 3
}
}
}
]
}
}
}
{% include copy-curl.html %}
각 쿼리 절은 첫 번째 결과만 기여하므로, 키워드 절은 문서 1을, 벡터 절은 문서 2를 제공해요. 두 문서 중 어느 것도 잘린 리스트 양쪽에 모두 나타나지 않으며, 둘 다 1 / (1 + 1) = 0.5점을 받고, 동점은 문서 1 쪽으로 해결돼요. size: 10에서 문서 2를 1위로 평가했던 두 절 사이의 합의는 더 이상 융합 단계까지 도달하지 못해요.
융합 깊이를 페이지 크기와 분리하려면 hybrid 쿼리에 pagination_depth를 설정하세요. 이 값은 size와 무관하게 샤드당 각 쿼리 절이 기여하는 결과 수를 정의해요. 다음 쿼리는 결과를 하나만 반환하지만 각 절의 상위 10개 결과를 융합하기 때문에, 문서 2가 다시 최상위 히트로 돌아와요:
GET /products/_search?search_pipeline=rrf-pipeline
{
"size": 1,
"_source": {
"excludes": ["item_vector"]
},
"query": {
"hybrid": {
"pagination_depth": 10,
"queries": [
{
"match": {
"item_name": "running shoes"
}
},
{
"knn": {
"item_vector": {
"vector": [0.9, 0.1],
"k": 3
}
}
}
]
}
}
}
{% include copy-curl.html %}
애플리케이션이 적은 수의 결과를 요청한다면, 애플리케이션이 사용하는 가장 큰 size 값보다 크게 pagination_depth를 설정하세요. 값을 크게 하면 융합된 순위의 품질이 좋아지지만, OpenSearch가 샤드당 더 많은 결과를 보존하고 처리해야 해요. 자세한 내용은 하이브리드 쿼리 결과 페이징을 참조하세요.
RRF 점수 해석
RRF _score는 역순위들의 합이므로, 문서가 쿼리 절 결과에서 차지했던 위치만 반영할 뿐 기반이 되는 쿼리 절들의 관련성 점수와는 아무런 관계가 없어요. 문서가 받을 수 있는 최고 점수는 쿼리 절 가중치의 합을 (rank_constant + 1)로 나눈 값이고, 모든 쿼리 절에서 1위를 하면 얻
샤드 개수가 RRF 결과에 미치는 영향
OpenSearch는 각 쿼리 절의 샤드 수준 결과를 병합한 다음 순위를 매겨요. 그런데 이 병합에 들어가는 입력 두 가지는 샤드 단위로 계산되기 때문에, 같은 데이터에 같은 쿼리를 날려도 샤드 개수가 다른 인덱스에서는 서로 다른 RRF 점수가 나올 수 있어요:
- BM25 통계는 샤드별로 계산돼요. 그래서 키워드 쿼리 절은 각 문서가 어느 샤드에 들어 있느냐에 따라 문서를 다르게 정렬할 수 있어요.
knn쿼리 절의k값은 샤드별로 적용돼요. 그래서 샤드가 세 개인 인덱스는 벡터 쿼리 절에서 샤드가 하나뿐인 인덱스보다 최대 세 배까지 많은 문서를 돌려줄 수 있어요. 추가로 나온 문서마다 순위를 받고 융합에 들어가요.
앞선 예제를 샤드가 세 개인 인덱스의 같은 다섯 문서에 실행하면 결과가 네 개가 아니라 다섯 개가 나오고, 문서 1과 2의 키워드 순위는 서로 뒤바뀌어요:
| 문서 ID | 키워드 순위 | 벡터 순위 | RRF 계산식 | _score |
|---|---|---|---|---|
2 |
1 | 1 | 1 / (1 + 1) + 1 / (1 + 1) | 1.0 |
1 |
2 | 4 | 1 / (1 + 2) + 1 / (1 + 4) | 0.53333336 |
3 |
-- | 2 | 1 / (1 + 2) | 0.33333334 |
4 |
-- | 3 | 1 / (1 + 3) | 0.25 |
5 |
-- | 5 | 1 / (1 + 5) | 0.16666667 |
BM25의 영향은 샤드당 문서 수가 늘어날수록 약해져요. 샤드별 용어 통계가 인덱스 전체 값에 수렴하기 때문이에요. 반면 k의 영향은 사라지지 않아요. 벡터 쿼리 절은 샤드마다 최대 k개의 문서를 항상 기여하기 때문이에요. rank_constant나 weights를 튜닝할 때는 프로덕션 인덱스와 샤드 개수가 같은 인덱스를 대상으로 실험하세요.
rank constant 튜닝하기
rank_constant 파라미터는 문서 순위가 내려갈 때 그 문서의 기여도가 얼마나 빨리 감소하는지를 조절해요. 유효한 값은 [1, 10000] 범위예요. 기본값은 60이에요.
rank constant를 작게 잡으면 연속된 순위 사이 간격이 커져서, 각 쿼리 절의 상위 몇 개 결과에 영향력이 몰려요. rank constant를 크게 잡으면 그 간격이 줄어들어서, 순위가 낮은 결과도 상위 결과와 거의 비슷한 가중치를 갖게 돼요. 기본값인 rank_constant 60으로 앞선 예제를 실행하면 순서는 그대로인데 점수만 압축돼요. 1 / 61, 1 / 62, 1 / 63의 차이가 1 / 2, 1 / 3, 1 / 4의 차이보다 훨씬 작기 때문이에요:
| 문서 ID | RRF 계산식 | _score |
|---|---|---|
2 |
1 / (60 + 2) + 1 / (60 + 1) | 0.032522473 |
1 |
1 / (60 + 1) | 0.016393442 |
3 |
1 / (60 + 2) | 0.016129032 |
4 |
1 / (60 + 3) | 0.015873017 |
내 데이터에 맞는 값을 고르려면, 여러 rank constant를 판단 목록(judgment list)으로 평가하는 하이브리드 검색 실험을 돌려 보세요. 자세한 내용은 하이브리드 검색 최적화를 참고하세요.
쿼리 절에 가중치 주기
기본 설정에서는 RRF가 모든 쿼리 절에 똑같은 가중치를 줘요. 어떤 절을 다른 절보다 우대하려면 combination.parameters.weights를 지정하세요. 가중치 개수는 쿼리 절 개수와 일치해야 하고, 가중치의 합은 1.0이어야 해요. 각 가중치는 해당 절의 reciprocal rank(역수 순위)에 곱해져요:
$$score(d) = \sum_{q \in Q} w_q \cdot \frac{1}{k + rank_q(d)}$$
다음 파이프라인은 키워드 쿼리 절에 30%, 벡터 쿼리 절에 70%의 가중치를 할당해요:
PUT /_search/pipeline/rrf-pipeline
{
"description": "Post processor for hybrid RRF search",
"phase_results_processors": [
{
"score-ranker-processor": {
"combination": {
"technique": "rrf",
"rank_constant": 1,
"parameters": {
"weights": [
0.3,
0.7
]
}
}
}
}
]
}
{% include copy-curl.html %}
이 가중치로 5단계의 쿼리를 다시 실행하면 문서 1이 마지막 순위로 밀려나요. 이 문서의 기여가 전부 키워드 절에서 나오는데, 키워드 절의 가중치가 이제 낮아졌기 때문이에요:
| 문서 ID | RRF 계산식 | _score |
|---|---|---|
2 |
0.3 × 1 / (1 + 2) + 0.7 × 1 / (1 + 1) | 0.45 |
3 |
0.7 × 1 / (1 + 2) | 0.23333333 |
4 |
0.7 × 1 / (1 + 3) | 0.175 |
1 |
0.3 × 1 / (1 + 1) | 0.15 |
가중치를 도입하면 가중치 없는 RRF가 피해 주던 튜닝 노력이 다시 필요해져요. 그러니 관련성에 미치는 영향을 측정할 수 있는 경우에만 가중치를 바꾸세요.