검색 시스템을 만들다 보면 결국 같은 자리로 돌아옵니다. BM25는 “옥수수”라고 쳤을 때 “옥수수”가 든 문서를 정확히 찾지만, “노란 알갱이 곡물”로는 아무것도 못 찾습니다. 벡터 검색은 그 반대입니다. “노란 알갱이 곡물”에는 잘 답하지만, 상품 코드 CORN-2024-A 를 정확히 찾아내는 데는 형편없습니다.
이 글은 그 두 축을 Elasticsearch 위에서 어떻게 구현하고 섞는지, 특히 BM25 공식이 왜 그렇게 생겼는지, 768차원을 왜 줄이며 줄이면 무엇이 깨지는지, RRF가 왜 점수가 아니라 순위를 섞는지를 정리합니다.
1. BM25 — 이름부터
BM25가 무슨 뜻인가
BM은 Best Matching의 약자이고, 25는 25번째 시도라는 뜻입니다. 농담이 아니라 문자 그대로입니다.
1970~80년대에 스티븐 로버트슨(Stephen Robertson)과 카렌 스팍 존스(Karen Spärck Jones)가 확률적 검색 프레임워크(Probabilistic Relevance Framework) 를 세우면서 가중치 함수를 여러 변형으로 실험했고, 그 후보들을 BM1, BM11, BM15… 하는 식으로 번호를 붙여 불렀습니다. 그중 실험에서 가장 잘 동작한 조합이 BM25였고 그대로 이름이 굳었습니다. 앞에 자주 붙는 Okapi 는 이 함수가 처음 구현된 런던 시티대학교의 검색 시스템 이름입니다.
즉 BM25는 어떤 이론의 축약어가 아니라 실험 번호입니다. 이 사실이 중요한 이유는, 이 공식의 상수들이 이론에서 유도된 값이 아니라 경험적으로 잘 맞아서 남은 값이라는 뜻이기 때문입니다. 뒤에 나올 k1 = 1.2, b = 0.75 도 마찬가지고, RRF의 60 도 마찬가지입니다.
TF-IDF로는 왜 부족했나
TF-IDF의 점수는 대략 TF × IDF입니다.
- TF(Term Frequency) — 이 문서에 이 단어가 몇 번 나왔나. 많이 나올수록 관련 있다.
- IDF(Inverse Document Frequency) — 이 단어가 전체 문서 중 몇 개에 나왔나. 드문 단어일수록 변별력이 크다.
방향은 맞지만 두 군데가 어긋납니다.
첫째, TF가 선형입니다. “카프카”가 100번 나온 문서가 1번 나온 문서보다 100배 관련 있을까요? 아닙니다. 1번 → 2번의 차이는 크지만, 50번 → 51번은 거의 의미가 없습니다. 관련성은 어느 지점부터 포화(saturation)되어야 합니다.
둘째, 문서 길이를 보정하지 않습니다. 10만 자짜리 문서는 아무 단어나 자연스럽게 여러 번 나옵니다. 짧은 문서에서 3번 나온 것이 긴 문서에서 3번 나온 것보다 훨씬 강한 신호입니다.
BM25는 이 두 가지를 정면으로 다룹니다.
공식과 각 항의 역할
\[\text{score}(D, Q) = \sum_{q_i \in Q} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}\]수식이 부담스러우면 이렇게 읽으면 됩니다.
점수 = Σ (단어의 희소성) × (포화되는 등장 횟수 ÷ 길이 보정)
↑ IDF ↑ TF 포화 ↑ 문서 길이
IDF — 흔한 단어 깎아내리기
Lucene이 실제로 쓰는 식입니다.
\[\text{IDF}(q_i) = \ln\left(1 + \frac{N - n(q_i) + 0.5}{n(q_i) + 0.5}\right)\]N 은 전체 문서 수, n(qi) 는 그 단어를 포함한 문서 수입니다. 안쪽 1 + 가 붙어 있는 이유가 있습니다. 원래 확률 모델 식에는 이게 없어서, 단어가 전체 문서의 절반 이상에 나오면 IDF가 음수가 됩니다. 검색어를 포함했는데 점수가 깎이는 기괴한 일이 생기죠. Lucene은 1 + 를 넣어 IDF가 항상 0 이상이 되게 막았습니다.
TF 포화 — k1
분모의 f + k1·(…) 구조 때문에 등장 횟수가 늘어도 점수가 무한히 오르지 않고 점근선에 붙습니다.
k1 = 1.2 일 때 (길이 보정 무시)
등장 횟수 1 2 3 5 10 50 100
정규화 TF 0.45 0.62 0.71 0.81 0.89 0.98 0.99
└─ 1→2 는 크게 오르고, 50→100 은 거의 안 오른다
k1 은 이 곡선이 얼마나 천천히 포화되는지를 정하는 손잡이입니다.
k1 = 0→ 등장 횟수를 완전히 무시. 단어가 있냐 없냐만 봅니다(불리언 검색에 가까움)k1이 크면 → 포화가 늦어져 등장 횟수가 더 오래 영향을 미침- Elasticsearch 기본값은 1.2
짧은 문서(상품명, 제목)는 k1 을 낮추는 게 낫습니다. 제목에 단어가 세 번 나온 건 대개 관련성이 아니라 잡음이니까요.
문서 길이 보정 — b
(1 - b + b · |D|/avgdl) 항이 분모에 곱해집니다. |D| 는 이 문서의 길이, avgdl 은 해당 필드의 평균 문서 길이입니다.
b = 0→ 괄호가1이 되어 길이를 전혀 보지 않음b = 1→ 길이 비율이 그대로 반영되어 긴 문서를 최대로 깎음- Elasticsearch 기본값은 0.75
평균보다 긴 문서는 분모가 커져 점수가 깎이고, 짧은 문서는 분모가 작아져 같은 등장 횟수라도 점수가 높습니다.
Lucene 구현에서 실제로 다른 점
문서에 적힌 공식과 Lucene 코드가 두 군데 다릅니다. 둘 다 실무에 영향이 있습니다.
1) (k1 + 1) 이 없습니다.
Lucene의 BM25Similarity 는 분자의 (k1 + 1) 을 곱하지 않습니다. 이 값은 모든 문서·모든 단어에 똑같이 곱해지는 상수라서 순위를 바꾸지 않기 때문입니다. 그래서 논문 공식으로 손계산한 점수와 _explain 이 뱉는 점수가 정확히 (k1+1) 배, 즉 2.2배 차이 납니다. 순위는 같습니다.
2) 문서 길이가 1바이트로 손실 압축되어 있습니다.
이게 더 중요합니다. Lucene은 필드 길이(norms)를 1바이트에 욱여넣습니다. SmallFloat.intToByte4 로 인코딩하는데, 값이 커질수록 간격이 성기게 벌어지는 부동소수점 방식입니다.
실제 길이 → 저장/복원되는 길이
1 1
2 2
...
40 40
41 40 ← 여기서부터 뭉개지기 시작
44 40
45 48
...
120 112
즉 길이 40인 문서와 44인 문서는 BM25 입장에서 완전히 같은 문서입니다. 짧은 필드에서 길이 몇 글자 차이로 순위를 미세 조정하려는 시도는 이 지점에서 무의미해집니다. norms 는 인덱싱 시점에 결정되므로, 이 값이 싫으면 index_options 나 별도 필드로 우회해야 합니다.
Elasticsearch 설정
BM25는 5.0부터 기본 유사도입니다. 파라미터는 인덱스 생성 시점에 정해야 하고, 바꾸려면 리인덱싱이 필요합니다.
PUT /products
{
"settings": {
"index": {
"similarity": {
"title_bm25": {
"type": "BM25",
"k1": 0.6,
"b": 0.3
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"similarity": "title_bm25"
},
"description": {
"type": "text"
}
}
}
}
제목처럼 짧은 필드에는 k1 과 b 를 함께 낮추는 조합이 흔히 잘 듣습니다. 반복이 신호가 아니고(k1↓), 길이 차이도 크지 않기 때문(b↓)입니다.
튜닝 전에 반드시 _explain 으로 점수 분해를 봐야 합니다.
GET /products/_explain/1
{
"query": { "match": { "title": "옥수수" } }
}
idf, tf, dl, avgdl 이 각각 얼마로 계산됐는지 그대로 나옵니다. 감으로 파라미터를 돌리는 것보다 이쪽이 훨씬 빠릅니다.
BM25의 한계 — 어휘 불일치
BM25는 결국 문자열 일치입니다. 아무리 잘 튜닝해도 이건 못 넘습니다.
| 질의 | 문서 | BM25 |
|---|---|---|
| “노트북” | “랩탑 추천” | 0점 |
| “배송 얼마나 걸려요” | “출고 소요 기간 안내” | 거의 0점 |
| “car” | “automobile” | 0점 |
동의어 사전(synonym 필터)으로 어느 정도는 메꿀 수 있지만, 사전은 사람이 유지해야 하고 문맥을 모릅니다. 이 지점에서 벡터 검색이 필요해집니다.
2. 벡터 검색과 dense_vector
임베딩 모델은 텍스트를 고정 길이 실수 벡터로 바꿉니다. 의미가 비슷한 문장은 벡터 공간에서 가까이 놓입니다. “노트북”과 “랩탑”은 글자가 하나도 안 겹치지만 벡터는 거의 같은 곳을 가리킵니다.
PUT /products
{
"mappings": {
"properties": {
"title": { "type": "text" },
"title_vector": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine",
"index_options": {
"type": "int8_hnsw",
"m": 16,
"ef_construction": 100
}
}
}
}
}
similarity 선택
| 값 | 쓰는 때 |
|---|---|
cosine |
크기를 무시하고 방향만 비교. 가장 무난한 기본값 |
dot_product |
벡터를 미리 정규화해 두었다면 이쪽. 코사인과 결과가 같은데 정규화 연산을 매번 안 해서 더 빠름 |
l2_norm |
유클리드 거리. 좌표·임베딩 외 수치 벡터 |
max_inner_product |
정규화되지 않은 벡터의 내적. 크기 자체가 의미를 갖는 모델 |
실무에서는 색인 전에 벡터를 L2 정규화하고 dot_product 를 쓰는 조합이 가장 자주 쓰입니다.
3. kNN 알고리즘 — 정확한 이웃 vs 근사 이웃
exact kNN (brute force)
모든 문서와 거리를 계산합니다. 정확도 100%, 대신 O(N).
POST /products/_search
{
"query": {
"script_score": {
"query": { "term": { "category": "laptop" } },
"script": {
"source": "cosineSimilarity(params.q, 'title_vector') + 1.0",
"params": { "q": [0.12, -0.33, "..."] }
}
}
}
}
+ 1.0 은 코사인 유사도의 범위가 [-1, 1] 인데 Elasticsearch 점수는 음수를 허용하지 않아서 붙이는 관용구입니다.
문서가 수만 건 이하이거나, 필터로 후보가 이미 크게 좁혀진 경우에는 이쪽이 오히려 낫습니다. 뒤에 나올 리콜 측정의 정답지로도 씁니다.
HNSW — 근사 최근접 이웃
문서가 수백만 건이 되면 전수 비교는 불가능합니다. Elasticsearch는 HNSW(Hierarchical Navigable Small World) 를 씁니다.
핵심 아이디어는 고속도로 + 국도의 계층 구조입니다.
Layer 2 ●───────────────────● ← 노드 적음, 링크가 멀리 뻗음 (고속도로)
│ │
Layer 1 ●────────●──────────●────● ← 중간
│ │ │ │
Layer 0 ●─●─●─●─●─●─●─●─●─●─●─●─●─● ← 모든 문서, 가까운 것끼리 (국도)
탐색은 최상위 층에서 시작해 질의 벡터에 가장 가까운 노드로 이동하고, 더 가까워질 수 없으면 한 층 내려갑니다. 이걸 반복해 0층에 도달하면 그 근방을 정밀 탐색합니다. 처음에는 크게 점프해 대략적인 위치를 잡고, 내려갈수록 세밀하게 좁히는 구조입니다. 탐색 비용이 O(N)이 아니라 대략 O(log N)이 되는 이유입니다.
파라미터 세 개
| 파라미터 | 시점 | 기본값 | 의미 |
|---|---|---|---|
m |
색인 | 16 | 노드 하나가 가질 최대 링크 수. 크면 그래프가 촘촘해져 리콜↑, 메모리·색인시간↑ |
ef_construction |
색인 | 100 | 그래프를 만들 때 살펴볼 후보 수. 크면 그래프 품질↑, 색인시간↑ |
num_candidates |
검색 | — | 각 샤드에서 유지할 후보 수. 크면 리콜↑, 검색 지연↑ |
m 과 ef_construction 은 인덱스 매핑에 박히므로 나중에 못 바꿉니다. num_candidates 만 질의 시점에 조절할 수 있습니다.
POST /products/_search
{
"knn": {
"field": "title_vector",
"query_vector": [0.12, -0.33, "..."],
"k": 10,
"num_candidates": 100
}
}
num_candidates 는 샤드당 값입니다. 샤드가 5개면 실제로는 500개 후보를 탐색한 뒤 조정 노드에서 상위 10개를 고릅니다. 샤드 수를 늘리면 리콜이 자연히 올라가는 대신 전체 계산량도 늘어납니다.
필터와 kNN — pre-filter 가 기본
벡터 검색에 조건을 거는 두 방식의 차이는 결과 품질을 완전히 갈라놓습니다.
post-filter (나쁨)
kNN 으로 상위 10개 → 그중 category=laptop 인 것만 남김 → 2개만 남음
pre-filter (좋음)
category=laptop 인 문서만 대상으로 kNN → 10개 다 채움
Elasticsearch의 knn 절에 있는 filter 는 pre-filter 로 동작합니다. HNSW 그래프를 걸으면서 필터를 통과하지 못하는 노드를 건너뜁니다.
POST /products/_search
{
"knn": {
"field": "title_vector",
"query_vector": [0.12, -0.33, "..."],
"k": 10,
"num_candidates": 100,
"filter": {
"bool": {
"must": [
{ "term": { "category": "laptop" } },
{ "range": { "price": { "lte": 2000000 } } }
]
}
}
}
}
다만 필터가 너무 빡세면 HNSW가 오히려 불리해집니다. 통과 문서가 전체의 1% 남짓이면 그래프를 한참 걸어도 유효 후보를 못 채웁니다. Lucene은 이런 경우를 감지해 전수 탐색으로 자동 전환하지만, 경계에서는 지연이 튈 수 있습니다. 선택도가 아주 낮은 필터라면 애초에 script_score 로 전수 비교하는 쪽이 예측 가능합니다.
4. 768차원을 왜 줄이는가
여기가 실무에서 가장 자주 부딪히는 지점입니다.
메모리 계산부터
768차원 float32 벡터 하나의 크기는 이렇습니다.
768 dims × 4 bytes = 3,072 bytes ≈ 3 KB
여기에 HNSW 그래프 자체의 링크가 붙습니다. 0층은 노드당 최대 2m 개, 상위 층은 m 개 링크를 가지고 링크 하나가 4바이트라면 대략 이렇습니다.
그래프 링크 ≈ m × 2 × 4 bytes = 16 × 8 = 128 bytes (0층 기준)
문서 1,000만 건이면,
| 항목 | 크기 |
|---|---|
| 벡터 원본 (float32) | 약 30.7 GB |
| HNSW 그래프 | 약 1.3 GB |
| 합계 | 약 32 GB |
여기서 문제는 용량이 아니라 “어디에 있어야 하는가”입니다. HNSW 탐색은 그래프를 걸으며 링크를 따라 무작위로 벡터를 읽습니다. 이게 디스크에서 일어나면 탐색 한 번에 수십 번의 랜덤 I/O가 발생합니다. 벡터와 그래프가 파일 시스템 캐시(page cache)에 올라와 있어야 실용적인 지연 시간이 나옵니다.
즉 32GB는 “디스크 32GB”가 아니라 사실상 “RAM 32GB” 를 요구합니다. 노드 하나에 올리기 어려운 숫자입니다. 줄여야 하는 진짜 이유는 이것입니다.
줄이는 두 가지 방법 — 혼동하면 안 됩니다
“768차원을 줄인다” 는 말에 서로 다른 두 기법이 섞여 쓰입니다.
| 양자화 (Quantization) | 차원 축소 (Dimensionality Reduction) | |
|---|---|---|
| 무엇을 줄이나 | 값의 정밀도 (4바이트 → 1바이트) | 차원 수 (768 → 256) |
| 벡터 모양 | 768차원 그대로 | 256차원으로 짧아짐 |
| 누가 하나 | Elasticsearch가 자동으로 | 임베딩 모델 / 전처리 단계에서 |
| 되돌릴 수 있나 | 원본을 디스크에 보관해 재채점 가능 | 정보가 사라져 복구 불가 |
방법 A — 양자화 (Elasticsearch 내장)
각 차원의 float32 값을 더 적은 비트로 표현합니다. index_options.type 으로 지정합니다.
| type | 비트/차원 | 768차원 크기 | 압축비 |
|---|---|---|---|
hnsw |
32 (원본) | 3,072 B | 1× |
int8_hnsw |
8 | 768 B + α | 4× |
int4_hnsw |
4 | 384 B + α | 8× |
bbq_hnsw |
1 (이진) | 96 B + α | 32× |
최근 버전에서는 float dense_vector 의 기본값이 int8_hnsw 입니다. 아무것도 안 적어도 이미 4배 압축된 상태로 색인되고 있다는 뜻입니다.
여기서 중요한 장치가 재채점(rescoring) 입니다. int8/int4/BBQ 로 색인하면 원본 float 벡터도 디스크에 함께 보관됩니다. 그래서 이런 2단계가 가능합니다.
1단계: 압축 벡터로 그래프를 빠르게 걸어 후보 N개 뽑기 (메모리에서, 빠름)
2단계: 그 N개만 원본 float 벡터로 다시 정확히 계산 (디스크 읽기, 후보만)
후보를 넉넉히 뽑아(oversampling) 재채점하면 압축으로 잃은 리콜을 상당 부분 되찾습니다. BBQ처럼 32배까지 줄이는 방식이 실용적인 이유가 바로 이 재채점 덕분입니다. 최신 버전에는 rescore_vector.oversample 처럼 이 배수를 질의에서 직접 주는 옵션도 있습니다.
방법 B — 차원 축소 (768 → 256)
차원 수 자체를 줄입니다. 그리고 여기서 대부분의 사고가 납니다.
가장 흔한 실수가 “벡터 앞 256개만 잘라 쓰면 되지 않나” 입니다. 대부분의 임베딩 모델에서 이건 재앙입니다. 일반적인 모델의 768개 차원에는 순서상의 우선순위가 없습니다. 312번째 차원이 5번째 차원보다 덜 중요할 이유가 전혀 없습니다. 그냥 자르면 정보의 3분의 2를 무작위로 버리는 셈입니다.
이게 통하려면 모델이 그렇게 훈련되어 있어야 합니다.
마트료시카 표현 학습(Matryoshka Representation Learning, MRL) 은 학습 시점에 앞쪽 차원만으로도 손실이 작도록 목적 함수를 겁니다. 이름 그대로 러시아 인형처럼, 768차원 안에 512차원짜리가, 그 안에 256차원짜리가 들어 있는 구조입니다. MRL로 훈련된 모델이라면 앞에서 잘라 쓰는 게 공식 지원되는 사용법이고, 아니라면 하면 안 됩니다. 쓰려는 모델 카드에 마트료시카 지원이 적혀 있는지부터 확인해야 합니다.
MRL이 아니라면 PCA 같은 방법으로 별도 투영 행렬을 학습해야 하는데, 이때는 색인과 질의에 똑같은 행렬을 적용해야 하고 데이터 분포가 바뀌면 다시 학습해야 합니다. 운영 부담이 꽤 큽니다.
왜 줄이면 검색 품질이 떨어지는가
세 가지 층위에서 각각 다르게 무너집니다.
1) 거리 계산이 틀립니다 (양자화의 경우)
양자화는 연속값을 구간에 몰아넣습니다. int8이면 각 차원이 256단계로만 표현됩니다. 벡터 하나당 오차는 작지만, 768개 차원에 걸쳐 누적되면 두 벡터 사이 거리 계산에 무시 못 할 오차가 생깁니다.
진짜 1등과 3등의 실제 거리 차이가 아주 작았다면, 양자화 오차만으로 순서가 뒤집힙니다. 상위권이 촘촘할수록 더 잘 뒤집힙니다. 재채점이 필요한 이유입니다.
2) 그래프 탐색 경로 자체가 어긋납니다
이게 더 고약합니다. 거리 계산 오차는 최종 순위만 흔드는 게 아니라 HNSW가 어느 방향으로 걸어갈지를 결정하는 데 쓰입니다. 중간에 한 번 잘못된 이웃으로 방향을 잡으면, 정답이 있는 영역으로 아예 못 들어갑니다.
정확한 거리로 탐색: A → C → F → [정답 영역]
오차 있는 거리로: A → B → D → [엉뚱한 영역] ← 여기서 재채점해도 소용없음
재채점은 “뽑힌 후보들의 순위”만 고칠 수 있지, “아예 후보에 안 든 문서”는 못 살립니다. 그래서 압축률을 높일수록 num_candidates 를 함께 키워 그물을 넓게 던져야 합니다.
3) 차원 축소는 구별 자체를 없앱니다
양자화는 정보를 뭉개지만 차원 축소는 정보를 버립니다. 768차원에서는 서로 다른 축으로 구별되던 두 개념이 256차원에서는 같은 좌표로 접힐 수 있습니다.
768차원: "무선 마우스"와 "무선 키보드"가 서로 다른 축에서 갈림
256차원: 두 축이 하나로 접히면서 거의 같은 벡터가 됨 → 구별 불가
이건 재채점으로도 복구 불가능합니다. 원본 벡터가 아예 없기 때문입니다. 그래서 차원 축소는 양자화보다 훨씬 신중해야 하고, 도메인 데이터로 리콜을 반드시 측정해야 합니다.
실무 선택 순서
1. 그냥 int8_hnsw 로 시작한다 (대개 기본값이라 이미 그렇다)
→ 4배 절감, 리콜 손실 거의 없음
2. 메모리가 여전히 모자라면 BBQ + 재채점
→ 32배 절감, num_candidates 를 넉넉히
3. 그래도 모자라면 그때 차원 축소를 검토
→ 반드시 MRL 지원 모델, 반드시 리콜 측정
차원 축소를 첫 번째 카드로 꺼내지 않는 게 핵심입니다. 양자화는 Elasticsearch가 알아서 해 주고 원본을 남겨 두지만, 차원 축소는 파이프라인 전체를 바꾸고 되돌릴 수 없습니다.
리콜은 이렇게 잽니다
감으로 하면 안 됩니다. 정답지를 exact kNN으로 만들고 겹침을 세면 됩니다.
# 1) 정답지: script_score 로 전수 비교 (근사 없음)
exact = search_exact(query_vector, k=10) # 문서 ID 집합
# 2) 실제 설정: HNSW + 양자화
approx = search_knn(query_vector, k=10, num_candidates=100)
# 3) recall@10
recall = len(set(exact) & set(approx)) / 10
질의를 수백 개 모아 평균을 내면 됩니다. 운영 로그에서 실제 질의를 뽑아 쓰는 게 좋습니다. num_candidates 를 2배로 올렸을 때 리콜이 얼마나 오르고 p99 지연이 얼마나 느려지는지 그래프로 그려 보면 적정선이 금방 보입니다. 보통 어느 지점 이후로는 리콜이 거의 안 오르는데 지연만 늘어납니다.
5. RRF — 왜 점수가 아니라 순위를 섞는가
BM25와 kNN을 둘 다 돌렸습니다. 이제 합쳐야 합니다.
점수를 그냥 더하면 안 되는 이유
BM25 점수와 코사인 유사도는 애초에 비교 가능한 값이 아닙니다.
| 범위 | 성질 | |
|---|---|---|
| BM25 | 0 ~ 무제한 | 질의마다 스케일이 완전히 다름. 희귀어가 들어가면 IDF가 커져 점수가 폭등 |
| 코사인 | 0 ~ 2 (ES 보정 후) | 항상 유계, 대체로 좁은 구간에 몰림 |
같은 인덱스에서도 이런 일이 벌어집니다.
질의 A ("옥수수") BM25 1등 = 8.3 벡터 1등 = 0.91
질의 B ("보급형 노트북") BM25 1등 = 31.7 벡터 1등 = 0.88
0.5 × BM25 + 0.5 × cosine 같은 가중합을 쓰면 질의 B에서는 BM25가 벡터를 완전히 압도합니다. 질의마다 min-max 정규화를 하는 방법도 있지만, 이번에는 그 질의의 최고점이 얼마였느냐에 결과가 휘둘립니다. 상위 1건만 유난히 높은 질의에서는 나머지가 전부 0 근처로 눌립니다.
RRF의 발상 — 점수를 버리고 순위만 본다
Reciprocal Rank Fusion(상호 순위 융합) 은 점수를 아예 안 씁니다. 몇 등이었는지만 봅니다.
\[\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}\]R 은 결과 목록들(BM25, kNN, …), rank_r(d) 는 목록 r 에서 문서 d 의 등수(1부터), k 는 상수입니다.
손으로 계산해 보면 바로 이해됩니다. k = 60 일 때,
| 문서 | BM25 등수 | kNN 등수 | RRF 점수 | 최종 |
|---|---|---|---|---|
| A | 1 | 50 | 1/61 + 1/110 = 0.0255 | 2위 |
| B | 3 | 2 | 1/63 + 1/62 = 0.0320 | 1위 |
| C | 2 | 대상 외 | 1/62 = 0.0161 | 3위 |
| D | 대상 외 | 1 | 1/61 = 0.0164 | 4위 사이 |
B가 1등입니다. 어느 쪽에서도 1등이 아니었지만 양쪽 모두에서 꾸준히 상위였기 때문입니다. 반면 A는 BM25 1등이지만 벡터에서 50등으로 밀려 합산에서 졌습니다.
이게 RRF의 성격을 그대로 보여 줍니다. 한 검색기에서만 튀는 문서보다, 여러 검색기가 동의하는 문서를 위로 올립니다. 앙상블의 투표와 같은 발상입니다.
k = 60 은 어디서 나왔나
Cormack, Clarke, Büttcher의 2009년 SIGIR 논문에서 제안된 값입니다. 이론적 유도가 아니라 실험에서 잘 나온 값입니다(BM25의 1.2, 0.75 와 똑같은 사연입니다).
k 가 하는 일은 상위권의 격차를 얼마나 완만하게 만드느냐입니다.
k = 0 → 1등 1.000, 2등 0.500, 3등 0.333 ← 1등이 압도적
k = 10 → 1등 0.091, 2등 0.083, 3등 0.077 ← 격차 작음
k = 60 → 1등 0.0164, 2등 0.0161, 3등 0.0159 ← 거의 평평
k 가 작으면 1등이 너무 세져 RRF를 쓰는 의미가 없어집니다. 한 검색기의 1등이 다른 검색기의 상위권 합의를 눌러 버리니까요. k = 60 정도면 1~10등 사이가 충분히 평평해져서 “여러 곳에서 상위권” 이라는 신호가 살아납니다.
k↓ → 각 검색기의 최상위를 더 신뢰k↑ → 순위 차이를 더 무시하고, 여러 목록에 등장했는지를 중시
Elasticsearch 구현
8.x의 retriever 문법으로 쓰면 이렇습니다.
POST /products/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": "가벼운 노트북",
"fields": ["title^2", "description"]
}
}
}
},
{
"knn": {
"field": "title_vector",
"query_vector": [0.12, -0.33, "..."],
"k": 50,
"num_candidates": 200
}
}
],
"rank_window_size": 100,
"rank_constant": 60
}
},
"size": 10
}
rank_window_size(기본 100) 가 실무에서 제일 자주 문제가 되는 값입니다. 각 검색기에서 몇 등까지를 융합 대상으로 볼지를 정합니다.
rank_window_size = 100 이면
BM25 결과 1~100등, kNN 결과 1~100등만 RRF 계산에 들어간다
101등부터는 아예 없는 셈 친다
그래서 rank_window_size 는 최종 size 보다 충분히 커야 합니다. 작게 잡으면 한쪽에서만 잡힌 좋은 문서가 융합 전에 잘려 나갑니다. 반대로 키우면 조정 노드에서 정렬·병합할 문서가 늘어 지연이 증가합니다. size 의 10배쯤에서 시작해 조정하는 게 무난합니다.
rank_constant(기본 60) 가 공식의 k 입니다.
분산 환경에서 실제로 일어나는 일
RRF는 순위를 매겨야 하므로 결과가 한곳에 모인 뒤에만 계산할 수 있습니다.
[샤드 1] BM25 실행 ┐
[샤드 2] BM25 실행 ├→ 조정 노드에서 병합 → BM25 전역 순위 매김 ┐
[샤드 3] BM25 실행 ┘ │
├→ RRF 합산 → 최종
[샤드 1] kNN 실행 ┐ │
[샤드 2] kNN 실행 ├→ 조정 노드에서 병합 → kNN 전역 순위 매김 ┘
[샤드 3] kNN 실행 ┘
여기서 두 가지가 따라옵니다.
첫째, 두 검색기가 실제로 다 실행됩니다. 비용이 단순 합산입니다. 하이브리드 검색이 느린 건 융합 때문이 아니라 검색을 두 번 하기 때문입니다.
둘째, 원래 점수(_score)는 사라집니다. 최종 _score 는 RRF 값이라 0.0164 같은 작은 수입니다. BM25 점수로 임계값을 걸던 로직이 있다면 전부 깨집니다. 각 검색기의 원점수를 보려면 explain 을 쓰거나 별도로 조회해야 합니다.
RRF를 쓰지 말아야 할 때
RRF는 순위만 보므로 점수 차이의 크기를 버립니다. 이게 손해일 때가 있습니다.
1등이 압도적으로 좋고 2등부터는 명백히 쓰레기인 질의(예: 상품 코드 정확 일치)에서, RRF는 그 압도적 격차를 1/61 vs 1/62 로 눌러 버립니다. 이럴 땐 차라리 정확 일치를 먼저 판정하고 분기하거나, 점수를 정규화해 가중합하는 편이 낫습니다.
6. 세 가지를 언제 쓰나
| BM25 | 벡터(kNN) | 하이브리드(RRF) | |
|---|---|---|---|
| 정확한 키워드·코드·ID | 강함 | 약함 | 강함 |
| 동의어·의역·자연어 질문 | 약함 | 강함 | 강함 |
| 희귀어·전문용어 | 강함 (IDF가 살림) | 약함 (학습 데이터에 없으면 무력) | 강함 |
| 오타 | 약함 | 보통 | 보통 |
| 색인 비용 | 낮음 | 높음 (임베딩 추론) | 높음 |
| 메모리 | 낮음 | 높음 | 높음 |
| 검색 지연 | 낮음 | 보통 | 두 배 |
| 설명 가능성 | 높음 (_explain) |
낮음 | 낮음 |
BM25는 여전히 기본값이어야 합니다. 싸고, 빠르고, 왜 그렇게 나왔는지 설명할 수 있습니다. 벡터 검색은 “어휘 불일치로 검색이 실패하는 질의가 실제 로그에 얼마나 있는가”를 확인한 뒤에 얹는 게 순서입니다.
세 번째 선택지로 학습된 희소 검색(ELSER 등, sparse_vector 타입) 도 있습니다. 모델이 질의어를 관련 어휘로 확장해 희소 벡터로 만드는 방식이라, 의미 검색의 이점을 얻으면서 역색인 구조를 유지합니다. 임베딩 모델을 직접 고르고 관리하기 부담스러운 경우 검토할 만합니다.
정리
| 질문 | 답 |
|---|---|
| BM25가 무슨 뜻인가 | Best Matching, 25번째 실험 변형. 상수들은 이론이 아니라 경험값 |
| 왜 TF를 포화시키나 | 등장 100번이 1번보다 100배 관련 있지 않기 때문. k1 이 포화 속도 |
| 왜 문서 길이를 보정하나 | 긴 문서는 아무 단어나 자주 나오기 때문. b 가 보정 강도 |
| 768차원을 왜 줄이나 | HNSW는 벡터와 그래프가 메모리에 있어야 빠르다. 1천만 건이면 32GB |
| 줄이면 왜 나빠지나 | ① 거리 오차로 순위가 뒤집히고 ② 탐색 경로가 어긋나 후보에서 아예 빠지고 ③ 차원 축소는 구별 자체를 없앰 |
| 어떻게 줄이는 게 안전한가 | 양자화(int8 → BBQ) + 재채점이 먼저. 차원 축소는 MRL 모델일 때만 |
| RRF는 왜 순위를 섞나 | BM25 점수와 코사인은 스케일이 달라 더할 수 없고, 정규화도 질의마다 불안정하기 때문 |
rank_constant가 왜 60인가 |
논문의 경험값. 작으면 1등이 너무 세져 융합의 의미가 사라짐 |
| RRF에서 뭘 조심하나 | rank_window_size 가 작으면 좋은 문서가 융합 전에 잘림. 최종 _score 가 RRF 값으로 바뀜 |
참고 문헌
- Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond (2009): https://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf
- Cormack, Clarke & Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009): https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- Malkov & Yashunin, Efficient and robust approximate nearest neighbor search using HNSW graphs: https://arxiv.org/abs/1603.09320
- Kusupati et al., Matryoshka Representation Learning: https://arxiv.org/abs/2205.13147
- Elasticsearch — kNN search: https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html
- Elasticsearch — dense_vector field type: https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html
- Elasticsearch — Reciprocal rank fusion: https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html