RAG 파이프라인을 처음 만들 때 대부분 벡터 검색 하나에 의존합니다. 문서와 질문을 임베딩으로 바꿔 코사인 유사도가 높은 청크를 뽑아 프롬프트에 붙이는 구조입니다. 데모에서는 잘 돌아가지만, 실사용 로그를 보면 특정 유형의 질문에서 엉뚱한 청크를 가져오는 패턴이 반복됩니다. 제품 코드, 오류 메시지, 고유 식별자처럼 글자 그대로 일치해야 하는 토큰이 질문에 들어 있을 때가 특히 그렇습니다.

이 글은 그 구멍을 메우는 하이브리드 검색을 다룹니다. 어휘 기반 BM25와 의미 기반 벡터 검색을 함께 돌리고 두 결과를 하나의 순위로 합치는 방법입니다. 핵심은 “둘 중 하나를 고르는 것”이 아니라 “서로의 실패 모드를 상쇄하도록 결합하는 것”입니다. 결합, 재순위(rerank), 정확도 검증까지 실전 코드로 짚습니다.

왜 벡터 검색만으로는 부족한가

벡터 검색은 “의미가 비슷한” 것을 찾습니다. “환불 절차 알려줘”에 “결제를 취소하고 대금을 돌려받는 방법” 같은 청크를 잘 찾는 것이 강점입니다. 그런데 임베딩은 텍스트를 저차원 의미 공간으로 압축하며 “정확한 문자열” 정보는 대체로 흐려집니다. 그래서 다음 질문들에서 벡터 검색은 자주 미끄러집니다.

  • 정확한 식별자: 하나의 토큰이 정답을 결정하는 경우. 임베딩 공간에서 ERR_CONN_2043ERR_CONN_2041은 거의 같은 자리에 놓입니다.
  • 희귀 고유명사: 학습 코퍼스에 거의 없던 신제품명, 내부 코드명을 임베딩이 제대로 표현하지 못합니다.
  • 부정·수치 조건: “2024년 이전“, “10만 원 미만“은 의미가 가까운 반대 문서까지 끌어옵니다.

반대로 BM25 같은 어휘 검색은 정확 일치에 강하지만 동의어·의역에는 약합니다. 즉 두 방식의 실패 지점이 다릅니다. 한쪽이 놓치는 질의를 다른 쪽이 건지는 것, 이것이 하이브리드가 효과를 내는 근본 이유입니다.

BM25는 정확히 무엇을 계산하는가

BM25는 질의어가 문서에 얼마나 “특징적으로” 등장하는지를 점수화하는 랭킹 함수입니다. 세 가지 직관이면 충분합니다. TF(단어 빈도)는 질의어가 많이 나올수록 점수를 올리되 포화시킵니다(k1). IDF(역문서 빈도)는 흔한 단어의 가중치를 낮추고 희귀 토큰(고유 식별자)의 가중치를 높여 정확 일치에 강하게 만듭니다. 문서 길이 정규화는 긴 문서가 길다는 이유만으로 유리해지지 않게 보정합니다(b).

중요한 실무 포인트는 BM25가 토큰화(analyzer)에 크게 좌우된다는 것입니다. 한국어라면 형태소 분석 여부에 따라 결과가 완전히 달라지므로 분석기를 명시적으로 지정해야 합니다.

// Elasticsearch/OpenSearch: 한국어 형태소 분석기(nori)로 BM25 색인
// 정확 일치가 중요한 코드 필드는 keyword 로 별도 보관
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ko": { "type": "custom", "tokenizer": "nori_tokenizer" }
      }
    },
    "similarity": {
      "bm25_tuned": { "type": "BM25", "k1": 1.2, "b": 0.75 }
    }
  },
  "mappings": {
    "properties": {
      "content":  { "type": "text", "analyzer": "ko", "similarity": "bm25_tuned" },
      "doc_code": { "type": "keyword" }  // ERR_CONN_2043 같은 식별자는 원형 보존
    }
  }
}

두 인덱스를 나란히 두기

하이브리드의 첫걸음은 같은 청크 집합을 두 방식으로 색인하는 것입니다. 어휘(BM25) 인덱스와 벡터 인덱스가 같은 chunk_id를 공유해야 결과를 합칠 수 있습니다. 아래는 벡터 쪽을 pgvector로 구성한 예입니다.

-- pgvector: 청크와 임베딩을 한 테이블에. chunk_id 로 BM25 인덱스와 매칭
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
  chunk_id   BIGSERIAL PRIMARY KEY,
  content    TEXT NOT NULL,
  embedding  VECTOR(1024)          -- 임베딩 차원은 모델에 맞춘다
);

