- Published on
RAG 리랭커 도입 - nDCG·MRR로 성능 2배
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 기반 RAG에서 품질이 흔들리는 가장 흔한 이유는 LLM이 아니라 retrieval입니다. 특히 벡터 검색(바이인코더)만으로 top-k 문서를 뽑으면, “관련은 있어 보이지만 답에 직접 쓰기 어려운 문서”가 상위에 섞이는 문제가 자주 발생합니다. 이때 리랭커(reranker)를 추가하면, 초기 후보군을 넓게 가져가되 최종 상위 문서만 더 정확히 고를 수 있어 정확도와 안정성이 동시에 올라갑니다.
이 글에서는 RAG 리랭커를 어떻게 붙이고, 무엇을 nDCG와 MRR로 측정해야 하며, “성능 2배”를 과장 없이 만들려면 어떤 실험 설계가 필요한지까지 실무 관점에서 정리합니다.
RAG에서 리랭커가 필요한 지점
일반적인 RAG 파이프라인은 다음과 같습니다.
- 쿼리 임베딩 생성
- 벡터 DB에서
top-k후보 검색(예:k=10) - 후보 문서를 컨텍스트로 넣고 LLM이 답변 생성
문제는 2번의 벡터 검색이 “대략 비슷한 것”을 잘 찾는 대신, 정답 근거로 쓰기 좋은 문서의 정렬에는 약하다는 점입니다. 이유는 간단합니다.
- 바이인코더는 쿼리와 문서를 독립적으로 임베딩한 뒤 내적/코사인으로 점수화합니다.
- 문장 단위의 미세한 조건(부정, 범위, 버전, 예외 케이스)까지 반영한 정렬에는 한계가 있습니다.
리랭커는 보통 크로스인코더 계열로, 쿼리와 문서를 함께 입력으로 받아 “이 쌍이 얼마나 관련 있는지”를 직접 점수화합니다. 비용은 늘지만, top-k를 top-n으로 다시 정렬해 상위 문서 품질을 크게 끌어올릴 수 있습니다.
리랭커 아키텍처: 후보 검색과 재정렬의 분리
리랭커 도입의 핵심은 “검색”과 “정렬”을 분리하는 것입니다.
- 1차 검색: 빠르게 넓게(
k=50또는k=100) 후보를 모음 - 2차 정렬: 느리지만 정확하게 상위(
n=5또는n=10)만 선택
이 구조가 성립하려면 다음이 필요합니다.
- 1차 검색의 재현율(Recall)이 충분히 높아야 함
- 2차 리랭커가 상위 정렬 품질을 확실히 개선해야 함
- 최종 컨텍스트 길이(토큰)와 비용을 통제해야 함
운영 관점에서는 “LLM에 넣는 문서 수는 그대로인데, 그 문서의 질이 올라가서 답변 품질이 상승”하는 형태가 가장 ROI가 좋습니다.
nDCG와 MRR: 무엇을 측정해야 ‘성능 2배’가 되는가
리랭커의 효과는 단순 정확도(accuracy)로 보기 어렵습니다. 검색/정렬 문제에서는 랭킹 품질 지표가 중요합니다.
MRR(Mean Reciprocal Rank)
MRR은 “첫 번째 정답이 몇 등으로 등장했는지”에 민감합니다.
- 각 쿼리마다 정답 문서가 처음 등장한 순위를
rank라고 할 때 - 점수는
1/rank - 전체 쿼리 평균이 MRR
예를 들어 정답이 1등이면 1.0, 2등이면 0.5, 5등이면 0.2입니다. RAG에서는 정답 근거가 상위에 빨리 올라오는지가 중요하므로 MRR이 매우 직관적입니다.
nDCG(Normalized Discounted Cumulative Gain)
nDCG는 “상위에 관련 문서가 많이 몰려 있는지”를 봅니다. 특히 관련도 레이블이 0/1이 아니라 0/1/2 같은 graded relevance일 때 강합니다.
- DCG는 상위 순위일수록 더 큰 가중치를 주며 관련도를 누적
- nDCG는 이상적인 정렬(IDCG)로 정규화해 0~1로 비교 가능
RAG 맥락에서는 다음 상황에서 nDCG가 특히 유용합니다.
- 정답 문서가 하나가 아니라 여러 개일 수 있음
- “완전 정답”은 아니지만 답변에 도움이 되는 문서가 존재함
- 상위 3~5개 문서의 품질이 답변 품질을 좌우함
정리하면,
- MRR: “정답이 상위에 빨리 오나”
- nDCG: “상위 묶음의 전체 품질이 좋아졌나”
성능 2배라는 표현은 보통 MRR 같은 지표에서 0.25 → 0.50처럼 체감이 큰 개선이 나올 때 현실적으로 가능합니다. 다만 과장 없이 말하려면, 동일한 평가셋에서 동일한 후보군 크기(k)를 유지한 채 리랭커만 바꿔 비교해야 합니다.
평가 데이터셋 설계: 리랭커는 ‘레이블’이 전부다
리랭커는 모델을 바꾸는 것보다, 평가셋과 레이블 정의가 더 중요합니다.
1) 쿼리-문서 관련도 레이블 만들기
최소한 다음 중 하나는 있어야 합니다.
- (권장) 쿼리마다 관련 문서
doc_id목록 - (차선) 쿼리마다 정답 문서 1개
- (확장) 관련도 점수(0/1/2)로 graded relevance
실무에서는 다음 방식이 현실적입니다.
- 기존 FAQ/헬프데스크 티켓의 “정답 링크”를 약한 레이블로 사용
- 검색 로그에서 클릭/체류시간 기반으로 약한 레이블 생성
- LLM을 심사위원으로 써서 후보 문서를 채점하되, 샘플링해 사람 검수로 보정
2) 평가 구간을 top-k로 고정
리랭커는 보통 k=50 후보를 받아 n=5로 줄입니다. 이때 평가도 nDCG@5, MRR@10처럼 @k를 명시해야 합니다.
nDCG@5: 상위 5개 품질MRR@10: 상위 10개 안에 정답이 들어오는지
3) 오프라인 지표와 온라인 지표를 분리
오프라인에서 nDCG/MRR이 올라도, 온라인에서 답변 만족도가 안 오를 수 있습니다. 이유는 다음과 같습니다.
- 컨텍스트 길이 제한으로 일부 문서가 잘려 나감
- 중복 문서가 늘어 다양성이 떨어짐
- 리랭커가 “관련은 높은데 답을 직접 주지 않는 문서”를 선호
따라서 오프라인 지표는 필수지만, 온라인에서는 최소한 다음을 같이 보세요.
- 답변에 인용된 근거 문서의 클릭/스크롤
- 재질문율(사용자가 같은 질문을 다시 하는 비율)
- 정답률이 있는 과제라면 task success rate
구현 예시: 1차 벡터 검색 + 2차 리랭킹
아래는 TypeScript로 “후보 검색 후 리랭킹”을 붙이는 최소 예시입니다. (실제 모델 호출은 벤더/호스팅에 따라 달라집니다.)
type Candidate = {
id: string;
text: string;
vectorScore: number;
};
type Reranked = Candidate & {
rerankScore: number;
};
async function retrieveTopK(query: string, k: number): Promise<Candidate[]> {
// vector DB 검색 결과를 가져온다고 가정
return [];
}
async function rerank(query: string, candidates: Candidate[]): Promise<Reranked[]> {
// 크로스인코더/리랭커 API에 (query, doc) 쌍을 넣어 점수화
// 예: candidates.map(c => score(query, c.text))
const scored: Reranked[] = candidates.map((c) => ({
...c,
rerankScore: Math.random(),
}));
return scored.sort((a, b) => b.rerankScore - a.rerankScore);
}
export async function ragRetrieve(query: string) {
const k = 50; // 넓게 후보 확보
const n = 5; // LLM에 넣을 최종 문서 수
const candidates = await retrieveTopK(query, k);
const reranked = await rerank(query, candidates);
return reranked.slice(0, n);
}
핵심 포인트는 다음입니다.
- 1차 검색은 빠른 벡터 검색으로
k를 크게 - 리랭커는
k개 후보에만 적용해 비용을 제한 - 최종 LLM 컨텍스트는
n개로 고정해 토큰 비용을 안정화
지표 계산 코드 예시: MRR@k, nDCG@k
평가 코드는 간단해 보여도 실수하기 쉽습니다. 특히 @k 절단과, 관련도 레이블(0/1/2)의 처리에서 오류가 자주 납니다.
아래는 MRR@k와 nDCG@k를 계산하는 간단한 예시입니다.
type Judgement = {
queryId: string;
// docId별 관련도: 0(무관) ~ 2(매우 관련)
relevanceByDocId: Record<string, number>;
};
type RankedList = {
queryId: string;
rankedDocIds: string[]; // 리랭킹 결과 순서
};
export function mrrAtK(judgements: Judgement[], rankings: RankedList[], k: number): number {
const byQuery = new Map(rankings.map(r => [r.queryId, r.rankedDocIds]));
let sum = 0;
let cnt = 0;
for (const j of judgements) {
const ranked = (byQuery.get(j.queryId) ?? []).slice(0, k);
let rr = 0;
for (let i = 0; i < ranked.length; i++) {
const docId = ranked[i];
const rel = j.relevanceByDocId[docId] ?? 0;
if (rel > 0) { // 정답/관련 문서가 등장한 첫 위치
rr = 1 / (i + 1);
break;
}
}
sum += rr;
cnt += 1;
}
return cnt === 0 ? 0 : sum / cnt;
}
function dcgAtK(rels: number[], k: number): number {
let dcg = 0;
for (let i = 0; i < Math.min(rels.length, k); i++) {
const rel = rels[i];
const denom = Math.log2(i + 2); // i=0일 때 log2(2)=1
dcg += (Math.pow(2, rel) - 1) / denom;
}
return dcg;
}
export function ndcgAtK(judgements: Judgement[], rankings: RankedList[], k: number): number {
const byQuery = new Map(rankings.map(r => [r.queryId, r.rankedDocIds]));
let sum = 0;
let cnt = 0;
for (const j of judgements) {
const ranked = (byQuery.get(j.queryId) ?? []).slice(0, k);
const rels = ranked.map(docId => j.relevanceByDocId[docId] ?? 0);
const dcg = dcgAtK(rels, k);
const idealRels = Object.values(j.relevanceByDocId).sort((a, b) => b - a);
const idcg = dcgAtK(idealRels, k);
const ndcg = idcg === 0 ? 0 : dcg / idcg;
sum += ndcg;
cnt += 1;
}
return cnt === 0 ? 0 : sum / cnt;
}
실무 팁:
rel > 0을 정답으로 보느냐,rel === 2만 정답으로 보느냐에 따라 MRR이 크게 달라집니다.- nDCG는
2^rel - 1를 쓰는 경우가 많아 graded relevance에서 차이를 더 잘 벌립니다.
“성능 2배”를 만드는 실험 디자인
리랭커 도입 후 지표가 올랐는데도 체감이 약하면, 대개 실험 설계가 잘못되었거나 병목이 다른 곳에 있습니다. 다음 체크리스트를 권장합니다.
1) 비교 조건을 고정한다
- 동일한 평가셋
- 동일한 후보 검색
k - 동일한 최종 컨텍스트 문서 수
n - 동일한 chunking 규칙(길이, overlap)
리랭커만 바꿔야 “리랭커의 기여”를 말할 수 있습니다.
2) 후보군 k를 키워 리랭커의 공간을 만든다
k=10에서 리랭커를 붙이면 좋아질 여지가 제한적입니다. 보통 다음이 출발점입니다.
- 1차 검색
k=50또는k=100 - 리랭킹 후
n=5
다만 k가 커질수록 리랭커 비용이 증가하므로, 지연시간 예산을 먼저 정하세요.
3) 중복 제거와 다양성(coverage)을 같이 본다
리랭커가 특정 문서군만 계속 올려서 다양성이 떨어지면, 답변이 한쪽으로 치우칠 수 있습니다.
- 동일 출처/동일 섹션 문서 중복 패널티
- MMR(Maximal Marginal Relevance) 같은 다양성 재정렬을 후처리로 추가
4) 실패 케이스를 분류한다
리랭커 도입 후에도 실패하는 케이스는 보통 다음 중 하나입니다.
- 후보군에 정답이 없음(1차 검색 문제)
- 정답이 있어도 리랭커가 못 올림(리랭커/레이블 문제)
- 정답 문서를 올렸는데 LLM이 못 씀(프롬프트/컨텍스트 구성 문제)
즉, nDCG/MRR이 올랐는데 답변이 안 좋아지면 3번 문제일 가능성이 큽니다.
운영 관점: 지연시간, 비용, 캐시
리랭커는 품질을 올리는 대신 비용과 지연시간을 늘립니다. 따라서 운영에서는 다음 전략이 중요합니다.
- 쿼리 캐시: 동일/유사 쿼리의 리랭킹 결과를 TTL 캐시
- 비동기 프리페치: UI에서 사용자가 입력 중일 때 후보를 미리 준비(가능한 제품에서)
- 배치 평가: 모델/프롬프트 변경 시 회귀 테스트로 nDCG/MRR을 자동 계산
CI에서 데이터/모델 아티팩트 캐시가 꼬이면 실험 결과가 흔들릴 수 있는데, 캐시 키 설계가 중요합니다. 관련해서는 GitHub Actions 캐시 무효화 안됨 - key·restore-keys 디버깅 글의 접근법이 RAG 실험 파이프라인에도 그대로 적용됩니다.
또한 로컬에서 리랭커나 임베딩 모델을 돌리면 GPU 메모리 이슈가 자주 터집니다. 이 경우 Transformers 로컬 LLM OOM? 7가지 즉시 해결의 체크리스트가 실무적으로 도움이 됩니다.
리랭커 선택 가이드: 무엇을 기준으로 고를까
리랭커 모델 선택은 “최고 성능”보다 “우리 데이터에서의 상위 정렬 개선”이 기준입니다.
- 언어: 한국어 쿼리가 많으면 한국어/다국어 성능을 반드시 확인
- 입력 길이: chunk가 길면 truncation으로 성능이 급락할 수 있음
- 배치 처리:
k=100후보를 한 번에 점수화할 수 있는지 - 서빙 형태: 외부 API인지, 자체 호스팅인지(지연/비용/보안)
실무에서는 먼저 2~3개 후보 리랭커를 붙여 동일 평가셋에서 nDCG@5, MRR@10을 비교하고, 지연시간과 비용까지 포함해 선택하는 방식이 가장 빠릅니다.
체크리스트: 도입 순서 요약
- 평가셋 준비: 쿼리, 후보 문서, 관련도 레이블 정의
- 베이스라인 측정: 리랭커 없이
nDCG@k,MRR@k - 리랭커 추가:
k를 늘리고n은 고정 - 오프라인 개선 확인: 특히
MRR@10이 의미 있게 오르는지 - 온라인 검증: 재질문율/클릭/만족도 등 제품 지표로 확인
- 운영 최적화: 캐시, 배치, 비용 상한, 회귀 테스트 자동화
결론
RAG에서 리랭커는 “검색 결과를 조금 더 예쁘게 정렬”하는 수준이 아니라, LLM이 정답 근거를 만날 확률을 구조적으로 끌어올리는 장치입니다. 이를 제대로 증명하려면 nDCG와 MRR 같은 랭킹 지표로 오프라인에서 개선을 수치화하고, 동일 조건 비교와 실패 케이스 분류로 개선 원인을 분해해야 합니다.
리랭커를 도입했는데도 품질이 오르지 않는다면 대부분은 리랭커 자체가 아니라 k 설정, 레이블 품질, chunking, 중복/다양성, 그리고 “LLM이 쓰기 좋은 근거”라는 정의가 불명확한 데서 문제가 시작됩니다. 이 지점만 정리되면, nDCG/MRR 개선이 곧 답변 품질 개선으로 연결되고, 결과적으로 ‘성능 2배’라는 표현이 숫자와 체감 모두에서 성립하게 됩니다.