[RAG] 단순 RAG를 넘어: 고도화된 하이브리드 검색(Keyword + Semantic) 설계하기

RAG가 유행이라길래 대충 벡터 DB에 때려 넣으셨나요? 솔직히 저도 처음엔 그랬습니다. “문서 넣고, 임베딩하고, 검색해서 LLM에 던지면 끝”인 줄 알았는데요. 실제 서비스에 붙이자마자 정확도는 들쭉날쭉, 운영은 난이도 급상승이더군요.

특히 문서를 “기계적으로 잘라서” 넣는 순간부터 문제가 시작됩니다. 문장 하나가 반으로 잘리고, 표/코드/약관 조항이 끊기고, 그 조각을 근거로 모델이 답을 만들면… 결과는 뻔합니다. 검색 품질이 흔들리면 RAG는 곧바로 엉뚱한 말을 합니다.

 

단순 토큰 분할의 함정: “잘라 넣었더니 잘라 먹습니다”

제가 겪었던 케이스 하나 말씀드리겠습니다. 고객센터 환불 정책 문서를 그냥 512 토큰으로 잘라서 벡터 DB에 넣었습니다. 질문은 단순했습니다. “부분 환불 가능한가요?”

근데 답변이 이렇게 나가더군요: “부분 환불은 불가하며, 예외 조항도 없습니다.”… 문제는 실제 문서엔 “특정 조건에서 부분 환불 가능” 조항이 있었어요. 왜 이런 일이 생겼냐면,

  • “부분 환불 가능” 문장이 중간에서 끊겨서 다음 청크로 넘어감
  • 이전 청크에는 “원칙적으로 불가”만 남아 있음
  • 검색은 그 청크만 잡아오고, LLM은 그걸 근거라고 믿고 단정적으로 답함

이게 참 골치 아픕니다. 서비스 운영 입장에선 “근거가 있는데도 답이 틀린” 상황이라 더 위험하거든요. 사용자는 자신 있게 틀린 답을 제일 싫어합니다.

 

하이브리드 검색을 하는 이유: 키워드와 의미는 서로 보완재입니다

현업에서 검색 품질을 안정시키려면 Keyword(BM25/역색인)Semantic(벡터)를 같이 씁니다. 한쪽만 믿으면 꼭 구멍이 생깁니다.

키워드 검색은 이런 데 강합니다:

  • 정확한 용어: 에러코드(1046, 1093), 주문번호, SKU, 정책 조항 번호
  • 희귀 토큰: 서비스 내부 약어, 테이블명, 변수명
  • 문서가 길어도 “정확히 그 단어가 있는 곳”을 잘 찍음

시맨틱 검색은 이런 데 강합니다:

  • 표현이 달라도 의미가 같은 질문: “해지” vs “구독 취소” vs “멤버십 종료”
  • 문장이 길고 서술적인 문서: 가이드/FAQ/정책/설계문서
  • 키워드를 정확히 모르는 사용자 질문

결국 결론은 간단합니다. 키워드는 정밀 타격, 시맨틱은 포괄 탐색입니다. 둘 다 있어야 운영이 편해집니다.

 

실전에서 “하이브리드”는 보통 이렇게 굴립니다

  • 1차 후보: BM25 Top-K + Vector Top-K를 각각 뽑아서 합치기(Union)
  • 정리: 중복 제거 + 메타데이터 필터(서비스, 버전, 언어, 날짜)
  • 재정렬: Reranker(크로스 인코더)로 Top-N만 다시 점수 매기기
  • 최종 컨텍스트: “답변에 필요한 만큼만” 넣기(너무 많이 넣으면 LLM이 산만해짐)

 

분할(Chunking)부터 다시: RecursiveCharacterTextSplitter의 한계

