Published on

RAG 정확도 2배 - Cohere Rerank+BM25 하이브리드

Authors

서로 다른 검색 신호(의미 유사도, 키워드 일치도)를 한 번에 잡는 가장 실전적인 조합이 BM25 + 벡터 검색 + Rerank 입니다. 벡터 검색은 문맥적 유사도를 잘 잡지만, 제품 코드/에러 코드/약어/고유명사처럼 “문자 그대로”가 중요한 질의에서는 빈번히 미끄러집니다. 반대로 BM25는 키워드 정합에 강하지만 동의어·표현 변형·문장형 질의에서는 약합니다.

이 글에서는 BM25로 키워드 누락을 막고, 벡터 검색으로 의미 후보를 넓힌 뒤, Cohere Rerank로 최종 순위를 통일하는 하이브리드 RAG를 설계합니다. 목표는 단순히 “문서 더 많이 가져오기”가 아니라, LLM이 답을 만들기 전에 정답 근거가 되는 문서를 Top-K에 안정적으로 올리는 것입니다.

또한 인덱스/파라미터 튜닝은 성능에 직결됩니다. 벡터 인덱스 쪽은 아래 글도 함께 보면 전체 최적화 흐름이 잡힙니다.

왜 BM25와 Rerank가 함께 필요할까

벡터 검색 단독의 흔한 실패 케이스

  • 키워드가 정답을 결정: 예) E0502, ORA-00942, CVE-2024-xxxx, m5.2xlarge 같은 토큰은 임베딩에서 “비슷한 의미”로 뭉개지기 쉽습니다.
  • 짧은 질의: 예) oauth 302 loop 같은 질의는 의미 공간에서 다양한 문서로 퍼져 recall은 나오는데 precision이 떨어집니다.
  • 도메인 특화 용어: 사내 용어, 약어, 내부 서비스명은 임베딩 모델이 학습하지 않았을 수 있습니다.

BM25 단독의 한계

  • 동의어/패러프레이즈: 사용자가 “로그인이 계속 튕겨요”라고 쓰면 문서에는 “세션 만료”로 적혀 있을 수 있습니다.
  • 문장형 질문: “왜 특정 조건에서만 장애가 나지” 같은 질문은 키워드 매칭만으로는 잡히지 않습니다.

Rerank의 역할

하이브리드에서 가장 중요한 포인트는 “두 검색 결과를 어떻게 섞을지”입니다. 단순 가중 합산은 튜닝이 어렵고, 쿼리마다 최적 가중치가 달라집니다. Rerank 모델은 쿼리-문서 쌍을 직접 비교해 최종 순위를 만들어주기 때문에, 결합 로직을 단순화하면서도 정확도를 끌어올릴 수 있습니다.

정리하면 다음 구조가 안정적입니다.

  • 1단계: BM25로 키워드 정합 후보 확보
  • 2단계: Vector search로 의미 유사 후보 확보
  • 3단계: 후보를 합쳐 Cohere Rerank로 Top-K 재정렬
  • 4단계: Top-K 문서로 RAG 답변 생성

아키텍처: Hybrid Retrieve 후 Rerank

데이터 준비: 문서 청킹과 메타데이터

Rerank는 문서 텍스트가 길어질수록 비용이 늘고, 너무 짧으면 근거가 부족해집니다. 실무에서는 다음이 무난합니다.

  • 청크 길이: 300에서 800 토큰 근처(도메인에 따라 조정)
  • 오버랩: 10%에서 20%
  • 메타데이터: doc_id, chunk_id, source, title, created_at, tags

BM25는 원문 텍스트 기반, 벡터 검색은 청크 임베딩 기반으로 맞추되, 최종 Rerank 입력은 “청크 텍스트 + 제목 + 섹션 헤더”처럼 근거가 되는 문장을 포함시키는 편이 좋습니다.

질의 처리 파이프라인

  1. 사용자 질의 정규화(불필요 공백, 코드 블록 처리 등)
  2. BM25 Top-N 예: N=50
  3. Vector Top-M 예: M=50
  4. 합집합 후 중복 제거: 최대 120개 내로 컷
  5. Cohere Rerank로 Top-K 예: K=8 또는 K=12
  6. 컨텍스트 구성(출처 포함) 후 LLM 생성

구현 예제: Python으로 BM25+pgvector+Cohere Rerank

아래 예시는 개념을 보여주는 형태입니다. 운영에서는 캐시, 타임아웃, 관측 지표, 폴백을 반드시 넣어야 합니다.

1) BM25 (rank-bm25) 구성

from rank_bm25 import BM25Okapi
import re

def tokenize(text: str) -> list[str]:
    # 한국어는 형태소 분석이 더 좋지만, 빠른 베이스라인으로는 단순 토큰화부터 시작
    text = text.lower()
    text = re.sub(r"[^0-9a-zA-Z가-힣_\-\s]", " ", text)
    return [t for t in text.split() if t]

