RAG 챗봇 만들기 4 - 텍스트 청킹하기

learning by Seven Fingers Studio 10분
RAG청킹ChunkingLangChainText SplitterPython

블로그 대표 이미지

처음에 청크 사이즈를 500자로 했더니 검색 정확도가 영 별로였어요. 문장이 잘리면서 의미가 끊기더라고요. 1000자로 올리고 overlap 200 주니까 훨씬 나아졌어요. 청킹 설정이 생각보다 RAG 성능에 영향을 많이 줘요.

오늘은 문서를 적당한 크기로 쪼개는 청킹(Chunking)을 배울 거예요. 왜 필요한지부터 시작해볼게요.

왜 청킹이 필요해요?

이유 1: LLM 컨텍스트 제한

LLM은 한 번에 처리할 수 있는 텍스트 양이 정해져 있어요 (토큰 제한).

  • GPT-3.5: 약 4,000 토큰 (~3,000자)
  • GPT-4: 약 8,000~128,000 토큰

100페이지짜리 문서를 통째로 넣을 수가 없어요.

이유 2: 검색 정확도

작은 단위로 쪼개야 관련 있는 부분만 정확하게 찾을 수 있어요.

[전체 문서 100페이지]
  → "연차 규정" 검색
  → 전체 문서가 결과로 나옴 (너무 넓음)

[1000자씩 쪼갠 청크들]
  → "연차 규정" 검색
  → 연차 관련 청크만 정확하게 찾음

이유 3: 비용 절감

LLM API는 토큰 단위로 과금돼요. 필요한 부분만 보내면 비용이 줄어요.

Text Splitter 종류

LangChain에서 제공하는 스플리터들이에요:

1. CharacterTextSplitter

단순히 글자 수로 자르는 방식:

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=1000,      # 청크 크기
    chunk_overlap=200,    # 겹치는 부분
    separator="\n"        # 자르는 기준
)

2. RecursiveCharacterTextSplitter (추천)

문단 → 문장 → 단어 순으로 자연스럽게 자르는 방식:

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]
)

얘가 제일 많이 쓰여요. 문장이 중간에 잘리는 걸 최대한 방지해줘요.

블로그 대표 이미지

실습: 문서 청킹하기

기본 청킹

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader

# 1. 문서 로드
loader = PyPDFLoader("docs/sample.pdf")
documents = loader.load()
print(f"원본 문서: {len(documents)}개 페이지")

# 2. 청킹
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200
)
chunks = splitter.split_documents(documents)
print(f"청킹 후: {len(chunks)}개 청크")

# 3. 결과 확인
print(f"\n첫 번째 청크:")
print(f"길이: {len(chunks[0].page_content)}자")
print(f"내용: {chunks[0].page_content[:200]}...")
print(f"메타데이터: {chunks[0].metadata}")

실행 결과:

원본 문서: 5개 페이지
청킹 후: 18개 청크
첫 번째 청크:
길이: 987자
내용: 회사 취업규칙 제1조(목적) 이 규칙은 주식회사...
메타데이터: {'source': 'docs/sample.pdf', 'page': 0}

chunk_overlap이 뭐예요?

청크가 겹치는 부분이에요. 왜 겹쳐야 할까요?

overlap 없이 자르면:
[청크1: ...중요한 내용의 앞부분] [청크2: 뒷부분은 여기에...]
→ 문맥이 끊겨서 의미 파악이 어려움

overlap 있으면:
[청크1: ...중요한 내용의 앞부분 | 중간 겹침]
                             [청크2: 중간 겹침 | 뒷부분은 여기에...]
→ 겹치는 부분이 있어서 문맥이 이어짐

보통 chunk_size의 10~20% 정도로 설정해요.

chunk_size 정하기

정답은 없지만, 경험상 이래요:

용도chunk_sizechunk_overlap
짧은 Q&A50050
일반 문서1000200
긴 맥락 필요2000400

저는 보통 1000/200으로 시작하고, 검색 품질 보면서 조절해요.

청킹 품질 확인하기

