RAG 제대로 쓰기: 청킹부터 검색 최적화까지, 2주 삽질의 기록

작년 11월에 사내 문서 검색 시스템을 만들었는데, 초반에 완전히 망했다. GPT-4를 쓰는데도 답변이 엉터리였다. 알고 보니 문제는 모델이 아니라 검색 자체였다 — 관련 없는 청크가 컨텍스트에 잔뜩 들어가고 있었다. 그때부터 2주 동안 청킹 전략, 벡터 DB, 검색 파이프라인을 뜯어고치면서 배운 것들을 여기 정리해본다.

청킹이 검색 품질의 70%를 결정한다

처음엔 청킹을 대충 생각했다. “그냥 1000 토큰으로 자르면 되지 않나?” 했는데, 이게 가장 큰 착각이었다.

청킹의 핵심 딜레마는 이거다. 청크가 너무 크면 임베딩이 의미를 희석시키고, 너무 작으면 컨텍스트가 부족해서 LLM이 답변을 제대로 못 만든다. 여기서 보통 두 가지 실수를 한다 — 청크 크기만 조정하거나, overlap을 아예 안 주거나.

내 케이스는 사내 기술 문서(마크다운 파일들, 약 2만 페이지)였다. 처음에 RecursiveCharacterTextSplitter로 500자씩 잘랐더니 문장 중간에서 잘리는 경우가 많았다. 특히 코드 블록이 잘리면 그 청크는 검색에서 완전히 쓸모없어진다.

시도 1: Fixed-size chunking — 빠르지만 의미 단위를 무시한다. 테이블이나 코드 블록이 있는 문서엔 최악이다.

시도 2: Semantic chunking — LangChain 0.1.x에 들어온 SemanticChunker를 써봤다. 임베딩 기반으로 의미 변화가 감지되는 지점에서 자른다. 결과는 확실히 좋았는데, 속도가 느리고 청크 크기가 들쭉날쭉해서 벡터 DB 인덱싱이 좀 비효율적이었다.

시도 3: 구조 기반 chunking — 결국 내가 정착한 방식이다. 마크다운 헤더를 기준으로 자르고, 그 안에서 다시 최대 길이 제한을 거는 방식.

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

# 1단계: 헤더 기반으로 문서 구조 분리
header_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#", "h1"),
        ("##", "h2"),
        ("###", "h3"),
    ],
    strip_headers=False  # 헤더 텍스트를 청크에 남겨둔다 — 컨텍스트에 중요
)

# 2단계: 너무 큰 섹션은 다시 자르되, 코드 블록 경계를 우선시
secondary_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    separators=["\n```\n", "\n\n", "\n", " "],  # 코드 블록 경계 우선
)

def chunk_document(text: str, source_metadata: dict) -> list[dict]:
    header_chunks = header_splitter.split_text(text)
    final_chunks = []

    for chunk in header_chunks:
        if len(chunk.page_content) > 800:
            sub_chunks = secondary_splitter.split_text(chunk.page_content)
            for sub in sub_chunks:
                final_chunks.append({
                    "content": sub,
                    "metadata": {**source_metadata, **chunk.metadata}
                })
        else:
            final_chunks.append({
                "content": chunk.page_content,
                "metadata": {**source_metadata, **chunk.metadata}
            })

    return final_chunks

이 방식으로 바꾼 후 검색 관련성이 눈에 띄게 올라갔다. 특히 “XX 기능의 설정 방법”처럼 특정 섹션을 찾는 쿼리에서 효과가 컸다.

한 가지 팁: strip_headers=False로 헤더를 청크 안에 남겨두는 게 중요하다. 나중에 검색했을 때 “이 청크가 어떤 섹션 소속인지”가 LLM한테 힌트가 된다.

벡터 DB 선택 — 내가 삽질한 이야기

팀이 3명인 스타트업 환경에서 벡터 DB를 고를 때 고려할 것들이 생각보다 많다. 나는 Pinecone → Weaviate → pgvector 순서로 갔다.

