Published on

RAG 품질 급락? 벡터DB 하이브리드검색 튜닝

Authors

RAG를 운영하다 보면 어느 날부터 답변이 뜬금없어지거나, 예전엔 잘 맞던 질문에 근거 문서가 엉뚱하게 붙는 순간이 옵니다. 많은 팀이 이때 임베딩 모델 교체부터 시도하지만, 실제 원인은 검색 단계의 하이브리드 구성과 튜닝 값에 있는 경우가 더 많습니다.

이 글은 벡터DB에서 흔히 쓰는 **하이브리드 검색(semantic vector + lexical keyword)**을 기준으로, 품질 급락의 전형적인 원인과 튜닝 절차를 실무 관점에서 정리합니다. 예시는 특정 벡터DB에 종속되지 않게 개념 중심으로 설명하고, 코드도 함께 제공합니다.

RAG 품질이 급락하는 대표 증상 6가지

아래 증상은 하이브리드 검색 튜닝 미스나 데이터 변화로 자주 발생합니다.

  1. 정답 문서가 Top-K에 안 들어온다: 예전엔 1~3위에 있던 근거가 사라짐
  2. 유사하지만 다른 문서가 상위로 뜬다: 용어가 비슷한 다른 제품/정책 문서가 끼어듦
  3. 최신 문서가 검색되지 않는다: 인덱싱은 됐는데 랭킹에서 밀림
  4. 필터 적용 시 결과가 급격히 빈약해진다: 메타데이터 필터가 과도하거나 누락
  5. 긴 문서에서 엉뚱한 단락이 붙는다: 청킹 경계가 나쁘거나, chunk overlap이 부족
  6. 특정 키워드 질문만 유독 약하다: 약어, 코드, 에러 메시지, 제품명 같은 lexical 의존 질의

이 중 1, 2, 6은 하이브리드 검색의 가중치와 리랭킹으로, 3, 4는 인덱싱/메타데이터로, 5는 청킹 전략으로 해결되는 경우가 많습니다.

하이브리드 검색을 “점수 합산”으로 단순화해 이해하기

대부분의 하이브리드 검색은 아래 형태로 생각하면 디버깅이 쉬워집니다.

  • vector_score: 임베딩 유사도(코사인 등)
  • lexical_score: BM25 또는 키워드 매칭 점수
  • 최종 점수: final = alpha * vector_score + (1 - alpha) * lexical_score

여기서 alpha가 핵심 튜닝 파라미터입니다.

  • alpha가 너무 높으면 semantic은 강해지지만, 제품명/코드/약어 질의에서 미끄러질 수 있음
  • alpha가 너무 낮으면 키워드에 과적합되어 동의어/문맥 질의가 약해짐

또 하나 중요한 점은 두 점수의 스케일이 다르다는 사실입니다. 벡터 유사도는 대개 0~1 근처인데, BM25는 문서 길이/term 빈도에 따라 범위가 훨씬 넓을 수 있습니다. 스케일을 맞추지 않으면 alpha를 조정해도 체감이 안 나거나, 특정 질의에서만 폭발적으로 망가집니다.

1단계: “검색 로그”부터 남겨야 튜닝이 된다

RAG 품질 문제를 재현하려면, 최소한 아래를 요청 단위로 남겨야 합니다.

  • 원문 질의, 정규화된 질의(전처리 후)
  • Top-K 후보의 vector_score, lexical_score, final_score
  • 적용된 필터(tenant, locale, product, doc_type 등)
  • 최종 컨텍스트로 선택된 chunk id와 원문 위치

아래는 검색 결과를 점수와 함께 기록하는 간단한 예시입니다.

import json
from datetime import datetime

def log_search(query, results, meta):
    payload = {
        "ts": datetime.utcnow().isoformat() + "Z",
        "query": query,
        "meta": meta,
        "results": [
            {
                "doc_id": r["doc_id"],
                "chunk_id": r.get("chunk_id"),
                "vector_score": r.get("vector_score"),
                "lexical_score": r.get("lexical_score"),
                "final_score": r.get("final_score"),
                "title": r.get("title"),
            }
            for r in results
        ],
    }
    print(json.dumps(payload, ensure_ascii=False))

이 로그가 없으면 튜닝은 감(感)으로 하게 되고, 팀 내 합의도 불가능해집니다.

2단계: 하이브리드 가중치 alpha를 “질의 유형별”로 나누기