LangChain에서 흔히 쓰는 RecursiveCharacterTextSplitter는 편합니다. 근데 운영 관점에서 보면 한계가 명확합니다.

  • 문단/문장 경계를 존중한다고는 하지만, 표/코드/목차/조항 같은 구조엔 약합니다
  • 길이 기준이 강해서 결국 의미 단위가 깨질 확률이 있습니다
  • 오버랩을 늘리면 품질이 좋아지긴 하는데… 인덱스 용량/비용/중복근거가 폭증합니다

비유를 하나 들면, 전공 서적을 공부해야 하는데 누가 책을 페이지 단위가 아니라 무작위로 찢어서 봉투에 넣어 준 느낌입니다. 제목/정의/예제가 따로 놀면 이해가 될까요?

 

그래서 Semantic Chunking(의미론적 분할)이 필요합니다

의미론적 분할은 “길이”보다 “내용 흐름”을 우선합니다. 현실적인 접근은 이렇습니다:

  • 헤더 기준: Markdown의 #, ##, ### 또는 HTML의 h1/h2/h3를 경계로 자르기
  • 조항 기준: 약관/정책은 번호(예: 3.2, 제10조) 단위로 고정 분할
  • 코드/표 보호: 코드블록/테이블은 덩어리째 유지(절대 중간 분할 금지)
  • 의미 유사도 기반: 문장 임베딩 유사도가 급격히 떨어지는 지점에서 끊기

이렇게 해두면 검색으로 뽑혀온 청크 자체가 “근거로서 온전”해집니다. 나중에 LLM이 요약하든 답변하든, 최소한 자료가 찢겨 있지는 않게 되는 거죠.

 

구현: 하이브리드 검색 + 의미 단위 청킹(현실 버전)

아래 코드는 “실무에서 굴러가는 형태”를 최대한 흉내 낸 예시입니다. 중요한 건 라이브러리보다 데이터 구조평가 루프입니다.

 

1) 의미 단위로 문서 쪼개기(헤더+코드블록 보호)

import re
from dataclasses import dataclass
from typing import List, Dict, Any

@dataclass
class Chunk:
    text: str
    meta: Dict[str, Any]

def split_markdown_semantic(md: str, base_meta: Dict[str, Any]) -> List[Chunk]:
    """
    아주 단순한 의미 기반 분할 예시:
    - 헤더(#/##/###) 기준으로 섹션 유지
    - 코드블록은 통째로 보호
    """
    # 코드블록 보호를 위해 임시 토큰으로 치환
    code_blocks = []
    def _stash_code(m):
        code_blocks.append(m.group(0))
        return f"__CODE_BLOCK_{len(code_blocks)-1}__"

    protected = re.sub(r"```.*?```", _stash_code, md, flags=re.DOTALL)

    # 헤더 기준 분할
    parts = re.split(r"(?m)^(#{1,3}\s+.+)$", protected)
    # parts: [pre, header1, body1, header2, body2, ...] 형태
    chunks: List[Chunk] = []
    cur_header = "ROOT"
    cur_body = []

    for i, part in enumerate(parts):
        if re.match(r"^#{1,3}\s+", part.strip()):
            # flush
            if cur_body:
                text = "\n".join(cur_body).strip()
                if text:
                    chunks.append(Chunk(text=text, meta={**base_meta, "section": cur_header}))
            cur_header = part.strip()
            cur_body = [cur_header]
        else:
            cur_body.append(part)

    # last flush
    text = "\n".join(cur_body).strip()
    if text:
        chunks.append(Chunk(text=text, meta={**base_meta, "section": cur_header}))

    # 코드블록 복원
    restored = []
    for ch in chunks:
        t = ch.text
        for idx, cb in enumerate(code_blocks):
            t = t.replace(f"__CODE_BLOCK_{idx}__", cb)
        restored.append(Chunk(text=t, meta=ch.meta))

    return restored


# 이 수치는 정답이 아닙니다. 직접 돌려보며 찾으세요.
# 헤더 분할만으로도 “문장 반갈죽” 사고가 확 줄어듭니다.

2) 하이브리드 검색(키워드+벡터) 후보를 합치고, 재정렬까지

from typing import Tuple