청크가 잘 나뉘었는지 확인하는 코드:

def analyze_chunks(chunks):
    """청크 분석"""
    lengths = [len(c.page_content) for c in chunks]

    print(f"총 청크 수: {len(chunks)}")
    print(f"평균 길이: {sum(lengths) / len(lengths):.0f}자")
    print(f"최소 길이: {min(lengths)}자")
    print(f"최대 길이: {max(lengths)}자")

    # 너무 짧은 청크 확인
    short_chunks = [c for c in chunks if len(c.page_content) < 100]
    if short_chunks:
        print(f"\n⚠️ 100자 미만 청크 {len(short_chunks)}개:")
        for c in short_chunks[:3]:
            print(f"  - {c.page_content[:50]}...")

analyze_chunks(chunks)

실행 결과:

총 청크 수: 18
평균 길이: 892자
최소 길이: 234자
최대 길이: 1000자
⚠️ 100자 미만 청크 0개

메타데이터 활용하기

청크에 추가 정보를 붙이면 나중에 유용해요:

from langchain.text_splitter import RecursiveCharacterTextSplitter

def chunk_with_metadata(documents, source_name):
    """메타데이터 추가하며 청킹"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200
    )

    chunks = splitter.split_documents(documents)

    # 각 청크에 추가 메타데이터
    for i, chunk in enumerate(chunks):
        chunk.metadata["chunk_id"] = i
        chunk.metadata["source_name"] = source_name
        chunk.metadata["chunk_size"] = len(chunk.page_content)

    return chunks

# 사용
chunks = chunk_with_metadata(documents, "회사규정집")
print(chunks[0].metadata)

실행 결과:

{
'source': 'docs/sample.pdf',
'page': 0,
'chunk_id': 0,
'source_name': '회사규정집',
'chunk_size': 987
}

전체 파이프라인 코드

지금까지 배운 걸 합치면:

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

def load_and_chunk(file_path: str, chunk_size: int = 1000) -> list:
    """문서 로드 + 청킹 파이프라인"""

    # 1. 로드
    loader = PyPDFLoader(file_path)
    documents = loader.load()
    print(f"✓ {len(documents)}개 페이지 로드")

    # 2. 청킹
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=int(chunk_size * 0.2)  # 20% overlap
    )
    chunks = splitter.split_documents(documents)
    print(f"✓ {len(chunks)}개 청크 생성")

    # 3. 메타데이터 추가
    for i, chunk in enumerate(chunks):
        chunk.metadata["chunk_id"] = i

    return chunks

# 실행
chunks = load_and_chunk("docs/sample.pdf")

운영자 실전 노트

실제 프로젝트 진행하며 겪은 문제

  • chunk_size 500으로 시작 → 문맥 잘려서 검색 품질 저하. 1000으로 올리니 개선
  • overlap 0으로 설정 → 문장이 청크 경계에서 잘림. 200(20%)으로 설정하니 문맥 연결 개선
  • 표와 코드 블록이 청크 중간에 잘림 → separators에 \n\n\n 추가해서 구조 보존
  • 너무 짧은 청크(<100자) 다수 발생 → 품질 저하. 후처리로 짧은 청크 합치기 필요

이 경험을 통해 알게 된 점

  • chunk_size는 문서 특성에 따라 다름. 여러 값(500/1000/1500)으로 테스트 필수
  • overlap은 10~20%가 적정선. 너무 크면 중복 데이터로 비용 증가
  • RecursiveCharacterTextSplitter가 CharacterTextSplitter보다 문맥 보존 우수

마무리

청킹 설정은 RAG 성능에 직접적인 영향을 준다. 기본값으로 시작하되, 실제 검색 결과를 보며 튜닝하는 게 중요하다.

다음 편에서는 텍스트를 숫자 벡터로 바꾸는 “임베딩”을 배운다. 왜 텍스트를 숫자로 바꿔야 하는지, 어떻게 작동하는지 알아본다.


RAG 챗봇 만들기 시리즈:

← 블로그 목록으로