실무에서 alpha를 하나로 고정하면, 반드시 한쪽 질의가 손해를 봅니다. 대신 질의 유형을 간단히 분류해 가중치를 다르게 주는 방식이 효과적입니다.

질의 유형 휴리스틱 예시

  • 코드/에러/약어가 많으면 lexical 비중을 높임
  • 자연어 질문(정의, 절차, 비교)이면 vector 비중을 높임
import re

def choose_alpha(query: str) -> float:
    q = query.strip()

    # 에러코드, 스택트레이스, 식별자 패턴
    if re.search(r"\b[0-9]{3,5}\b", q) or re.search(r"[A-Z_]{3,}", q):
        return 0.35  # lexical 강화

    # 특수문자 비율이 높으면(로그/명령어) lexical 강화
    special = sum(1 for c in q if not c.isalnum() and not c.isspace())
    if len(q) > 0 and special / len(q) > 0.12:
        return 0.30

    return 0.70  # 일반 자연어는 vector 강화

이건 정답이 아니라 출발점입니다. 핵심은 질의군별로 최적점이 다르다는 사실을 시스템에 반영하는 것입니다.

3단계: 점수 스케일 정규화 없이는 alpha가 무의미해진다

BM25 점수와 벡터 유사도를 그대로 섞으면 한쪽이 항상 지배합니다. 해결책은 크게 3가지입니다.

  1. min-max 정규화: 같은 쿼리의 Top-K 후보 내에서 0~1로 스케일링
  2. z-score 정규화: 평균/표준편차 기반(단, 분포가 안정적일 때)
  3. rank 기반 결합: 점수 대신 순위를 결합(Reciprocal Rank Fusion 등)

가장 구현이 쉬운 건 쿼리 단위 min-max입니다.

def minmax(scores):
    lo, hi = min(scores), max(scores)
    if hi - lo < 1e-9:
        return [0.0 for _ in scores]
    return [(s - lo) / (hi - lo) for s in scores]

def hybrid_fuse(candidates, alpha: float):
    v = [c["vector_score"] for c in candidates]
    b = [c["lexical_score"] for c in candidates]

    v_n = minmax(v)
    b_n = minmax(b)

    for i, c in enumerate(candidates):
        c["final_score"] = alpha * v_n[i] + (1 - alpha) * b_n[i]

    return sorted(candidates, key=lambda x: x["final_score"], reverse=True)

이렇게만 해도 alpha의 의미가 살아나고, “갑자기 BM25가 다 이겨버리는” 현상을 줄일 수 있습니다.

4단계: Top-K, 후보 풀, 그리고 리랭킹의 관계

운영에서 흔한 실수는 Top-K를 너무 작게 두고 리랭커에 모든 걸 맡기는 것입니다.

  • 1차 검색 Top-K가 5인데 정답이 6~20위에 있다면, 리랭커는 기회조차 없습니다.
  • 반대로 Top-K를 너무 크게 잡으면 비용과 지연이 늘고, 노이즈가 리랭커를 방해할 수 있습니다.

실무 권장 접근은 다음입니다.

  • 1차 검색 후보: K_candidates = 30~200 (데이터 규모에 따라)
  • 리랭킹 후 컨텍스트: K_context = 3~8

특히 하이브리드라면 vector로 넓게, lexical로 좁게 또는 그 반대의 전략을 섞을 수 있습니다.

2-stage 검색 예시

  1. vector로 100개 후보
  2. lexical로 50개 후보
  3. 합집합으로 120개 정도 만들고
  4. cross-encoder 리랭킹으로 최종 5개 선택

이 구조는 “semantic recall”과 “키워드 정확도”를 동시에 확보하기 좋습니다.

5단계: 메타데이터 필터는 “정확도”가 아니라 “리콜”을 먼저 본다

RAG에서 필터는 정밀도를 올리지만, 잘못 걸리면 리콜을 박살냅니다. 특히 멀티테넌트, 문서 버전, 로캘, 제품 라인 같은 필터는 다음을 점검해야 합니다.

  • 필터 값이 인덱싱 시점에 실제로 채워졌는가
  • null 또는 빈 값이 섞여 검색에서 제외되고 있지 않은가
  • 최신 문서만 보려다 “정답이 구버전 문서에만 있는” 상황을 막고 있는가

운영 팁은 필터를 단계적으로 완화하며 품질 변화를 보는 것입니다.

  • product만 적용
  • product + locale
  • product + locale + doc_type