Pinecone: 처음엔 편했다. 관리형 서비스라 인프라 걱정이 없고, SDK도 깔끔하다. 근데 문서가 100만 개 넘어가니까 비용이 급격히 올라갔다. 그리고 — 이건 나만 느낀 건지 모르겠는데 — 메타데이터 필터링이 생각보다 제한적이었다. 복잡한 조건을 걸면 쿼리가 느려졌다.

Weaviate: 기능이 많다. Hybrid search가 내장되어 있고, GraphQL API도 있다. 근데 self-hosted로 운영할 때 설정이 복잡하고, 메모리를 꽤 많이 먹는다. 도커로 올렸을 때 8GB 컨테이너가 기본이었다. 작은 팀한테는 관리 부담이 좀 있었다.

pgvector: 결국 여기로 왔다. 이미 PostgreSQL을 쓰고 있으니까 추가 인프라가 없다는 게 결정적이었다. pgvector 0.5.0에서 HNSW 인덱스가 들어오면서 성능이 많이 올라왔다. 백만 건 이하 규모에서는 충분히 빠르다.

한 가지 삽질을 공유하자면 — pgvector에서 처음에 IVFFlat 인덱스를 썼는데, 데이터를 다 넣기 전에 인덱스를 만들었다. IVFFlat은 인덱스 생성 시점의 데이터 분포를 기반으로 클러스터를 만들기 때문에, 나중에 데이터가 많이 추가되면 검색 품질이 떨어진다. HNSW는 이 문제가 없다. 지금은 무조건 HNSW를 쓴다.

-- IVFFlat 대신 이걸 쓰자
CREATE INDEX ON documents 
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 검색 시 ef_search 파라미터로 정확도/속도 트레이드오프 조절 가능
SET hnsw.ef_search = 100;  -- 기본값은 40, 높을수록 정확하지만 느림

1천만 건 이상이라면 pgvector만으론 부족할 수 있다. 그 규모면 Qdrant나 Weaviate가 맞을 것 같은데, 나는 아직 그 규모를 경험해보지 못해서 확신은 없다.

Hybrid Search와 Reranking이 검색을 바꾼 방식

순수 벡터 검색만으로는 한계가 있다. 특히 고유명사, 제품 코드, 오타가 섞인 쿼리에서 취약하다. “k8s ingress 502 에러”처럼 키워드가 명확한 쿼리를 벡터 검색으로만 처리하면 오히려 엉뚱한 결과가 나온다.

Hybrid search는 BM25(키워드 검색)와 벡터 검색 결과를 합치는 방식이다. 두 결과를 합칠 때 RRF(Reciprocal Rank Fusion)를 주로 쓴다.

from rank_bm25 import BM25Okapi
import numpy as np

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    """
    여러 랭킹 리스트를 RRF로 합친다.
    k=60은 Cormack et al. 2009 논문에서 권장한 값 — 실험해보니 대부분 케이스에서 잘 됨.
    """
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

def hybrid_search(query: str, top_k: int = 20) -> list[dict]:
    # 벡터 검색
    query_embedding = embed(query)
    vector_results = vector_db.search(query_embedding, top_k=top_k)

    # BM25 키워드 검색
    bm25_results = bm25_index.search(query, top_k=top_k)

    # RRF로 합치기
    fused = reciprocal_rank_fusion([
        [r["id"] for r in vector_results],
        [r["id"] for r in bm25_results],
    ])

    return [fetch_doc(doc_id) for doc_id, _ in fused[:top_k]]

hybrid search만으로 부족할 때가 있다. 상위 20개를 가져왔는데 그 중에서 실제로 관련 있는 건 3~4개인 경우 — 그럼 LLM에 20개 청크를 다 보내는 건 낭비다.

이때 reranking이 들어온다. Cohere의 rerank API나 cross-encoder/ms-marco-MiniLM-L-6-v2 같은 cross-encoder 모델을 쓰면 된다. Bi-encoder(임베딩 모델)와 달리 cross-encoder는 쿼리-문서 쌍을 같이 처리하기 때문에 관련성 판단이 훨씬 정확하다. 대신 속도가 느려서 보통 top-20 → rerank → top-5 식으로 파이프라인을 구성한다.

