def chunk_text(text: str, chunk_size: int = 500, overlap: int = 100):
"""고정 길이 청크 분할"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start += chunk_size - overlap
return chunks
겉보기엔 둘 다 "청크 크기 + 오버랩" 개념을 쓰지만 실제 동작 방식과 결과 품질에는 상당한 차이가 있습니다.
직접 작성한 chunk_text
text[start:end]로 순수 문자 인덱스 기준 슬라이싱RecursiveCharacterTextSplitter
["\n\n", "\n", " ", ""] 순서로 재귀적으로 시도\n\n) 단위로 나누고, 그래도 chunk_size보다 크면 줄바꿈(\n)으로, 그래도 크면 공백으로, 최후에는 문자 단위로 쪼갬→ 기능적 차이의 본질: 고정 길이 방식은 "글자 수만 맞추는" 기계적 분할이고, Recursive 방식은 "의미 단위를 최대한 지키면서 글자 수를 맞추는" 절충적 분할입니다.
start += chunk_size - overlap 로 항상 정확히 고정된 문자 수만큼 겹침 → 오버랩이 매우 예측 가능하고 일정함chunk_size 길이로 균일함chunk_size보다 작을 수 있고 편차가 생김 (대신 자연스러운 경계에서 끊김)문장/단어가 잘린 채로 임베딩되면:
Recursive 방식은 이 문제를 완화하도록 설계된 것이고, 직접 작성한 버전은 이런 보정이 전혀 없습니다.
| 항목 | 고정 길이 chunk_text | RecursiveCharacterTextSplitter |
|---|---|---|
| 시간 복잡도 | O(n), 단순 슬라이싱 | O(n·k), k=구분자 단계 수 (여전히 선형에 가깝지만 상수항 큼) |
| 연산 | 인덱스 계산만 | 문자열 탐색(str.split/find) + 병합 로직 반복 |
| 메모리 | 매우 가벼움 | 재귀 중 임시 리스트/문자열 생성으로 오버헤드 있음 |
| 실행 속도 | 훨씬 빠름 | 상대적으로 느림 (특히 텍스트가 크고 구분자 탐색이 여러 단계 필요할 때) |
즉, 직접 작성한 버전이 순수 실행 속도/자원 효율은 더 좋지만, 청크 품질(의미 보존)은 떨어집니다. 반대로 RecursiveCharacterTextSplitter는 다소 느리고 복잡하지만 RAG 파이프라인에서 실질적으로 더 나은 검색 결과를 만들어낼 가능성이 높습니다.
RecursiveCharacterTextSplitter는 length_function을 토큰 카운터(tiktoken 등)로 바꿔서 토큰 기준 분할도 가능하고, 코드/마크다운 전용 구분자 세트(from_language())도 지원요약: 두 방식 모두 "chunk_size + overlap" 파라미터를 갖지만, 직접 작성한 버전은 속도가 빠른 대신 의미 경계를 무시하는 기계적 슬라이딩 윈도우이고, RecursiveCharacterTextSplitter는 다소 느리지만 문단→문장→단어 순으로 최대한 자연스러운 경계를 찾아 분할하는 방식입니다. 성능(속도) 우선이면 전자가, 검색/RAG 품질 우선이면 후자가 유리합니다.
chunker의 성능을 RecursiveCharacterTextSplitter 수준으로 높일수 있는 방법이 있는가?
네, 가능합니다. 핵심은 "고정 인덱스 슬라이싱"에 경계 인식 로직을 추가하는 것입니다. 단계별로 개선 방법을 정리하겠습니다.
가장 확실한 방법은 원리를 그대로 가져오는 것입니다.
def recursive_chunk_text(
text: str,
chunk_size: int = 500,
overlap: int = 100,
separators: list[str] = None,
) -> list[str]:
"""구분자 우선순위 기반 재귀 청크 분할"""
if separators is None:
separators = ["\n\n", "\n", ". ", " ", ""]
# 종료 조건: 텍스트가 이미 충분히 작으면 그대로 반환
if len(text) <= chunk_size:
return [text] if text.strip() else []
# 현재 레벨에서 사용할 구분자 선택
sep = separators[0]
remaining_seps = separators[1:]
if sep == "":
# 최후의 수단: 문자 단위 고정 분할
return _fixed_split(text, chunk_size, overlap)
splits = text.split(sep)
chunks = []
current = ""
for piece in splits:
candidate = current + sep + piece if current else piece
if len(candidate) <= chunk_size:
current = candidate
else:
if current:
chunks.append(current)
# 조각 자체가 chunk_size보다 크면 다음 레벨 구분자로 재귀
if len(piece) > chunk_size:
chunks.extend(
recursive_chunk_text(piece, chunk_size, overlap, remaining_seps)
)
current = ""
else:
current = piece
if current:
chunks.append(current)
return _apply_overlap(chunks, overlap)
def _fixed_split(text: str, chunk_size: int, overlap: int) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start += chunk_size - overlap
return chunks
def _apply_overlap(chunks: list[str], overlap: int) -> list[str]:
"""청크 사이에 오버랩(이전 청크 꼬리 일부)을 붙임"""
if overlap <= 0 or len(chunks) <= 1:
return chunks
result = [chunks[0]]
for i in range(1, len(chunks)):
prev_tail = chunks[i - 1][-overlap:]
result.append(prev_tail + chunks[i])
return result
이렇게 하면 기능적으로는 RecursiveCharacterTextSplitter와 거의 동일해집니다.
완전 재귀 대신, 기존 고정 슬라이싱 로직을 유지하면서 자르는 지점만 가까운 문장/단어 경계로 밀어주는 절충안입니다. 속도 이점을 크게 희생하지 않으면서 품질을 개선할 수 있습니다.
import re
SENTENCE_END = re.compile(r'[.!?。!?]\s')
def chunk_text_boundary_aware(
text: str, chunk_size: int = 500, overlap: int = 100, search_window: int = 50
):
chunks = []
start = 0
n = len(text)
while start < n:
end = min(start + chunk_size, n)
if end < n:
# end 근처 search_window 범위 안에서 가장 가까운 문장 경계 탐색
window = text[max(start, end - search_window):end]
matches = list(SENTENCE_END.finditer(window))
if matches:
# 마지막 매치 위치로 end 조정
last = matches[-1]
end = max(start, end - search_window) + last.end()
else:
# 문장 경계 없으면 공백 기준으로라도 조정
space_idx = text.rfind(" ", max(start, end - search_window), end)
if space_idx != -1:
end = space_idx
chunks.append(text[start:end].strip())
start = end - overlap if end - overlap > start else end
return [c for c in chunks if c]
RecursiveCharacterTextSplitter처럼 완전한 재귀는 아니지만 실무에서 꽤 좋은 절충점글자 수가 아니라 토큰 수로 분할하면 LLM 컨텍스트 윈도우/임베딩 모델 토큰 제한에 훨씬 더 잘 맞습니다.
import tiktoken
def chunk_text_by_tokens(text: str, chunk_size: int = 500, overlap: int = 100, model="cl100k_base"):
enc = tiktoken.get_encoding(model)
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = start + chunk_size
chunk_tokens = tokens[start:end]
chunks.append(enc.decode(chunk_tokens))
start += chunk_size - overlap
return chunks
문자 수 기준은 언어/토크나이저에 따라 실제 토큰 수와 괴리가 크기 때문에(한글은 특히), 임베딩 모델의 max token 제한을 맞추려면 이 방식이 더 정확합니다.
가장 실용적인 조합은:
1. 문단(\n\n) → 문장 → 공백 순 재귀 분할 (방법 1)
2. 최종 길이 기준은 토큰 수(방법 3)로 측정
3. overlap은 문자/토큰이 아니라 "이전 청크의 마지막 N개 문장"으로 계산 (의미 단위 오버랩)
def smart_chunk(text, chunk_size=500, overlap_sentences=1):
paragraphs = re.split(r'\n\n+', text)
chunks = []
buffer = []
buffer_len = 0
def flush():
if buffer:
chunks.append(" ".join(buffer))
for para in paragraphs:
sentences = re.split(r'(?<=[.!?])\s+', para)
for sent in sentences:
if buffer_len + len(sent) > chunk_size and buffer:
flush()
# 오버랩: 마지막 N 문장 유지
buffer = buffer[-overlap_sentences:] if overlap_sentences else []
buffer_len = sum(len(s) for s in buffer)
buffer.append(sent)
buffer_len += len(sent)
flush()
return chunks
| 개선 방법 | 품질 | 속도 | 구현 난이도 |
|---|---|---|---|
| 방법 1 (완전 재귀) | RecursiveCharacterTextSplitter와 거의 동일 | 원본과 비슷 (느림) | 중 |
| 방법 2 (경계 스냅) | 상당히 개선 | 원본과 큰 차이 없음 (빠름) | 낮음 |
| 방법 3 (토큰 기준) | 토큰 정합성 개선 | tiktoken 인코딩 비용 추가 | 낮음 |
| 방법 4 (결합) | 가장 높음 | 중간 | 높음 |
실무에서 라이브러리 의존성을 줄이면서 빠르게 개선하고 싶다면 방법 2(경계 스냅)가 가성비가 가장 좋고, RAG 품질을 최우선으로 한다면 방법 1 또는 4를 추천합니다.