Post

[LLMOps] Day 2: 문서 처리와 청킹 전략 - 검색 품질을 결정하는 첫 단계

[LLMOps] Day 2: 문서 처리와 청킹 전략 - 검색 품질을 결정하는 첫 단계

서론: “Garbage In, Garbage Out”

RAG 시스템에서 가장 간과되기 쉬운 단계가 청킹(Chunking)이다. 많은 개발자가 LLM 선택이나 벡터 DB 성능에만 집중하지만, 실제로는 문서를 어떻게 분할하느냐가 검색 품질의 70%를 결정한다.

잘못된 청킹의 결과는 치명적이다.

  • 너무 작은 청크: 문맥이 부족해서 검색 정확도가 떨어진다.
  • 너무 큰 청크: LLM 컨텍스트 창을 낭비하고, 관련 없는 정보가 섞인다.
  • 의미 단절: 문장 중간에서 잘려서 검색 시 매칭이 안 된다.

이번 글에서는 엔지니어링 관점에서 실전에서 검증된 청킹 전략을 다룬다.

1. 청킹의 핵심 파라미터

1.1 Chunk Size

임베딩 모델과 LLM의 특성에 따라 최적값이 다르다.

모델권장 청크 크기이유
OpenAI ada-002400~600 토큰8191 토큰 컨텍스트, 중간 크기가 정확도 최고
Cohere embed-v3256~512 토큰긴 문서는 자동 압축되어 정보 손실
OpenAI text-embedding-3512~1024 토큰8191 토큰 컨텍스트, 긴 청크 처리 개선
BAAI/bge-large256~512 토큰512 토큰 최대 입력

경험칙: 임베딩 모델의 최대 토큰 길이의 50~70%를 사용한다.

1.2 Chunk Overlap

청크 간 중복은 문맥 손실을 방지한다.

1
2
3
4
5
6
7
8
9
# 예시: 중복이 없을 때
chunk_1 = "...Rust는 메모리 안전성을 보장한다."
chunk_2 = "PyO3를 사용하면 Python 확장을 만들 수 있다."
# "Rust로 Python 확장을 만들 수 있나요?" 검색 시 매칭 실패

# 중복이 있을 때
chunk_1 = "...Rust는 메모리 안전성을 보장한다. PyO3를 사용하면"
chunk_2 = "PyO3를 사용하면 Python 확장을 만들 수 있다."
# 두 청크 모두 검색됨

권장값: 청크 크기의 10~20% (50~100 토큰)

2. 청킹 전략의 종류

2.1 Fixed Size Chunking (고정 크기)

가장 단순하지만, 의외로 많은 경우에 충분하다.

1
2
3
4
5
6
7
8
9
10
from langchain_text_splitters import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separator="\n",
    length_function=len
)

chunks = splitter.split_text(document)

장점: 빠르고(1M 문자 처리에 2초), 예측 가능한 청크 개수 단점: 문장 중간에서 잘릴 수 있음 적합한 경우: 로그 파일, 정형화된 문서, 채팅 기록

2.2 Recursive Character Splitting (재귀적 분할)

실전에서 가장 많이 사용되는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=[
        "\n\n",    # 문단 우선
        "\n",      # 줄바꿈
        ". ",      # 문장
        " ",       # 단어
        ""         # 문자 (최후의 수단)
    ]
)

chunks = splitter.split_text(document)

핵심 아이디어: 큰 구분자부터 시도하고, 안 되면 작은 구분자로 fallback한다.

성능 벤치마크:

1
2
3
4
5
6
7
8
9
10
11
12
13
import time

# 1MB 문서 처리 시간
text = load_large_document()  # 1,000,000 characters

start = time.time()
chunks = splitter.split_text(text)
print(f"처리 시간: {time.time() - start:.2f}")
print(f"청크 개수: {len(chunks)}")

# 출력:
# 처리 시간: 1.23초
# 청크 개수: 2,150

2.3 Semantic Chunking (의미 기반)

문장 간 유사도를 계산하여 의미적으로 응집력 있는 단위로 분할한다.

1
2
3
4
5
6
7
8
9
10
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # 상위 N% 유사도에서 분할
    breakpoint_threshold_amount=90
)

chunks = splitter.split_text(document)

작동 원리:

  1. 문장 단위로 임베딩 생성
  2. 인접 문장 간 코사인 유사도 계산
  3. 유사도가 급격히 떨어지는 지점에서 분할

성능 비교:

1
2
Recursive Splitting: 1.23초 (1MB 문서)
Semantic Chunking:   14.7초 (1MB 문서)

