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, 환각 평가 루프까지 이어간다.
참고
'현재 > AI Agent' 카테고리의 다른 글
| 쿼리문을 작성하는 RAG - SQLite와 ReAct 에이전트 (0) | 2026.03.31 |
|---|---|
| 벡터 리트리버와 RAG 평가 - BM25, 앙상블, LangGraph, 환각 검증 (0) | 2026.03.30 |
| 벡터 데이터베이스와 ChromaDB - 의미 검색·RAG 저장소 (0) | 2026.03.26 |
| LangGraph Reflection - 자기평가로 답변을 개선하는 루프 설계 (0) | 2026.03.24 |
| LangGraph로 챗봇 만들기 - Tool Calling Agent, Tavily, ToolNode, create_react_agent (0) | 2026.03.24 |