- Published on
RAG 리랭커로 환각 줄이기 - Cohere·bge
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG(Retrieval-Augmented Generation)를 붙였는데도 답이 그럴듯하게 틀리는 경우가 많습니다. 원인은 대개 LLM 자체가 아니라, LLM에 들어가는 컨텍스트가 잘못된 순서로 들어가거나(상위 문서가 부정확), 질문과 직접 관련 없는 문서가 섞여 들어가 “근거처럼 보이는 잡음”이 늘어나는 데 있습니다.
이때 가장 비용 대비 효과가 좋은 처방이 리랭커(reranker) 입니다. 임베딩 기반 벡터 검색(dual-encoder)은 빠르지만 “대충 비슷한” 문서를 상위에 올리는 경향이 있고, 리랭커(cross-encoder 계열)는 느리지만 “질문-문서 쌍을 정밀 채점”해서 상위 K개를 훨씬 정확하게 고릅니다. 결과적으로 LLM이 참고할 근거가 좋아져 환각이 줄어듭니다.
이 글에서는 실무에서 많이 쓰는 두 축인 Cohere Rerank(상용 API) 와 bge reranker(오픈소스 모델) 를 기준으로, 어디에 끼워 넣고 어떻게 평가해야 하는지까지 정리합니다.
왜 리랭커가 환각을 줄이나
RAG 파이프라인을 단순화하면 다음과 같습니다.
- 쿼리 임베딩 생성
- 벡터 DB에서 top-N 검색(예: 50)
- 상위 문서 일부를 컨텍스트로 넣고 LLM 생성
문제는 2번의 top-N 결과가 “질문에 대한 정답 근거”와 일치하지 않을 수 있다는 점입니다.
- 임베딩 검색은 의미 유사도에 강하지만, 질문의 초점(조건, 최신성, 부정/예외, 수치 조건)을 놓치기 쉽습니다.
- top-N에 잡음 문서가 섞이면, LLM은 그럴듯한 문장으로 근거를 합성해 버립니다.
리랭커는 2와 3 사이에 들어가서 다음을 수행합니다.
- 질문과 각 문서를 함께 보고 점수화(보통 cross-attention)
- 질문에 더 직접적으로 답하는 문서를 상위로 올림
- 컨텍스트 길이가 제한된 상황에서 “정확한 근거”를 우선 투입
즉, 리랭커는 “검색 품질”을 올리는 동시에 “컨텍스트 예산”을 더 효율적으로 쓰게 만들어 환각을 체감적으로 줄입니다.
아키텍처: retrieve top-N 후 rerank top-K
실무에서 가장 흔한 패턴은 다음입니다.
- 벡터 검색:
topN = 50내외 - 리랭킹: topN 후보를 점수화 후
topK = 5~10만 선택 - LLM 컨텍스트: topK 문서만 넣기
여기서 핵심 튜닝 포인트는 아래 3가지입니다.
- N을 너무 작게 잡지 않기: 리랭커가 고를 후보 자체가 부족해집니다.
- K를 컨텍스트 예산에 맞추기: K가 커질수록 비용과 잡음이 증가합니다.
- chunk 전략: chunk가 너무 크면 관련 문장 찾기가 어려워지고, 너무 작으면 문맥이 끊겨 점수가 흔들립니다.
Cohere Rerank로 빠르게 붙이기
Cohere의 rerank는 API로 제공되어 운영이 간단합니다. 벡터 DB에서 가져온 후보 문서들을 그대로 넘기면, 질문에 대한 관련도 기준으로 재정렬된 결과를 받습니다.
Cohere Rerank 호출 예시(Node.js)
아래 예시는 “벡터 검색 결과 50개”를 Cohere에 보내고 상위 8개만 뽑는 흐름입니다.
import Cohere from "cohere-ai";
const cohere = new Cohere({ token: process.env.COHERE_API_KEY! });
type Candidate = { id: string; text: string; meta?: Record<string, unknown> };
export async function rerankWithCohere(params: {
query: string;
candidates: Candidate[];
topK: number;
}) {
const { query, candidates, topK } = params;
const res = await cohere.v2.rerank({
model: "rerank-v3.5",
query,
documents: candidates.map((c) => c.text),
topN: topK,
});
// res.results: [{ index, relevance_score }, ...]
return res.results.map((r) => ({
...candidates[r.index],
score: r.relevance_score,
}));
}
운영 관점 장단점
- 장점
- 모델 서빙, 스케일링, GPU 운영이 필요 없음
- 품질이 안정적이고, 실험 속도가 빠름
- 단점
- 호출 비용과 레이턴시가 추가됨
- 사내 데이터/규정상 외부 전송이 어려운 경우 제약
API 기반 리랭커를 붙이면, 다음 단계로는 “재시도/백오프” 같은 운영 이슈가 따라옵니다. 레이트 리밋과 재시도 설계는 OpenAI API 429·Rate Limit 재시도 백오프 설계에서 다룬 패턴을 그대로 응용할 수 있습니다(리랭커 API에도 동일한 문제가 생깁니다).
bge reranker로 온프레미스/사내망에 붙이기
bge 계열은 임베딩뿐 아니라 리랭커 모델도 널리 쓰입니다. 대표적으로 BAAI/bge-reranker-v2-m3 같은 모델이 멀티링구얼/다목적에 강합니다.
온프레미스 리랭커의 핵심은 “추론 비용”입니다. cross-encoder는 후보 문서 수에 비례해 연산이 늘어나므로, 반드시 topN을 제한하고 배치 추론을 잘 해야 합니다.
bge reranker 예시(Python, transformers)
아래는 질문과 문서들을 쌍으로 만들어 점수를 뽑고, 점수로 정렬하는 예시입니다.
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
model_name = "BAAI/bge-reranker-v2-m3"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)
model.eval()
@torch.no_grad()
def rerank(query: str, docs: list[str], top_k: int = 8):
pairs = [(query, d) for d in docs]
inputs = tokenizer(
pairs,
padding=True,
truncation=True,
return_tensors="pt",
max_length=512,
)
logits = model(**inputs).logits.squeeze(-1)
scores = logits.float().tolist()
ranked = sorted(list(enumerate(scores)), key=lambda x: x[1], reverse=True)
return ranked[:top_k]
# 사용 예
query = "RAG에서 리랭커는 왜 환각을 줄이나?"
docs = ["...chunk1...", "...chunk2...", "...chunk3..."]
print(rerank(query, docs, top_k=2))
서빙 팁
- 후보 문서 수를 줄이기: 벡터 검색에서
topN을 30~80 사이로 잡고 실험 - 배치 추론: 요청을 마이크로배치로 모아 GPU 효율을 올림
- 토큰 제한:
max_length를 현실적으로 제한(문서 chunk가 너무 길면 리랭커가 비싸짐) - 캐싱: 동일/유사 쿼리에서 리랭크 결과 캐시(특히 FAQ)
리랭커를 넣을 때 흔한 실수
1) 리랭커 점수를 “유사도”처럼 해석
리랭커 점수는 모델/버전에 따라 스케일이 다르고 절대값이 의미 없을 수 있습니다. 임계값 기반으로 “이 점수면 정답” 같은 식으로 쓰기보다, 정렬(rank) 자체에 집중하는 편이 안전합니다.
2) chunk 품질이 나쁜데 리랭커로 해결하려고 함
chunk가 너무 길거나(여러 주제 섞임), 너무 짧아 문맥이 끊기면 리랭커도 흔들립니다.
- 권장: 문서 구조(제목, 섹션, 표/코드 블록)를 보존하는 chunk
- 권장: 답을 담은 문장을 포함하도록 overlap을 적절히 부여
3) topK를 과하게 크게 잡아 컨텍스트를 오염
리랭커가 있어도 topK를 20, 30처럼 크게 넣으면 잡음이 늘고, LLM이 근거를 섞어 말할 확률이 올라갑니다. “더 많이 넣으면 더 정확”이 아니라, “정확한 것만 넣으면 더 정확”에 가깝습니다.
평가: 환각이 줄었는지 어떻게 확인하나
리랭커 도입 효과는 “정답률”만으로 보면 놓치는 부분이 많습니다. 아래 3단으로 나누어 측정하면 원인 분리가 쉬워집니다.
1) Retrieval 평가(검색 품질)
- 지표: Recall@K, MRR, nDCG
- 방법: 질문마다 정답 근거 문서(또는 근거 chunk)를 라벨링하고, 벡터 검색만 vs 리랭킹 후를 비교
2) Groundedness 평가(근거 기반 생성)
- 생성 답변이 컨텍스트에 근거하는지 측정
- 방법: 답변의 각 주장(claim)이 컨텍스트에 의해 지지되는지 휴먼/LLM 심사
3) End-to-End 평가(사용자 관점)
- 정확성, 유용성, 불필요한 장황함, 인용 품질 등을 종합 평가
여기서 중요한 점은, 리랭커는 대개 1번을 확실히 올려 주고, 2번을 간접적으로 개선합니다. 하지만 2번을 확실히 하려면 “답변은 제공된 근거에서만” 같은 생성 제약도 같이 들어가야 합니다. 이때 추론 품질을 올리되 불필요한 내부 추론 노출을 피하는 프롬프트/구조는 Chain-of-Thought 노출 없이 추론 정확도 올리기에서 소개한 접근이 잘 맞습니다.
실전 조합 예시: Vector search + rerank + 인용 생성
아래는 전체 흐름을 의사코드로 정리한 예시입니다.
// 1) vector search로 후보를 넓게 가져오기
const candidates = await vectorDb.search({
queryEmbedding,
topN: 50,
});
// 2) rerank로 상위 K개만 추리기
const topDocs = await rerankWithCohere({
query: userQuery,
candidates: candidates.map((c) => ({ id: c.id, text: c.chunk })),
topK: 8,
});
// 3) 컨텍스트 구성(인용용 메타 포함)
const context = topDocs
.map((d, i) => `[#${i + 1}] ${d.text}`)
.join("\n\n");
// 4) LLM에 근거 기반 답변 요구
const prompt = `
너는 주어진 근거만 사용해 답변한다.
근거에 없는 내용은 모른다고 말한다.
각 문장 끝에 근거 번호를 인용한다.
질문: ${userQuery}
근거:\n${context}
`;
const answer = await llm.generate({ prompt });
주의할 점은 프롬프트 문자열에 [ ] 같은 문자는 안전하지만, 본문 텍스트에 부등호가 섞여 들어오면 MDX에서 문제가 될 수 있다는 점입니다. 문서 원문에 부등호가 포함될 수 있는 환경이라면, 렌더링 단계에서 < >로 치환하거나, 코드 블록/인라인 코드로 감싸는 정책을 함께 두는 편이 좋습니다.
Cohere vs bge, 무엇을 선택할까
Cohere Rerank 추천 상황
- 빠르게 품질 개선을 확인하고 싶다
- GPU 운영이 부담이다
- 트래픽 변동이 크고 자동 확장이 필요하다
bge reranker 추천 상황
- 데이터 반출이 어렵다(사내망, 규정)
- 대량 트래픽에서 단가를 낮추고 싶다
- 모델/서빙을 커스터마이징하고 싶다
현실적인 전략은 “초기에는 Cohere로 리랭킹 효과를 검증하고, ROI가 확인되면 bge로 이관”입니다. 이때도 이관 리스크를 줄이려면, 리랭커를 함수처럼 추상화하고 입력/출력 계약(후보 리스트, topK, 점수, 원본 인덱스)을 고정해 두는 것이 좋습니다.
마무리
RAG에서 환각을 줄이는 가장 직접적인 방법은 LLM을 더 큰 모델로 바꾸는 것이 아니라, LLM에 들어가는 근거를 더 정확하게 만드는 것입니다. 리랭커는 벡터 검색의 속도와 cross-encoder의 정밀도를 결합해, 같은 컨텍스트 예산으로 더 맞는 문서를 넣게 해 줍니다.
- 벡터 검색은 넓게(
topN) - 리랭커로 좁게(
topK) - 컨텍스트는 짧고 정확하게
- 평가를 retrieval과 groundedness로 분리
이 4가지만 지키면 Cohere든 bge든, 리랭커는 “체감 환각”을 눈에 띄게 낮춰주는 레버가 됩니다.