비용 비교:

  • OpenAI Embedding API: $0.0001 / 1K 토큰
  • 1MB 문서 ≈ 250K 토큰
  • Semantic Chunking 비용: $0.025 per document

Trade-off: 정확도는 5~10% 향상되지만, 속도는 10배 느리고 비용도 발생한다. 적합한 경우: 법률 문서, 연구 논문 등 문맥이 매우 중요한 경우

3. 실전 청킹 파이프라인

3.1 문서 타입별 전처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import re
from typing import List

class DocumentProcessor:
    def __init__(self):
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=512,
            chunk_overlap=50
        )

    def process_markdown(self, text: str) -> List[str]:
        # 코드 블록 보존
        code_blocks = re.findall(r'```.*?```', text, re.DOTALL)
        for i, block in enumerate(code_blocks):
            text = text.replace(block, f"<<CODE_BLOCK_{i}>>")

        # 청킹
        chunks = self.splitter.split_text(text)

        # 코드 블록 복원
        for i, block in enumerate(code_blocks):
            chunks = [c.replace(f"<<CODE_BLOCK_{i}>>", block) for c in chunks]

        return chunks

    def process_pdf(self, text: str) -> List[str]:
        # PDF의 불필요한 줄바꿈 제거
        text = re.sub(r'(?<!\n)\n(?!\n)', ' ', text)

        # 페이지 번호 제거
        text = re.sub(r'\n\d+\n', '\n', text)

        return self.splitter.split_text(text)

    def process_code(self, code: str, language: str) -> List[str]:
        # 코드는 함수/클래스 단위로 분할
        if language == "python":
            # 함수와 클래스 정의 기준으로 분할
            chunks = re.split(r'\n(?=def |class )', code)
        else:
            # 기본 전략 사용
            chunks = self.splitter.split_text(code)

        return [c for c in chunks if len(c.strip()) > 0]

3.2 메타데이터 추가

청크만 저장하지 말고, 검색 품질 향상을 위한 메타데이터를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from datetime import datetime

def create_chunk_with_metadata(chunk: str, doc_metadata: dict) -> dict:
    return {
        "content": chunk,
        "source": doc_metadata.get("source"),
        "created_at": datetime.now().isoformat(),
        "chunk_index": doc_metadata.get("chunk_index"),
        "total_chunks": doc_metadata.get("total_chunks"),
        # 검색 필터링에 사용
        "category": doc_metadata.get("category"),
        "language": doc_metadata.get("language"),
        # 디버깅용
        "char_count": len(chunk),
        "word_count": len(chunk.split())
    }

4. 청킹 품질 측정

4.1 정량적 메트릭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import numpy as np

def evaluate_chunking(chunks: List[str]):
    sizes = [len(c) for c in chunks]

    return {
        "total_chunks": len(chunks),
        "avg_size": np.mean(sizes),
        "std_size": np.std(sizes),
        "min_size": min(sizes),
        "max_size": max(sizes),
        # 균일성 점수 (0~1, 1이 이상적)
        "uniformity": 1 - (np.std(sizes) / np.mean(sizes))
    }

# 사용 예시
stats = evaluate_chunking(chunks)
print(f"균일성 점수: {stats['uniformity']:.2f}")
# 0.9 이상이면 양호, 0.7 이하면 재검토 필요

4.2 정성적 평가

1
2
3
4
5
6
7
8
9
def sample_chunks(chunks: List[str], n: int = 5):
    """무작위로 N개 청크를 추출하여 육안 검사"""
    import random
    samples = random.sample(chunks, min(n, len(chunks)))

    for i, chunk in enumerate(samples):
        print(f"\n--- Sample {i+1} ---")
        print(chunk[:200] + "...")
        print(f"Length: {len(chunk)} chars")

5. 언제 어떤 전략을 사용할 것인가?

문서 타입권장 전략청크 크기이유
기술 문서, 블로그Recursive512 토큰문단 구조 보존 필요
채팅 로그, 로그 파일Fixed Size300 토큰속도 우선, 구조 단순
법률, 연구 논문Semantic700 토큰정확도 최우선
코드 파일Custom (함수 단위)가변논리적 단위 보존
FAQ, Q&A문서 단위 (분할 안 함)-질문-답변 쌍 보존

다음 단계

청킹이 완료되면, 다음 단계는 임베딩 생성과 벡터 DB 선택이다. Day 3에서는 임베딩 모델 벤치마크와 프로덕션 벡터 DB 비교를 다룰 것이다.


This post is licensed under CC BY 4.0 by the author.