본문 바로가기
삵
AI

RAG 정확도를 리랭커로 끌어올리기

sstdio.h·2026년 10월 5일·조회 1

RAG를 붙여 놓고 나면 꼭 한 번은 겪는 장면이 있다. 질문은 멀쩡한데 모델이 엉뚱한 문단을 근거로 답을 지어낸다. 로그를 열어 보면 벡터 검색이 가져온 상위 문서부터 질문과 미묘하게 어긋나 있다. 나도 사내 문서 검색을 처음 pgvector로만 돌렸을 때 이 지점에서 가장 많이 막혔다. 이번 글은 그 뒤에 리랭커를 한 단 더 얹어 상위 문서를 다시 줄 세우는 방법을 정리한다.

결론부터 말하자면, 벡터 검색 하나로 상위 k개를 바로 쓰지 말고 1차로 후보를 넓게(50~100개) 뽑은 뒤 cross-encoder 리랭커로 재정렬하는 2단계 구성이 가장 확실하다. pgvector는 빠르게 많은 후보를 걸러 주고, bge-reranker 같은 리랭커는 질문과 문단을 함께 읽어 정밀하게 순위를 매긴다. 최종적으로 상위 3~5개만 LLM에 넘기면 근거 품질이 올라가면서 토큰도 아낀다.

1. 리랭커가 왜 필요한가

먼저 용어부터 짚는다. RAG(Retrieval-Augmented Generation)는 질문과 관련된 문서를 먼저 찾아 와서, 그 내용을 프롬프트에 붙여 LLM이 답하게 하는 방식이다. 답의 품질은 사실상 '얼마나 정확한 근거를 찾아 왔는가'에 달려 있다.

1차 검색에 쓰는 임베딩 모델은 bi-encoder다. 질문과 문서를 각각 따로 벡터로 바꿔 두고, 두 벡터의 거리로 유사도를 잰다. 문서 벡터는 미리 계산해 두면 되니 수백만 건도 순식간에 훑는다. 대신 질문과 문서를 서로 보지 않고 각자 압축한 벡터만 비교하므로, 표현이 다르거나 부정 표현이 섞이면 어긋난 문단이 상위로 올라오기도 한다.

cross-encoder는 반대다. 질문과 문서 한 쌍을 하나의 입력으로 묶어 모델에 통째로 넣고, 둘 사이의 관련도 점수 하나를 출력한다. 질문과 문단이 서로를 보면서 attention을 걸기 때문에 훨씬 정확하다. 공식 모델 카드도 "cross-encoder가 embedding model보다 정확하지만 더 느리다"고 분명히 적어 둔다. 느리다는 게 핵심이다. 모든 문서를 cross-encoder로 점수 매길 수는 없다.

그래서 둘을 역할대로 나눈다. bi-encoder(pgvector)로 후보를 빠르게 넓게 추리고, 그 50~100개만 cross-encoder가 다시 읽어 정밀하게 재정렬한다. 리랭커는 상위 몇 개를 다시 줄 세우는 보정 단계다. 아래에서 1차 후보 검색, 리랭커 재정렬, 결과 확인을 차례로 살펴본다.

2. pgvector로 1차 후보 넓게 뽑기

pgvector는 PostgreSQL에 vector 타입과 거리 연산자, 근사 최근접 색인을 더해 주는 확장이다. 확장과 테이블은 다음과 같이 잡는다. 임베딩 차원은 쓰는 모델에 맞춘다(예: bge-m3는 1024차원).

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunks (
  id        bigserial PRIMARY KEY,
  doc_id    bigint NOT NULL,
  content   text   NOT NULL,
  embedding vector(1024)
);

검색 색인은 HNSW를 쓴다. 여기서 반드시 짚어야 할 함정이 하나 있다. HNSW 색인은 연산자 클래스를 생략할 수 없다. pgvector의 vector 타입에는 기본 연산자 클래스가 없어서, 그냥 색인을 만들려고 하면 생성 자체가 에러로 실패한다.

-- 연산자 클래스를 빼면 만들어지지 않는다
CREATE INDEX ON doc_chunks USING hnsw (embedding);
ERROR:  no default operator class for access method "hnsw"
HINT:  You must specify an operator class for the index or define a
       default operator class for the data type.