class Bm25Index:
    def __init__(self, docs: list[dict]):
        self.docs = docs
        corpus = [tokenize(d["text"]) for d in docs]
        self.bm25 = BM25Okapi(corpus)

    def search(self, query: str, top_n: int = 50) -> list[dict]:
        q = tokenize(query)
        scores = self.bm25.get_scores(q)
        ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_n]
        return [
            {"doc": self.docs[i], "bm25_score": float(scores[i])}
            for i in ranked
        ]

BM25의 핵심은 “토큰화 품질”입니다. 한국어는 형태소 분석기를 붙이면 체감이 커집니다. 다만 운영 난이도가 올라가므로, 먼저 단순 토큰화로 베이스라인을 만든 뒤 개선하는 접근이 안전합니다.

2) Vector 검색 (pgvector) 예시

import psycopg

class PgvectorIndex:
    def __init__(self, dsn: str, embed_fn):
        self.dsn = dsn
        self.embed_fn = embed_fn

    def search(self, query: str, top_m: int = 50) -> list[dict]:
        q_emb = self.embed_fn(query)  # list[float]

        sql = """
        SELECT doc_id, chunk_id, title, text,
               1 - (embedding <=> %s::vector) AS sim
        FROM rag_chunks
        ORDER BY embedding <=> %s::vector
        LIMIT %s
        """

        with psycopg.connect(self.dsn) as conn:
            rows = conn.execute(sql, (q_emb, q_emb, top_m)).fetchall()

        out = []
        for r in rows:
            out.append({
                "doc": {
                    "doc_id": r[0],
                    "chunk_id": r[1],
                    "title": r[2],
                    "text": r[3],
                },
                "vector_score": float(r[4]),
            })
        return out

인덱스 타입이나 파라미터(예: HNSW ef_search, IVFFlat nprobe)에 따라 recall과 latency가 크게 바뀝니다. 이 부분은 위의 pgvector 튜닝 글을 참고해, “정확도 목표에 맞는 지연시간”을 먼저 정하고 역으로 파라미터를 맞추는 게 좋습니다.

3) 후보 합치기와 Cohere Rerank

중요한 건 “BM25 점수와 벡터 점수를 섞어서 랭킹”하지 않고, 후보만 넓힌 뒤 Rerank에 최종 결정을 맡기는 것입니다.

from cohere import Client

class CohereReranker:
    def __init__(self, api_key: str, model: str = "rerank-english-v3.0"):
        self.co = Client(api_key)
        self.model = model

    def rerank(self, query: str, docs: list[dict], top_k: int = 10) -> list[dict]:
        # Cohere Rerank 입력은 문자열 리스트
        inputs = []
        for d in docs:
            title = d.get("title") or ""
            text = d.get("text") or ""
            inputs.append(f"Title: {title}\n\n{text}")

        res = self.co.rerank(
            model=self.model,
            query=query,
            documents=inputs,
            top_n=top_k,
        )

        # res.results에는 index와 relevance_score가 들어있음
        ranked = []
        for item in res.results:
            ranked.append({
                "doc": docs[item.index],
                "rerank_score": float(item.relevance_score),
            })
        return ranked

주의할 점

  • Rerank 모델 이름은 사용 환경/리전에 따라 다를 수 있으니, 실제 프로젝트에서는 Cohere 문서에서 최신 모델명을 확인하세요.
  • 한국어 질의가 많다면 멀티링구얼 지원 여부를 확인하고, 필요하면 언어별로 모델을 분리하거나 질의/문서 언어 감지를 넣는 편이 좋습니다.

4) 전체 오케스트레이션

def hybrid_retrieve(query: str, bm25: Bm25Index, vec: PgvectorIndex, rr: CohereReranker,
                    bm25_n: int = 50, vec_m: int = 50, rerank_k: int = 10) -> list[dict]:
    bm25_hits = bm25.search(query, top_n=bm25_n)
    vec_hits = vec.search(query, top_m=vec_m)

    # 후보 합치기 (doc_id, chunk_id 기준)
    merged = {}
    for h in bm25_hits:
        d = h["doc"]
        key = (d.get("doc_id"), d.get("chunk_id"))
        merged[key] = d
    for h in vec_hits:
        d = h["doc"]
        key = (d.get("doc_id"), d.get("chunk_id"))
        merged[key] = d

    candidates = list(merged.values())

    # 너무 많이 보내면 비용과 지연이 증가하므로 상한을 둠
    candidates = candidates[:120]

    reranked = rr.rerank(query, candidates, top_k=rerank_k)
    return reranked

이 구조의 장점은 명확합니다.

  • BM25와 벡터 검색은 “후보 생성기” 역할만 수행
  • 최종 순위는 Rerank가 책임
  • 가중치 튜닝 지옥에서 벗어남

정확도가 실제로 2배 오르는 지점: 평가와 디버깅

“2배”는 과장처럼 들릴 수 있지만, 특정 도메인에서는 Top-K hit rate가 실제로 크게 뛸 때가 있습니다. 특히 다음 조건에서 상승 폭이 큽니다.

  • 질의가 짧고 키워드 의존도가 높음
  • 문서가 길고, 정답 근거가 특정 섹션에만 존재
  • 임베딩 모델이 도메인을 충분히 커버하지 못함