def hybrid_retrieve(
    query: str,
    bm25_search,      # callable: (query, k) -> List[Tuple[doc_id, score, text, meta]]
    vector_search,    # callable: (query, k) -> List[Tuple[doc_id, score, text, meta]]
    rerank,           # callable: (query, docs) -> List[Tuple[doc_id, score]]
    k_bm25: int = 20,
    k_vec: int = 20,
    k_final: int = 8,
    filters: dict | None = None,
):
    """
    실무 패턴:
    - BM25 TopK + Vector TopK를 union
    - 메타데이터 필터
    - reranker로 TopN 재정렬
    """
    bm25_hits = bm25_search(query, k_bm25)
    vec_hits  = vector_search(query, k_vec)

    # union by doc_id
    pool = {}
    for doc_id, score, text, meta in bm25_hits + vec_hits:
        if doc_id not in pool:
            pool[doc_id] = {"doc_id": doc_id, "text": text, "meta": meta, "score_hint": 0.0}
        pool[doc_id]["score_hint"] = max(pool[doc_id]["score_hint"], float(score))

    docs = list(pool.values())

    # metadata filter (예: service, lang, version 등)
    if filters:
        def ok(d):
            for k, v in filters.items():
                if d["meta"].get(k) != v:
                    return False
            return True
        docs = [d for d in docs if ok(d)]

    # rerank (비용 이슈 때문에 pool 전체 rerank는 보통 피합니다)
    # 코멘트: 이 수치는 정답이 아닙니다. 직접 돌려보며 찾으세요.
    shortlist = sorted(docs, key=lambda x: x["score_hint"], reverse=True)[:40]

    reranked = rerank(query, shortlist)  # returns list of (doc_id, score)
    score_map = {doc_id: score for doc_id, score in reranked}

    final = sorted(shortlist, key=lambda x: score_map.get(x["doc_id"], -1e9), reverse=True)[:k_final]
    return final

# 주의:
# reranker는 정확도는 잘 올리는데 비용/지연이 늘어납니다.
# 운영에서는 “Top 40만 rerank” 같은 타협이 현실적이더군요.

운영 팁: 하이브리드 설계에서 자주 터지는 지점

  • 필터 설계: “서비스/국가/언어/버전/시점” 메타데이터 없으면 검색이 섞여서 답이 흔들립니다
  • 중복 근거: 오버랩 과다 + 유사 청크 과다로 컨텍스트가 비대해지면 LLM이 오히려 판단을 못 합니다
  • 평가 데이터셋: 검색 품질은 감으로 못 잡습니다. 질문-정답-근거 세트를 최소 50개라도 만들어야 합니다. 솔직히 이건 노가다죠
  • 로그: “유저 질문 / 검색 TopK / 최종 근거 / 모델 답변”을 남겨야 다음 개선이 됩니다. 안 남기면 끝없이 재현불가 장애 납니다

 

핵심 요약

하이브리드 검색은 “키워드로 정확히 찍고, 시맨틱으로 의미를 따라가고, rerank로 최종 정렬”하는 운영형 조합입니다.
그리고 그 출발점은 모델이 아니라 청킹과 메타데이터입니다.

 

마무리: 결국 성능은 모델이 아니라 데이터 노가다에서 갈립니다

정리하면요, RAG 품질은 “어떤 모델 쓰느냐”보다 “어떤 근거를 얼마나 온전하게 가져오느냐”에서 승부가 납니다. 모델은 그 다음입니다.

운영을 해보면 다들 비슷한 데서 삽질하더군요. 문서가 찢겨 들어가 있고, 메타데이터가 없고, 검색이 섞이고, 로그가 없어서 재현이 안 되고… 그러다 결국 모델 탓을 합니다.

냉정하게 말하면, AI 성능은 ‘데이터 정리’의 결과물입니다. 이게 참 재미없고, 참 노가다인데요. 그래도 이걸 해두면 이후 개선 속도가 확 달라집니다. “답이 틀린 이유”가 보이기 시작하거든요.