-- 코사인 거리 기준 근사 최근접(HNSW) 인덱스
CREATE INDEX ON chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

주의할 점은 청크 경계를 두 인덱스가 공유해야 한다는 것입니다. 벡터용과 BM25용으로 청크를 다르게 자르면 합칠 수 없으므로, 청킹 전략은 하나로 고정해 양쪽에 동일하게 넣습니다.

결과를 합치는 두 방식: RRF와 가중합

두 검색은 척도가 다른 점수를 냅니다. BM25는 상한이 없고 코사인 유사도는 -1~1 범위입니다. 이 둘을 그냥 더하면 스케일 큰 쪽이 결과를 지배합니다. 합치는 방법은 크게 두 가지입니다.

첫째, RRF(Reciprocal Rank Fusion)입니다. 점수 값을 버리고 순위만 씁니다. 각 리스트에서 순위 r인 문서에 1/(k + r)를 주고 기여를 합칩니다. 스케일 차이에 둔감해 튜닝 없이도 안정적이라 기본값으로 삼기 좋습니다.

# RRF: 점수 스케일을 무시하고 순위만으로 결합 (k=60 이 관례적 기본값)
def rrf_fuse(bm25_ids: list[int], vec_ids: list[int], k: int = 60) -> list[int]:
    scores: dict[int, float] = {}
    for rank, cid in enumerate(bm25_ids):      # 순위는 0부터
        scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
    for rank, cid in enumerate(vec_ids):
        scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
    # 합산 점수 내림차순으로 최종 순위 결정
    return [cid for cid, _ in sorted(scores.items(), key=lambda x: -x[1])]

둘째, 가중합(weighted sum)입니다. 두 점수를 각각 min-max로 정규화한 뒤 alpha * vec + (1-alpha) * bm25로 섞습니다. 어휘/의미 비중을 세밀하게 조절할 수 있지만, alpha를 데이터로 튜닝해야 하고 질의마다 점수 분포가 달라 불안정할 수 있습니다.

실무 권장은 RRF로 시작하고 평가셋을 갖춘 뒤에만 가중합으로 넘어가는 것입니다. RRF는 파라미터가 사실상 하나뿐이라 실패 여지가 적은 반면, alpha를 감으로 정한 가중합은 벡터 단독보다 나빠지는 경우가 흔합니다.

재순위(rerank)로 정밀도 끌어올리기

BM25와 벡터 검색은 모두 1차 후보를 넓게 뽑는 데 적합합니다(재현율 위주). 하지만 최종 3~5개를 고를 때는 정밀도가 필요하고, 여기서 크로스 인코더 재순위기가 차이를 만듭니다.

벡터 검색은 질문과 문서를 따로 임베딩해 비교합니다(바이 인코더). 반면 크로스 인코더는 둘을 한 쌍으로 함께 넣어 관련성을 직접 판정합니다. 훨씬 정확하지만 느리므로 전체가 아니라 1차 후보 상위 N개(예: 50개)에만 적용합니다.

# 하이브리드 후보 → 크로스 인코더로 재순위 → 상위 k개만 프롬프트에
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")  # 다국어 재순위기

def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
    # candidates: [{"chunk_id":.., "content":..}, ...]  (1차 후보 50개 정도)
    pairs = [(query, c["content"]) for c in candidates]
    scores = reranker.predict(pairs)                 # 쌍 단위 관련성 점수
    ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
    return [c for c, _ in ranked[:top_k]]            # 최종 컨텍스트만 반환

전체 흐름은 넓게 뽑고(하이브리드) → 정밀하게 좁힌다(재순위)는 2단계 깔때기입니다. 융합으로 50개를 만들고 크로스 인코더로 5개로 줄여 넘기는 구성이 정확도와 비용의 균형점입니다.

전체 파이프라인 조립

조각을 하나의 검색 함수로 엮으면 다음과 같습니다. 두 검색을 병렬로 실행하고 RRF로 합친 뒤 재순위합니다.

import asyncio