추천 지표

  • Top-K Recall 또는 Hit@K: 정답 문서가 Top-K에 포함되는지
  • MRR: 정답이 상위에 올수록 가중치를 주는 지표
  • nDCG: 여러 정답(graded relevance)이 있을 때 유용
  • 생성 품질 지표는 retrieval 지표와 분리: retrieval이 좋아져도 프롬프트가 나쁘면 답은 망가집니다

실패 케이스 분류 템플릿

운영에서 가장 빨리 효과를 보는 방법은 “틀린 질문”을 모아 아래로 라벨링하는 것입니다.

  • retrieval miss: Top-K에 근거 문서가 없음
  • rerank error: 후보에는 있었는데 순위가 밀림
  • context assembly error: 좋은 청크를 가져왔는데 잘려서 근거가 사라짐
  • generation error: 근거는 충분한데 LLM이 오답 생성

생성 단계의 안정성은 프롬프트 패턴이 크게 좌우합니다. 근거 기반 답변을 강제하고 자기검증을 넣고 싶다면 아래 글이 도움이 됩니다.

운영 팁: 비용, 지연시간, 캐시

Rerank 비용 최적화

Rerank는 쿼리마다 후보 개수만큼 과금/지연이 늘 수 있습니다. 실전에서는 다음 순서로 줄입니다.

  1. 후보 상한 설정: 예) 120을 넘기지 않기
  2. 질의 길이에 따라 동적 후보 수: 짧은 질의는 BM25 비중이 커서 후보를 줄일 수 있음
  3. 캐시: 동일 질의, 동일 인덱스 버전이면 rerank 결과 캐시
  4. 문서 압축: Rerank에 보내는 텍스트를 “핵심 문장” 위주로 구성

지연시간 예산 예시

  • BM25: 수 ms에서 수십 ms
  • 벡터 검색: 인덱스/파라미터에 따라 수 ms에서 수백 ms
  • Rerank API: 네트워크 포함 수백 ms 가능

사용자 체감이 중요한 제품이라면, “Rerank 타임아웃 시 폴백”을 넣어야 합니다.

import time

def safe_rerank(rr, query, candidates, top_k, timeout_s=1.2):
    start = time.time()
    try:
        out = rr.rerank(query, candidates, top_k=top_k)
        if time.time() - start > timeout_s:
            # 늦게 왔으면 버리고 폴백을 쓰는 정책도 가능
            return None
        return out
    except Exception:
        return None

폴백 전략

  • Rerank 실패 시: 벡터 Top-K와 BM25 Top-K를 단순 interleave
  • 더 공격적으로: “키워드가 강한 질의”는 BM25 우선, 아니면 벡터 우선

하이브리드에서 자주 터지는 함정 6가지

  1. 중복 문서 폭발: 같은 문서가 청크만 다르게 다수 들어오면 Rerank가 비슷한 텍스트를 상위에 몰아줌

    • 해결: doc_id 단위로 다양성 제약을 걸거나, 상위 N개에서 동일 문서 청크 수를 제한
  2. 청크 경계 문제: 정답 문장이 청크 경계에 걸리면 retrieval은 되는데 근거가 잘림

    • 해결: 오버랩 증가, 섹션 기반 청킹
  3. BM25 토큰화 품질: 한국어/코드/스네이크 케이스 처리에 따라 성능이 크게 달라짐

    • 해결: 커스텀 토크나이저, 코드 토큰 보존
  4. 벡터 인덱스 파라미터 미스: latency 줄이려다 recall이 급락

    • 해결: 목표 Hit@K를 먼저 정하고, 그 다음 latency 최적화
  5. Rerank 입력 포맷 불안정: 제목/섹션/본문을 섞는 방식이 일관되지 않으면 결과가 흔들림

    • 해결: 항상 동일 템플릿으로 구성
  6. 관측성 부족: “왜 틀렸는지”를 로그로 남기지 않으면 개선이 멈춤

    • 해결: 질의, 후보 리스트, 최종 Top-K, 각 단계 점수, 타임아웃 여부를 샘플링 로깅

결론: 가장 단순한데 가장 강력한 조합

BM25 + Vector + Cohere Rerank는 RAG에서 “정확도와 운영 난이도”의 균형이 좋습니다. BM25로 키워드 정합을 잡고, 벡터로 의미 recall을 확보한 다음, Rerank로 최종 결정을 맡기면 가중치 튜닝 부담이 줄고, Top-K 근거 문서 적중률이 크게 올라갑니다.

다음 단계로는

  • 질의 라우팅(짧은 키워드 질의는 BM25 강화)
  • 문서 다양성 제약(동일 문서 청크 쏠림 방지)
  • 인덱스 튜닝과 평가 자동화

를 붙이면, “데모 수준 RAG”에서 “운영 가능한 RAG”로 올라갈 수 있습니다.