수민 '-'

플오그래밍

제가 작성하는 모든 글은 절대 상업적인 이용이 아니며, 그저 개인적인 공부 용도로만 사용하는 것임을 밝힙니다.

RAG 청킹 - RecursiveCharacterTextSplitter와 SemanticChunker

RAG에서는 긴 텍스트나 문서를 한 번에 모델에 넣기 어렵다. 대형 언어모델은 토큰 수 한계 가 있기 때문에 문서를 잘게 나눠 임베딩하고, 검색·질의응답 때 필요한 청크(Chunk) 만 골라 전달하는 방식이 일반적이다.


1. 청크(Chunk)가 필요한 이유

청크는 긴 텍스트나 문서를 작은 단위로 나눈 조각이다. 주로 자연어 처리와 RAG 에서 쓴다.

  • 문서를 일정 길이로 나눠 임베딩 벡터로 바꾼 뒤, 질의 시 필요한 청크만 불러 모델에 넣어 효율과 정확성을 노린다.
  • 단순히 글자 수·토큰 수로만 자를 수도 있지만, 문단·문장의미 단위로 나눌수록 검색 품질이 좋아지는 경우가 많다.
  • 정리하면 청크는 방대한 데이터를 모델이 다룰 수 있는 크기로 잘게 나눈 최소 단위에 가깝다.

2. 환경 준비

import getpass
import os

def _set_env(var: str):
    if not os.environ.get(var):
        os.environ[var] = getpass.getpass(f"{var}: ")

_set_env("OPENAI_API_KEY")
!pip install langchain_community langchain_experimental langchain_openai pypdf

!pip install -qU langchain langchain-community langchain-openai
!pip install -qU langchain-text-splitters langchain-chroma chromadb
!pip install -qU rank_bm25 langchain-classic langgraph pypdf

!pip install -qU langchain-experimental

3. 일반 Chunking의 한계

  • 예: 1000자씩 자르기(문단 무시)
  • 문장이 중간에 잘림 → 의미가 깨질 수 있음
from langchain_community.document_loaders import PyPDFLoader

file_path = "/content/AI브리프_3월_260303.pdf"  # 실제 경로로 변경
loader = PyPDFLoader(file_path)
pages = loader.load()

print(f"페이지 수: {len(pages)}")
print(pages)
print(pages[0].metadata)
print(pages[0].page_content[:300])

print(pages[1].page_content[:300])

4. RecursiveCharacterTextSplitter

  • 문자 기반으로 청킹하되, 문장·단락을 최대한 유지한다.
  • chunk_size: 한 청크 목표 크기
  • chunk_overlap: 이전 청크 끝 일부를 다음 청크에 겹쳐 문맥 단절 완화
  • add_start_index=True: 원문 위치 메타데이터(출처·하이라이트에 활용)
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    add_start_index=True,
)

docs = text_splitter.split_documents(pages)
print(f"총 {len(docs)}개 만큼의 문서로 청킹되었습니다.")
print([len(doc.page_content) for doc in docs[:10]])

for doc in docs[:3]:
    print(doc.metadata)
    print(doc.page_content[:500])
    print("-" * 100)

5. SemanticChunker

SemanticChunker는 일정 길이로만 자르는 방식이 아니라, 문장의 의미적 맥락을 고려해 자연스럽게 나눈다. 문장·문단 의미가 끊기지 않게 해 임베딩·검색 정확도를 올리는 데 쓰이며, RAG에서 관련성 높은 정보를 덜 잘리게 하는 데 도움이 된다.

문장 단위로 나눈 뒤 → 문장 간 의미 유사도를 계산(임베딩) → 의미가 바뀌는 지점에서 청크를 나눔

동작 요약

  • 문장 단위로 나눔 → 각 문장 임베딩 → 문장 간 유사도 계산 → 유사도가 낮아지는 지점에서 끊음 → 의미가 이어지면 같은 청크에 계속 붙임

단점

  • 청킹 크기가 불균형
  • 속도가 느림(임베딩·유사도 계산)
  • 비용 증가(임베딩 API 호출 횟수 등)
  • 완벽하지 않음(애매한 문장은 잘못 묶일 수 있음)
  • 튜닝 난이도(threshold를 잘못 잡으면 너무 잘게 쪼개지거나 너무 크게 묶임)
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

# OpenAIEmbeddings(): 내부적으로 임베딩 API 호출. 토큰 수 기준으로 과금
semantic_splitter = SemanticChunker(OpenAIEmbeddings())
semantic_docs = semantic_splitter.split_documents(pages)

print(f"총 {len(semantic_docs)}개 만큼의 문서로 청킹되었습니다.")
print([len(doc.page_content) for doc in semantic_docs[:10]])

6. 실무에서 자주 쓰는 조합

6-1. Hybrid: 의미 청킹 후 길이로 다시 자르기

semantic_docs = semantic_splitter.split_documents(pages)

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
)
final_docs = splitter.split_documents(semantic_docs)

6-2. threshold 조정 예시

semantic_splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=95,
)

마치며

  • RecursiveCharacterTextSplitter는 속도·비용 대비 안정적인 기본선으로 쓰기 좋다.
  • SemanticChunker는 품질을 노리되 비용·지연·불균형 청크를 감수해야 한다.
  • 하이브리드(의미 분할 + 길이 제한)는 둘의 절충안으로 자주 쓴다.
  • 다음 글에서는 이렇게 만든 docs를 Chroma에 넣고, 벡터·BM25·앙상블 리트리버와 LangGraph, 환각 평가 루프까지 이어간다.

참고