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 성능은 ‘데이터 정리’의 결과물입니다. 이게 참 재미없고, 참 노가다인데요. 그래도 이걸 해두면 이후 개선 속도가 확 달라집니다. “답이 틀린 이유”가 보이기 시작하거든요.
'IT 테크 > AI' 카테고리의 다른 글
| [RAG] GraphRAG: 지식 그래프를 결합해 복잡한 관계형 질문 해결하기 (0) | 2026.03.11 |
|---|---|
| [RAG] Re-ranking 도입 전후 성능 평가: 왜 단순히 상위 K개만 뽑으면 안 되는가? (0) | 2026.03.10 |
| [RAG] RAG의 고질병 '환각(Hallucination)'을 줄이는 3가지 검증 레이어 (0) | 2026.03.09 |
| [RAG] Chunking 전략이 답변의 질을 결정한다: 의미 단위 분할의 기술 (0) | 2026.03.08 |
| [RAG] 벡터 데이터베이스 선택 가이드: Pinecone vs Milvus vs pgvector 성능 비교 (0) | 2026.03.07 |
