RAG - Custom Chunker 비교 (260707)

WonTerry·2026년 7월 7일

LLM

목록 보기
74/92

가장 단순한 형태의 Chunker 구조

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

RecursiveCharacterTextSplitter와 비교 (길이 분할)

겉보기엔 둘 다 "청크 크기 + 오버랩" 개념을 쓰지만 실제 동작 방식과 결과 품질에는 상당한 차이가 있습니다.

1. 분할 기준 (가장 핵심적인 차이)

직접 작성한 chunk_text

  • text[start:end]순수 문자 인덱스 기준 슬라이싱
  • 텍스트의 의미 구조(문장, 단락, 단어 경계)를 전혀 고려하지 않음
  • 단어 중간, 심지어 조사나 문장 중간에서 뚝 잘릴 수 있음

RecursiveCharacterTextSplitter

  • 기본 구분자 리스트 ["\n\n", "\n", " ", ""] 순서로 재귀적으로 시도
  • 먼저 문단(\n\n) 단위로 나누고, 그래도 chunk_size보다 크면 줄바꿈(\n)으로, 그래도 크면 공백으로, 최후에는 문자 단위로 쪼갬
  • 가능한 한 문장/단락 같은 의미 단위를 보존하려고 시도

기능적 차이의 본질: 고정 길이 방식은 "글자 수만 맞추는" 기계적 분할이고, Recursive 방식은 "의미 단위를 최대한 지키면서 글자 수를 맞추는" 절충적 분할입니다.

2. 오버랩 처리 방식

  • 직접 작성 버전: start += chunk_size - overlap 로 항상 정확히 고정된 문자 수만큼 겹침 → 오버랩이 매우 예측 가능하고 일정함
  • RecursiveCharacterTextSplitter: 내부적으로 구분자 기준으로 청크를 합치다 보니 실제 오버랩은 지정한 값 근처에서 가변적임 (정확히 N자가 겹친다고 보장 안 됨)

3. 청크 개수 및 크기 일관성

  • 고정 길이 방식: 모든 청크가 (마지막 제외) 정확히 chunk_size 길이로 균일함
  • Recursive 방식: 구분자를 우선하다 보니 청크 크기가 chunk_size보다 작을 수 있고 편차가 생김 (대신 자연스러운 경계에서 끊김)

4. RAG 품질에 미치는 영향 (기능적으로 가장 중요)

문장/단어가 잘린 채로 임베딩되면:

  • 임베딩 벡터가 원래 의미를 왜곡되게 표현할 가능성 ↑
  • 검색 시 관련성 낮은 청크가 상위로 올라올 위험 ↑
  • LLM에 컨텍스트로 넣었을 때 문맥이 부자연스러워짐

Recursive 방식은 이 문제를 완화하도록 설계된 것이고, 직접 작성한 버전은 이런 보정이 전혀 없습니다.

5. 성능 (속도/자원) 관점

항목고정 길이 chunk_textRecursiveCharacterTextSplitter
시간 복잡도O(n), 단순 슬라이싱O(n·k), k=구분자 단계 수 (여전히 선형에 가깝지만 상수항 큼)
연산인덱스 계산만문자열 탐색(str.split/find) + 병합 로직 반복
메모리매우 가벼움재귀 중 임시 리스트/문자열 생성으로 오버헤드 있음
실행 속도훨씬 빠름상대적으로 느림 (특히 텍스트가 크고 구분자 탐색이 여러 단계 필요할 때)

즉, 직접 작성한 버전이 순수 실행 속도/자원 효율은 더 좋지만, 청크 품질(의미 보존)은 떨어집니다. 반대로 RecursiveCharacterTextSplitter는 다소 느리고 복잡하지만 RAG 파이프라인에서 실질적으로 더 나은 검색 결과를 만들어낼 가능성이 높습니다.

6. 확장성 차이

  • RecursiveCharacterTextSplitterlength_function을 토큰 카운터(tiktoken 등)로 바꿔서 토큰 기준 분할도 가능하고, 코드/마크다운 전용 구분자 세트(from_language())도 지원
  • 직접 작성한 버전은 문자 수 기준으로 고정되어 있어 이런 유연성이 없음 (직접 로직을 추가해야 함)

요약: 두 방식 모두 "chunk_size + overlap" 파라미터를 갖지만, 직접 작성한 버전은 속도가 빠른 대신 의미 경계를 무시하는 기계적 슬라이딩 윈도우이고, RecursiveCharacterTextSplitter다소 느리지만 문단→문장→단어 순으로 최대한 자연스러운 경계를 찾아 분할하는 방식입니다. 성능(속도) 우선이면 전자가, 검색/RAG 품질 우선이면 후자가 유리합니다.


chunker의 성능을 RecursiveCharacterTextSplitter 수준으로 높일수 있는 방법이 있는가?

네, 가능합니다. 핵심은 "고정 인덱스 슬라이싱"에 경계 인식 로직을 추가하는 것입니다. 단계별로 개선 방법을 정리하겠습니다.

방법 1: 구분자 우선순위 기반 재귀 분할 (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와 거의 동일해집니다.

방법 2: 가벼운 "경계 스냅(snap)" 후처리 — 속도를 더 유지하고 싶을 때

완전 재귀 대신, 기존 고정 슬라이싱 로직을 유지하면서 자르는 지점만 가까운 문장/단어 경계로 밀어주는 절충안입니다. 속도 이점을 크게 희생하지 않으면서 품질을 개선할 수 있습니다.

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처럼 완전한 재귀는 아니지만 실무에서 꽤 좋은 절충점

방법 3: 토큰 기준 분할로 전환

글자 수가 아니라 토큰 수로 분할하면 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 제한을 맞추려면 이 방식이 더 정확합니다.

방법 4: 두 방법 결합 (실무 추천)

가장 실용적인 조합은:
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를 추천합니다.

profile
Hello, I'm Terry! 👋 Enjoy every moment of your life! 🌱 My current interests are Signal processing, Machine learning, Python, Database, LLM & RAG, MCP & ADK, Multi-Agents, Physical AI, ROS2...

0개의 댓글