즉 '거리 기준을 빠뜨리면 엉뚱한 색인이 조용히 만들어진다'가 아니라, 아예 만들어지지 않는다. 그러니 임베딩 모델이 코사인 유사도로 학습됐다면 vector_cosine_ops를 명시해야 한다. bge 계열은 코사인을 쓴다.

CREATE INDEX ON doc_chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

거리 연산자는 연산자 클래스와 짝을 맞춰 쓴다. 코사인은 <=>, L2는 <->, 내적은 <#>다. 색인에 건 연산자와 쿼리에 쓰는 연산자가 다르면 색인을 타지 못하니 둘을 일치시킨다.

후보는 넓게 뽑는다. 최종으로 3~5개를 쓸 거라도 1차에서는 50~100개를 가져온다. 리랭커가 다시 줄 세울 재료를 충분히 줘야 정답 문단이 1차에서 탈락하지 않는다.

-- 질문 임베딩을 바인딩 파라미터 $1로 넣는다
SET hnsw.ef_search = 100;

SELECT id, doc_id, content,
       embedding <=> $1 AS distance
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 100;

여기서 hnsw.ef_search는 검색 중 유지하는 후보 목록 크기다. 기본값은 40인데, 100개를 LIMIT으로 뽑을 거면 ef_search도 그만큼 올려야 재현율이 유지된다. ef_search가 LIMIT보다 작으면 뒤쪽 후보가 부실해진다. 값을 올리면 재현율이 좋아지는 대신 조회가 느려지니, 1차 후보 개수에 맞춰 세션 단위로 조정한다.

3. bge-reranker로 재정렬하기

1차로 뽑은 100개 문단을 cross-encoder에 넣어 질문과의 관련도 점수를 다시 매긴다. BAAI의 bge-reranker-v2-m3는 한국어를 포함한 다국어를 지원하는 0.6B 리랭커다. 영어와 중국어만 다룬다면 더 가벼운 bge-reranker-base(0.3B)도 쓸 만하다. 한국어 문서를 다루니 여기서는 v2-m3를 기준으로 쓴다.

pip install -U FlagEmbedding

FlagEmbedding의 FlagReranker로 질문과 문단 쌍의 점수를 한꺼번에 계산한다. normalize=True를 주면 sigmoid를 거쳐 0~1 범위로 나온다. 이 옵션을 빼면 점수가 logit 그대로라 음수도 나오고 범위가 들쭉날쭉하니, 임계값을 걸 생각이면 정규화를 켜 두는 편이 다루기 쉽다.

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

query = "리플리케이션 슬롯이 쌓이면 디스크가 왜 차나"
# candidates: pgvector가 돌려준 100개 문단 리스트
pairs = [[query, c['content']] for c in candidates]

scores = reranker.compute_score(pairs, normalize=True)

for c, s in zip(candidates, scores):
    c['rerank_score'] = s

reranked = sorted(candidates, key=lambda c: c['rerank_score'], reverse=True)
top = reranked[:5]   # LLM에 넘길 최종 근거

sentence-transformers를 이미 쓰고 있다면 같은 모델을 CrossEncoder로 불러 predict를 호출해도 된다. 둘 중 뭘 쓰든 입력은 (질문, 문단) 쌍이고 출력은 쌍마다 점수 하나라는 구조가 같다.

from sentence_transformers import CrossEncoder

model = CrossEncoder("BAAI/bge-reranker-v2-m3")
scores = model.predict([(query, c['content']) for c in candidates])

막상 돌려 보면 첫 호출이 유독 느리다. 모델을 내려받고 메모리에 올리는 시간이다. 요청마다 FlagReranker를 새로 만들면 매번 이 비용을 치르니, 프로세스 기동 시 한 번만 로드해 재사용한다. GPU가 있으면 use_fp16=True로 절반 정밀도를 켜 처리량을 올린다. CPU만 있다면 1차 후보를 100개까지 안 가고 30~50개로 줄여 리랭커 부담을 낮추는 쪽이 현실적이다.

4. 효과 확인하기

재정렬이 실제로 순위를 바꾸는지는 1차 순위와 리랭커 순위를 나란히 찍어 보면 바로 보인다.

