티스토리 뷰
MongoDB Vector Search 한계 극복하기: Exponential Decay 기반 최신성 점수와 Score Fusion 적용 경험
ebson 2026. 2. 14. 08:33MongoDB $exp를 활용한 DB 레벨 최신성 점수 계산과 가중치 튜닝 경험
벡터 검색 기반 RAG 챗봇을 운영하다 보면 “의미적으로 가장 유사한 문서”와 “시간적으로 가장 최신인 문서”가 항상 일치하지 않는다는 문제를 만나게 됩니다. 실제 운영 환경에서 “최신 OpenAI 업데이트 알려줘”와 같은 질문에 대해 가장 최근 문서가 아닌 직전 문서가 반환되는 현상을 반복적으로 경험했습니다.
MongoDB Atlas Vector Search의 $vectorSearch는 유사도 기준으로 상위 결과를 반환합니다. 코사인 유사도 기반으로 limit 만큼 결과를 잘라내는 구조이기 때문에, 최신 문서와 직전 문서의 유사도 차이가 0.01 정도로 근소할 경우 이전 문서가 상위에 위치할 수 있습니다. 이후 애플리케이션 레벨에서 최신성 보정을 수행하더라도, 이미 limit에서 제외된 문서는 복원할 수 없습니다. 이는 Atlas Vector Search의 동작 방식상 자연스러운 결과입니다.
이 문제를 해결하기 위해서는 벡터 유사도와 시간 기반 점수를 함께 고려하는 구조가 필요합니다. MongoDB는 이를 위해 $rankFusion(8.0+)과 $scoreFusion(8.2 Public Preview)을 제공합니다. $rankFusion은 RRF(Reciprocal Rank Fusion) 알고리즘을 기반으로 여러 검색 결과를 순위 중심으로 결합하며, 기본 파라미터 k=60을 사용합니다. RRF 공식은 다음과 같습니다.
RRF(d) = \sum \frac{1}{k + rank(d)}
이 공식은 SIGIR 2009 논문에서 제안된 방식과 동일합니다. $scoreFusion은 여러 파이프라인의 점수를 정규화(min-max scaling)한 뒤 가중 평균으로 결합합니다.
다만 MongoDB Atlas 8.0.x 환경에서는 $scoreFusion을 사용할 수 없었고, $vectorSearch를 입력으로 하는 일부 조합 역시 버전 제약이 있었습니다. 그래서 DB 레벨에서 최신성 점수를 직접 계산한 뒤, 애플리케이션 레벨에서 RRF를 재현하는 하이브리드 구조를 선택했습니다.
벡터 검색과 최신성 점수의 결합 구조
설계는 비교적 단순합니다.
첫 번째 소스는 $vectorSearch 결과에 최신성 점수를 추가하여 정렬까지 완료한 결과입니다. 두 번째 소스는 published_at 기준 내림차순 직접 쿼리입니다. 두 결과를 Java 레벨에서 RRF로 결합합니다.
이 구조의 목적은 명확합니다. 벡터 검색의 limit에서 잘려나갈 수 있는 최신 문서를 별도 경로로 보완하기 위함입니다. 동일 문서가 양쪽 결과에 등장하면 RRF 점수가 합산되어 상위에 위치하게 됩니다. 이는 MongoDB $rankFusion의 동작 원리와 동일합니다.
Decay 함수 선택: Exponential vs Hyperbolic vs Gaussian
기존 애플리케이션 레벨 최신성 보정에서는 Hyperbolic Decay 함수 1 / (1 + t/T)를 사용하고 있었습니다. DB 레벨로 점수 계산을 이전하면서 세 가지 함수를 다시 검토했습니다.
Exponential Decay는 e^(-λt) 형태입니다. MongoDB는 $exp 연산자를 통해 자연지수 계산을 지원합니다(4.2+). $dateDiff 연산자(5.0+)를 이용하면 두 날짜 간의 차이를 정수로 계산할 수 있습니다. 이 둘을 조합하면 Aggregation Pipeline 내부에서 최신성 점수를 직접 계산할 수 있습니다.
Hyperbolic Decay는 감소 곡선이 완만합니다. 시간이 많이 경과해도 점수가 상대적으로 높게 유지됩니다. Gaussian Decay는 특정 시점 주변에 점수가 집중되는 형태로, 문서의 생성 시점이 다양하게 분포하는 일반적인 RAG 환경에서는 다소 급격히 감소하는 특성이 있습니다.
λ 값을 1/365로 설정하고 두 함수를 비교하면 다음과 같습니다.
- 0일: 1.000
- 7일: 0.981
- 30일: 0.921 (Exponential) / 0.924 (Hyperbolic)
- 90일: 0.781 / 0.802
- 180일: 0.610 / 0.670
- 365일: 0.368 / 0.500
e^{-1} ≈ 0.368이므로 1년 경과 시점이 직관적인 기준점이 됩니다. 최근 730일 구간에서는 두 함수의 차이가 거의 없지만, 90일 이후 구간에서는 차이가 점차 벌어집니다. “최신 업데이트”와 같은 질의에서는 오래된 문서가 명확히 낮은 점수를 갖는 것이 의도에 부합한다고 판단했습니다. 또한 Exponential Decay는 출력 범위가 자연스럽게 01 사이에 위치하므로, 코사인 유사도와 스케일이 유사하다는 점도 고려했습니다.
MongoDB Aggregation Pipeline에서의 구현
최신성 점수는 $dateDiff로 published_at과 $$NOW의 일(day) 단위 차이를 구한 뒤, $exp로 지수 감쇠를 적용하는 방식입니다. published_at이 null인 경우에는 $cond와 $ifNull을 사용해 기본값 0.5를 부여했습니다. 0.5는 λ=1/365 기준으로 약 253일 경과한 문서와 유사한 수준입니다.
미래 날짜가 입력될 경우 $dateDiff 결과는 음수가 됩니다. 이 경우 $exp 결과가 1을 초과할 수 있습니다. 필요하다면 $max를 사용해 0 이상으로 클램핑할 수 있습니다. MongoDB 공식 문서에 따르면 $dateDiff는 음수 반환이 가능하며, $exp는 입력값에 대해 제한을 두지 않습니다. 따라서 이러한 동작은 연산자 정의에 부합합니다.
Score Fusion 파이프라인은 다음과 같은 흐름으로 구성했습니다.
$vectorSearch → $addFields(vectorScore) → $match(minScore) → $addFields(recencyScore) → $addFields(combinedScore) → $sort → $limit
combinedScore는 두 점수의 가중 합입니다. $scoreFusion과 동일한 원리이지만, 점수가 이미 0~1 범위에 위치하므로 별도의 정규화는 적용하지 않았습니다.
Spring Data MongoDB의 Aggregation Framework를 사용할 수도 있으나, Atlas 전용 stage인 $vectorSearch를 포함하는 경우에는 raw Document 기반 구성이 더 유연했습니다. 이는 Spring Data 공식 문서에서 명시된 확장 방식과 일치합니다.
쿼리 의도에 따른 가중치 조정
모든 쿼리에 동일 가중치를 적용하는 것은 적절하지 않다고 판단했습니다. 일반적인 의미 검색 질의에서는 벡터 유사도가 중심이 되어야 합니다. 반면 “latest”, “최근”, “최신”과 같은 표현이 포함된 질의에서는 최신성의 비중을 높이는 것이 자연스럽습니다.
운영에서는 일반 쿼리의 경우 vectorWeight 0.85, recencyWeight 0.15를 사용했고, 최신성 의도 감지 시에는 0.5/0.5로 조정했습니다. 이는 최신성을 완전히 우선하지 않으면서도 시간적 맥락을 충분히 반영하기 위한 균형점으로 선택한 값입니다.
DB 레벨 처리의 성능 특성
$dateDiff와 $exp는 단일 표현식 계산입니다. 해당 연산은 $vectorSearch 이후, 제한된 수의 문서에 대해 적용되므로 파이프라인 전체 비용에 큰 영향을 주지는 않았습니다. 또한 정렬과 제한을 DB 내부에서 완료하므로, 애플리케이션으로 전송되는 데이터 양이 감소하는 효과가 있었습니다.
특히 numCandidates를 늘려 후보군을 확장하더라도, 최종 limit 결과만 전송하므로 네트워크 오버헤드는 일정하게 유지할 수 있었습니다.
RRF를 통한 최종 통합
최종 단계에서는 두 소스를 RRF로 결합했습니다. k=60을 사용하면 상위 순위와 하위 순위 간 점수 차이가 완만하게 유지됩니다. 예를 들어 rank 1은 약 0.0164, rank 60은 약 0.0083입니다. 상위 결과에 과도하게 점수가 집중되지 않는 특성이 있습니다.
이 방식은 MongoDB $rankFusion의 기본 동작과 동일한 수식 구조를 따릅니다. 차이점은 DB가 아닌 애플리케이션 레벨에서 구현했다는 점입니다.
정리하며
이번 작업을 통해 느낀 점은, 검색 품질 문제는 단일 알고리즘의 선택 문제가 아니라 점수 계산이 이루어지는 위치의 문제일 수 있다는 것입니다. 애플리케이션 레벨에서 아무리 정교한 보정을 하더라도, DB에서 이미 제외된 문서는 되살릴 수 없습니다.
Exponential Decay를 선택한 이유는 수학적 특성뿐 아니라, MongoDB $exp와 $dateDiff로 DB 레벨에서 직접 계산할 수 있다는 실용적 근거도 있었습니다. 공식 문서에 정의된 연산자 범위 내에서 구현 가능한 방식이었기 때문에 유지보수 측면에서도 안정적이라고 판단했습니다.
MongoDB 8.2 이상의 $scoreFusion과 $rankFusion을 네이티브로 활용할 수 있는 환경이라면, 현재 구조를 단순화할 수 있을 것으로 보입니다. 그 전까지는 이 하이브리드 접근이 8.0 환경에서 현실적인 대안이라고 생각합니다.
개인적으로는 이번 경험이 “점수를 어디에서 계산할 것인가”라는 아키텍처적 질문을 다시 생각해보는 계기가 되었습니다. 검색 품질은 함수 하나로 결정되는 것이 아니라, 파이프라인 전체의 설계와 긴밀히 연결되어 있다는 점을 다시 확인하게 되었습니다.
'TECH AND AI' 카테고리의 다른 글
- Total
- Today
- Yesterday
- 동시성처리
- DB 인덱스 성능
- 백엔드 아키텍처
- Cache Avalanche
- Double-Checked Locking
- mybatis
- 캐시 성능 비교
- 트랜잭션 관리
- TTL 설계
- Enum 기반 싱글톤
- Cache Penetration
- DB 트랜잭션
- Hot Key 문제
- 캐시와 인덱스
- 캐시 장애
- InterruptedException
- Redis 캐시 전략
- Redis 성능 개선
- 백엔드 성능 설계
- Initialization-on-Demand Holder Idiom
- 스레드 생명주기
- Cache Aside
- 백엔드 성능
- spring batch 5
- Eager Initialization
- Java Performance
- Redis vs DB
- 백엔드 성능 튜닝
- Spring Batch
- 트래픽 처리
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