이렇게 좁혀가면 어떤 필터가 리콜을 깎는지 바로 드러납니다.

6단계: 청킹 튜닝은 하이브리드 검색과 함께 봐야 한다

청킹을 너무 작게 하면 lexical에는 유리할 수 있지만 문맥이 끊겨 vector가 약해지고, 너무 크게 하면 vector는 좋아도 BM25가 문서 길이 패널티를 먹거나 키워드 밀도가 떨어질 수 있습니다.

실무에서 자주 통하는 기본값은 아래입니다.

  • 문서 성격이 매뉴얼/정책: chunk 400~800 토큰, overlap 80~150
  • FAQ/짧은 문서: chunk 200~400 토큰, overlap 40~80

그리고 chunk에는 제목, 섹션 경로, 문서 메타를 함께 넣어야 lexical과 vector 모두에 도움이 됩니다.

def build_chunk_text(title, section_path, body):
    # section_path 예: "설치/리눅스/문제해결"
    return f"[TITLE] {title}\n[SECTION] {section_path}\n\n{body}" 

이때 대괄호 태그는 검색에 도움이 되지만, 과도하면 노이즈가 될 수 있으니 고정 포맷을 유지하는 게 중요합니다.

7단계: 리랭커를 넣었는데도 품질이 흔들리면 “후보 분포”를 의심

리랭킹을 붙였는데도 품질이 들쭉날쭉하면, 대개 1차 후보 풀 자체가 불안정합니다.

  • 특정 질의에서 vector 후보가 거의 다 같은 문서의 다른 chunk로 채워짐
  • lexical 후보가 과도하게 짧은 공지/목차 문서로 쏠림

해결책은 다음 중 하나입니다.

  • 문서 단위 디듀프: 같은 doc_id는 최대 n개 chunk만 후보로
  • MMR(Maximal Marginal Relevance): 다양성을 강제
def dedupe_by_doc(candidates, per_doc_limit=2):
    out = []
    seen = {}
    for c in candidates:
        doc = c["doc_id"]
        seen[doc] = seen.get(doc, 0) + 1
        if seen[doc] <= per_doc_limit:
            out.append(c)
    return out

이 한 줄이 “근거는 많은데 전부 같은 문서 조각” 문제를 크게 줄입니다.

8단계: 데이터 운영 이슈도 함께 점검하자(TTL, 캐시, 재시도)

하이브리드 검색 튜닝을 아무리 잘해도 운영 계층에서 데이터가 흔들리면 품질이 급락합니다.

  • 임시 메모리/대화 컨텍스트를 벡터DB에 쌓아두면 노이즈가 누적되어 검색 품질이 떨어질 수 있음
  • 임베딩/업서트 파이프라인이 429나 타임아웃으로 부분 실패하면 최신 문서가 빠질 수 있음

이런 운영 이슈는 아래 글과 같이 함께 점검하면 좋습니다.

실무 튜닝 체크리스트(이 순서대로 하면 빠르다)

  1. 검색 로그에 점수 3종(vector, lexical, final)을 남긴다
  2. alpha를 고정하지 말고 질의 유형별로 다르게 준다
  3. 두 점수의 스케일 정규화를 먼저 한다
  4. 1차 후보 풀을 충분히 크게 잡고, 리랭커는 최종 선택에 집중시킨다
  5. 필터는 단계적으로 적용해 리콜을 먼저 확보한다
  6. 청킹은 제목/섹션 메타를 포함하고, 크기와 overlap을 조정한다
  7. 후보 다양성 확보를 위해 doc 단위 디듀프 또는 MMR을 적용한다
  8. TTL, 인덱싱 실패, 레이트리밋 같은 운영 이슈를 같이 없앤다

마무리: “하이브리드”의 본질은 가중치가 아니라 관측 가능성

RAG 품질 급락을 하이브리드 검색으로 해결하려면, 결국 관건은 alpha 값 하나가 아니라 점수와 후보 분포를 관측하고, 질의군별로 다르게 최적화하는 체계입니다.

오늘 바로 할 수 있는 가장 큰 개선은 두 가지입니다.

  • 점수 정규화 후 alpha를 질의 유형별로 분기
  • 후보 풀을 넓히고 리랭커는 최종 3~8개 선택에 집중

이 두 가지만 적용해도 “갑자기 RAG가 멍청해진 느낌”은 상당 부분 줄어듭니다.