내 경험상 reranker 하나 추가했을 때 체감 품질이 가장 많이 올라갔다. 청킹이나 벡터 DB를 뜯어고치기 전에 reranker부터 붙여보는 걸 권한다 — 청킹을 아무리 잘 해도 검색 단계에서 노이즈가 들어오면 LLM이 헷갈리는데, reranker가 그걸 상당 부분 걸러준다. 실제로 내 경우엔 reranker 추가 후 “이 답변이 맞나요?” 사용자 피드백이 눈에 띄게 줄었다.

메타데이터 필터링으로 검색 범위를 좁히는 법

직접 겪어보니 검색 품질 문제의 상당 부분이 사실은 “관련 없는 문서 유형에서 검색이 일어나는 것”이었다.

예를 들어 우리 문서 베이스엔 API 레퍼런스, 튜토리얼, 릴리즈 노트, 내부 설계 문서가 섞여 있었다. 사용자가 “인증 토큰 갱신하는 법”을 물어볼 때 릴리즈 노트에서 “v2.3.0 — 인증 토큰 갱신 로직 변경” 같은 게 상위에 올라오는 문제가 있었다. 의미적으로는 관련 있지만, 실제로 원하는 건 방법론적인 설명이지 버전 변경 이력이 아니다.

해결 방법은 간단했다. 청크 생성 시점에 문서 유형을 메타데이터로 저장하고, 쿼리 의도에 따라 필터를 적용했다.

# 쿼리 라우팅 — 간단한 분류기로 충분하다
def classify_query(query: str) -> dict:
    # GPT-4o-mini로 빠르게 분류 (비용 최소화)
    response = llm.invoke(
        f"다음 질문의 의도를 분류하세요: '{query}'\n"
        "옵션: how-to, troubleshooting, reference, changelog\n"
        "단어 하나로만 답하세요."
    )
    intent = response.content.strip().lower()

    filter_map = {
        "how-to": {"doc_type": {"$in": ["tutorial", "guide"]}},
        "troubleshooting": {"doc_type": {"$in": ["guide", "faq"]}},
        "reference": {"doc_type": "api-reference"},
        "changelog": {"doc_type": "release-notes"},
    }

    return filter_map.get(intent, {})  # 분류 실패 시 필터 없이

이 방식의 단점은 쿼리 분류가 틀리면 검색 자체가 망한다는 것이다. 그래서 분류 신뢰도가 낮을 때는 필터를 아예 안 거는 폴백 로직을 넣었다.

실제로 내가 추천하는 스택

“상황에 따라 다르다”는 말은 너무 무책임하니까, 내 경험 기반으로 구체적으로 말하겠다.

문서 수 10만 건 이하에 팀이 작다면: pgvector + 구조 기반 청킹 + hybrid search (BM25 + vector) + cross-encoder/ms-marco-MiniLM-L-6-v2 reranker. 추가 인프라 없고, 운영 비용 낮고, 성능도 충분하다. Pinecone 같은 관리형 서비스 비용 내느니 이 조합으로 가겠다.

100만 건 이상이거나 다국어 지원이 필요하다면 Qdrant를 진지하게 고려한다. Rust로 만들어져서 메모리 효율이 좋고, 필터링 기능이 pgvector보다 유연하다. 한국어 BM25는 kiwipiepy + 형태소 분석기를 붙여서 쓰는 게 영문 BM25보다 확실히 낫다.

청킹 전략은 문서 구조에 따라 갈린다. 마크다운이나 HTML처럼 구조가 있으면 구조 기반으로. 구조 없는 긴 텍스트면 SemanticChunker가 품질은 좋지만 느리다는 거 감안해야 한다. 인덱싱을 실시간으로 해야 하는 상황이면 recursive character splitting에 충분한 overlap(10~15%)을 주는 게 실용적이다.

그리고 아무리 검색 파이프라인을 잘 만들어도 평가 없이는 방향을 모른다. RAGAS 같은 프레임워크로 faithfulness, answer relevancy, context recall을 주기적으로 측정하는 게 진짜 중요하다. 나는 처음 한 달을 감으로 튜닝했는데, 지표 넣고 나서야 내가 착각했던 부분들이 보였다. 감으로 개선됐다고 느꼈던 것 중 일부는 실제론 별 차이가 없었고, 오히려 신경 안 썼던 부분에서 점수가 떨어지고 있었다.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top