for rank, c in enumerate(top, 1):
    print(f"{rank}. vec_dist={c['distance']:.3f} "
          f"rerank={c['rerank_score']:.3f}  {c['content'][:40]}")

잘 동작하면 1차에서 distance가 가장 작지 않던 문단이 rerank_score 상위로 올라오는 장면이 나온다. 질문의 의도와 맞는 문단이 cross-encoder 단계에서 끌어올려졌다는 신호다.

품질을 수치로 보려면 질문마다 정답 문단을 미리 표시한 작은 평가셋을 만들어 Recall@k와 MRR을 비교한다. 1차 검색만 썼을 때와 리랭커를 얹었을 때를 같은 평가셋으로 재어 본다. 개선 폭은 데이터와 모델에 따라 다르지만, 어긋난 문단을 상위에서 밀어내는 효과가 상위 k가 작을수록(예: k=3) 더 크게 나타난다.

5. 운영에서 걸리는 지점

지연이 가장 먼저 눈에 띈다. 리랭커는 후보 수에 비례해 느려진다. 100개를 CPU로 재정렬하면 체감이 분명하다. 1차 후보 개수와 재정렬 지연은 맞바꾸는 관계이니, 지연 예산 안에서 후보 수를 정한다.

점수 임계값으로 문단을 버릴 거라면 정규화된 점수를 쓰고, 임계값은 평가셋으로 정한다. logit 원점수에 고정 임계값을 걸면 모델을 바꿀 때마다 기준이 흔들린다.

1차 후보가 부실하면 리랭커도 못 살린다. 리랭커는 넘어온 후보 안에서만 순서를 바꿀 뿐, 1차에서 아예 빠진 정답 문단을 데려오지는 못한다. 정답이 상위 100개에 못 드는 일이 잦다면 리랭커보다 먼저 청크 크기, 임베딩 모델, ef_search부터 손봐야 한다.

자주 묻는 질문

pgvector HNSW 색인을 만들 때 연산자 클래스를 빼면 어떻게 되나

색인이 만들어지지 않고 실패한다. pgvector의 vector 타입에는 기본 연산자 클래스가 없어서 'ERROR: no default operator class for access method "hnsw"' 에러가 난다. L2 색인이 조용히 생성되거나 엉뚱한 이웃이 올라오는 동작은 일어나지 않는다. 임베딩이 코사인 기반이면 vector_cosine_ops를, L2면 vector_l2_ops를 명시하고 쿼리의 거리 연산자(<=>, <->)도 거기에 맞춰야 한다.

리랭커를 쓰면 1차 벡터 검색은 안 해도 되나

둘은 역할이 다르다. cross-encoder 리랭커는 질문과 문단을 함께 읽어 정확하지만 느려서 전체 문서에 쓸 수 없다. 그래서 pgvector 같은 bi-encoder로 후보를 빠르게 넓게 추린 뒤, 그 50~100개만 리랭커가 재정렬한다. 1차 검색을 생략할 수는 없다.

1차 후보는 몇 개나 뽑아야 하나

최종으로 3~5개를 LLM에 넘길 거라도 1차에서는 50~100개를 뽑는다. 정답 문단이 1차에서 탈락하지 않도록 재료를 넓게 주는 것이다. CPU만으로 리랭커를 돌린다면 지연을 감안해 30~50개로 줄인다. 후보를 100개로 늘릴 때는 hnsw.ef_search도 그만큼 올려 재현율을 유지한다.

bge-reranker-v2-m3와 bge-reranker-base 중 무엇을 고르나

한국어 등 다국어 문서를 다루면 v2-m3(0.6B, 다국어)를 쓴다. 영어와 중국어만 다루고 더 가볍게 가고 싶으면 base(0.3B)가 적합하다. 둘 다 입력은 (질문, 문단) 쌍이고 출력은 쌍마다 관련도 점수 하나라서 코드 구조는 같다.

리랭커 점수는 그대로 임계값으로 써도 되나

기본 출력은 logit이라 음수도 나오고 범위가 일정하지 않다. compute_score에 normalize=True를 주면 sigmoid를 거쳐 0~1로 정규화된다. 점수 임계값으로 문단을 버릴 거면 정규화된 점수를 쓰고, 임계값 자체는 질문과 정답 문단을 표시한 평가셋으로 정한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.