async def hybrid_search(query: str, k_first: int = 50, k_final: int = 5):
    # 1) 어휘·벡터 검색을 병렬로 (서로 독립이라 대기시간이 겹치지 않음)
    bm25_task = asyncio.create_task(bm25_search(query, size=k_first))
    vec_task  = asyncio.create_task(vector_search(embed(query), size=k_first))
    bm25_ids, vec_ids = await asyncio.gather(bm25_task, vec_task)

    # 2) RRF 로 순위 융합 → 상위 k_first 개 후보 확정
    fused_ids = rrf_fuse(bm25_ids, vec_ids)[:k_first]

    # 3) 원문 로드 후 크로스 인코더 재순위 → 최종 컨텍스트
    candidates = load_chunks(fused_ids)              # chunk_id -> {content}
    return rerank(query, candidates, top_k=k_final)

운영 세부는 이렇습니다. 후보 폭(k_first)이 너무 작으면 재순위기가 좋은 청크를 볼 기회 자체가 없으므로 30~100에서 시작합니다. 지연의 큰 부분을 차지하는 embed(query)는 캐싱하고, 한쪽 검색이 실패해도 다른 쪽만으로 응답하도록 gather예외 격리와 폴백을 둡니다.

한 가지 더 놓치기 쉬운 것이 메타데이터 필터입니다. 특정 사용자·언어·최신 버전만 대상이어야 하는데 전체를 융합하면 관련 없는 청크가 상위에 올라옵니다. 양쪽 검색에 동일한 필터를 걸어야 하며, 필터 조건을 같은 소스에서 생성해 한쪽에만 걸리는 실수를 차단하는 것이 안전합니다.

-- 벡터 검색에도 BM25 와 같은 메타데이터 필터를 적용
-- (WHERE 를 빼먹으면 다른 테넌트/구버전 청크가 후보에 유입)
SELECT chunk_id, content FROM chunks
WHERE tenant_id = $1 AND lang = 'ko' AND version = 'current'
ORDER BY embedding  $2  -- 코사인 거리 오름차순
LIMIT 50;

정확도가 정말 올라갔는지 측정하기

하이브리드를 붙였다고 정확도가 올랐다고 믿으면 안 됩니다. 반드시 평가셋으로 검증해야 합니다. 생성 품질과 분리해 먼저 검색 단계 자체를 측정하는 것이 진단에 유리합니다. 질의 로그에서 뽑은 질문과 정답 청크(relevant_ids)를 라벨링해 둡니다.

  • Recall@k: 상위 k개 안에 정답 청크가 들어왔는가. RAG에서는 답이 컨텍스트에 들어오기만 하면 되므로 가장 중요합니다.
  • MRR / nDCG: 정답이 얼마나 위쪽에 있는가. 재순위 효과를 볼 때 유용합니다.
# 검색 설정별 Recall@k 비교: 벡터 단독 vs 하이브리드 vs 하이브리드+재순위
def recall_at_k(search_fn, dataset, k=5) -> float:
    hits = 0
    for row in dataset:                          # row: {"q":.., "relevant_ids": {..}}
        got = {c["chunk_id"] for c in search_fn(row["q"])[:k]}
        if got & set(row["relevant_ids"]):       # 정답 청크가 하나라도 들어오면 hit
            hits += 1
    return hits / len(dataset)

print("vector     :", recall_at_k(vector_only, eval_set))
print("hybrid     :", recall_at_k(hybrid_rrf, eval_set))
print("hybrid+rrk :", recall_at_k(hybrid_rerank, eval_set))

이 비교표가 있어야 각 구성 요소가 실제로 기여하는지 판단할 수 있습니다. 재순위기가 Recall@k를 거의 안 올리면서 지연만 늘리는 도메인도, 어휘 검색만으로 식별자 질의 정확도가 뛰는 도메인도 있습니다. 어떤 조합이 이득인지는 일반론이 아니라 데이터가 결정합니다.

마무리

하이브리드 검색의 본질은 단순합니다. 벡터 검색은 의미를, 어휘 검색은 정확한 문자열을 잡으며 두 방식의 실패 지점이 다르기 때문에 합치면 사각지대가 줍니다. 스케일에 둔감한 RRF를 기본으로 삼고, 최종 컨텍스트는 크로스 인코더 재순위로 좁히는 2단계 깔때기가 실전에서 가장 안정적입니다.

다만 청크 경계를 두 인덱스가 공유하고, 필터를 양쪽에 동일하게 걸고, 무엇보다 Recall@k를 평가셋으로 측정하는 것은 반드시 지켜야 합니다. 하이브리드는 거의 항상 벡터 단독보다 낫지만 “얼마나” “어느 질의에서”는 측정해야만 알 수 있습니다. 정확도가 최우선이라면 추측이 아니라 숫자로 각 구성 요소의 값을 증명하는 것이 유일하게 신뢰할 길입니다.