RAG 청킹 전략 실전: 문서 분할·오버랩·메타데이터로 검색 품질 끌어올리기
RAG 파이프라인을 처음 구축할 때 대부분 문서를 그냥 500자, 1000자 같은 고정 길이로 뚝뚝 잘라 벡터 DB에 넣는다. 데모는 그럴듯하게 돌아간다. 그런데 실제 질문을 던져보면 답이 어딘가 어긋난다. 필요한 정보가 두 청크에 걸쳐 반으로 쪼개져 있거나, 표 한가운데가 잘려 숫자만 덩그러니 남거나, 코드 블록이 중간에서 끊겨 문맥을 잃는다. 검색은 엉뚱한 청크를 물어오고, LLM은 반쪽짜리 근거로 그럴듯한 거짓을 만들어낸다.
문제의 뿌리는 대부분 생성 모델이 아니라 청킹(chunking)에 있다. 아무리 좋은 임베딩 모델과 강력한 LLM을 붙여도, 검색 단계에서 잘못된 조각을 건네주면 결과는 무너진다. 이 글에서는 청크 크기가 왜 검색과 생성 품질을 동시에 좌우하는지, 고정 크기부터 재귀적 분할과 구조 인식 청킹까지 어떻게 발전하는지, 오버랩과 메타데이터를 어떻게 다뤄야 하는지, 그리고 청킹을 감으로 정하지 않고 평가로 튜닝하는 방법까지 실전 관점에서 다룬다.
왜 청크 크기가 검색과 생성 품질을 좌우하는가
RAG에서 청크는 검색의 단위이자 생성의 근거 단위다. 이 하나의 조각이 두 가지 상반된 요구를 동시에 만족해야 한다. 첫째, 임베딩이 의미를 정확히 담을 만큼 초점이 좁아야 한다. 한 청크에 여러 주제가 섞이면 임베딩 벡터가 평균값으로 뭉개져 어떤 질문에도 어중간하게만 매칭된다. 둘째, LLM이 답을 만들 만큼 맥락이 충분해야 한다. 조각이 너무 작으면 문장 하나만 검색돼도 앞뒤 정황을 몰라 답을 못 만든다.
- 청크가 너무 크면: 여러 주제가 섞여 임베딩이 흐려지고, 검색 정확도가 떨어지며, 프롬프트 토큰을 낭비한다
- 청크가 너무 작으면: 문맥이 잘려 검색은 맞아도 답을 만들 근거가 부족하고, 필요한 정보가 여러 청크로 흩어진다
- 경계가 엉뚱하면: 문장·문단·표가 중간에서 끊겨 의미 단위가 파괴된다
그래서 청킹은 “몇 자로 자를까”의 문제가 아니라 “의미 단위를 어떻게 보존할까”의 문제다. 크기는 그 결과로 정해지는 값일 뿐이다.
고정 크기 vs 경계 인식 vs 재귀적 분할
가장 단순한 방식은 고정 크기 분할이다. 글자 수나 토큰 수를 세어 일정 길이마다 자른다. 구현이 쉽고 빠르지만, 단어·문장·문단을 무자비하게 관통한다. “서울특별시”가 “서울특”과 “별시”로 갈라지는 식이다.
한 단계 나은 방식은 경계 인식 분할이다. 문장 끝(마침표)이나 문단 끝(빈 줄)을 기준으로 자른다. 의미 단위는 지키지만, 문단 하나가 너무 길면 크기 상한을 넘고, 문장이 너무 짧으면 청크가 잘게 부서진다.
실무에서 가장 널리 쓰이는 절충안은 재귀적 분할(recursive splitting)이다. 구분자를 우선순위대로 나열해 두고, 큰 경계부터 시도하며 크기 상한을 만족할 때까지 점점 작은 경계로 내려간다. 문단 → 줄바꿈 → 문장 → 단어 → 글자 순으로 후퇴하므로, 가능한 한 큰 의미 단위를 유지하면서도 크기 제약을 지킨다.
def recursive_split(text: str, max_size: int, separators=None) -> list[str]:
"""큰 경계부터 시도하며 max_size를 만족할 때까지 재귀적으로 분할."""
if separators is None:
separators = ["nn", "n", ". ", " ", ""] # 문단→줄→문장→단어→글자
# 상한 이하면 그대로 반환
if len(text) <= max_size:
return [text]
# 현재 우선순위의 구분자로 쪼갠다
sep = separators[0]
pieces = text.split(sep) if sep else list(text)
chunks, buffer = [], ""
for piece in pieces:
candidate = buffer + (sep if buffer else "") + piece
if len(candidate) max_size:
chunks.extend(recursive_split(piece, max_size, separators[1:]))
buffer = ""
else:
buffer = piece
if buffer:
chunks.append(buffer)
return chunks
이 방식은 대부분의 산문 문서에서 무난하게 작동한다. LangChain의 RecursiveCharacterTextSplitter도 정확히 이 원리를 따른다. 다만 산문에 최적화돼 있어, 코드나 표가 많은 문서에는 뒤에서 다룰 구조 인식 청킹이 필요하다.
오버랩: 문맥 손실을 막는 최소한의 보험
경계를 아무리 잘 잡아도, 청크 경계 바로 걸친 정보는 반드시 잘린다. “이 약은 하루 2회 복용하되”에서 끊기고 다음 청크가 “공복에는 피한다”로 시작하면, 두 청크 어느 쪽도 완전한 지침을 담지 못한다. 오버랩(overlap)은 인접 청크가 경계 부분을 일정 길이만큼 공유하게 해서 이 손실을 막는다.
def split_with_overlap(text: str, size: int, overlap: int) -> list[str]:
"""인접 청크가 overlap 만큼 겹치도록 슬라이딩 윈도우 분할."""
if overlap >= size:
raise ValueError("overlap은 size보다 작아야 한다")
chunks = []
start = 0
while start = len(text):
break
start = end - overlap # 다음 시작점을 overlap 만큼 되돌린다
return chunks
오버랩에는 뚜렷한 트레이드오프가 있다. 겹침을 늘리면 경계 손실은 줄지만, 같은 내용이 여러 청크에 중복 저장돼 벡터 DB 용량과 검색 비용이 늘고, 검색 결과에 거의 같은 청크가 여러 개 뜨는 중복 문제가 생긴다. 경험적으로 청크 크기의 10~20% 정도가 무난한 출발점이다. 500토큰 청크라면 50~100토큰 오버랩이다. 문맥 연속성이 중요한 매뉴얼·계약서는 겹침을 조금 늘리고, 독립적인 FAQ 항목처럼 각 조각이 자기완결적이면 오버랩을 줄이거나 없애도 된다.
구조 인식 청킹: 헤더·코드·표를 보존하라
기술 문서, 마크다운, API 레퍼런스는 글자 수로만 자르면 참사가 벌어진다. 코드 블록이 중간에서 끊기고, 표의 헤더 행과 데이터 행이 분리돼 어떤 열이 무엇인지 알 수 없게 된다. 이럴 때는 문서의 구조를 먼저 파악하고 그 경계를 존중해야 한다.
- 마크다운 헤더(
#,##)를 섹션 경계로 삼아 같은 섹션 내용을 함께 묶는다 - 펜스 코드 블록(
```)은 통째로 하나의 청크로 유지해 중간에서 끊지 않는다 - 표는 헤더 행을 각 청크에 반복 포함시켜 열 의미를 잃지 않게 한다
아래는 마크다운 헤더를 기준으로 섹션을 나누되, 상위 헤더 경로를 메타데이터로 함께 기록하는 예시다. 이렇게 하면 “3.2절 인증 흐름” 같은 섹션 위치 정보가 청크에 따라붙어, 나중에 검색·재랭킹·출처 표시에 모두 쓸 수 있다.
import re
def split_by_markdown_header(md: str) -> list[dict]:
"""헤더를 섹션 경계로, 헤더 계층을 메타데이터로 부착."""
lines = md.split("n")
header_re = re.compile(r"^(#{1,3})s+(.*)")
sections, buffer, path = [], [], {}
in_code = False
def flush():
body = "n".join(buffer).strip()
if body:
sections.append({"text": body, "headers": dict(path)})
for line in lines:
if line.strip().startswith("```"):
in_code = not in_code # 코드 블록 안에서는 헤더 무시
m = header_re.match(line) if not in_code else None
if m:
flush()
buffer = []
level = len(m.group(1))
title = m.group(2).strip()
path = {k: v for k, v in path.items() if k < level} # 하위 경로 초기화
path[level] = title
buffer.append(line)
flush()
return sections
메타데이터: 필터링과 재랭킹의 지렛대
청크는 텍스트만 있는 게 아니다. 각 청크에 출처 문서·섹션 경로·작성일·문서 종류 같은 메타데이터를 붙여두면, 벡터 검색을 훨씬 정교하게 다룰 수 있다. 순수 벡터 유사도만으로는 “최신 문서만”, “특정 제품 매뉴얼만” 같은 조건을 표현할 수 없지만, 메타데이터 필터를 걸면 검색 공간 자체를 좁혀 정확도를 끌어올린다.
from datetime import date
def build_chunk(text: str, source: str, section: str, doc_date: date) -> dict:
return {
"text": text,
"metadata": {
"source": source, # 출처 파일·URL
"section": section, # 섹션 경로 (예: "설치 > 요구사항")
"date": doc_date.isoformat(),
"doc_type": "manual", # 필터·라우팅에 사용
"char_len": len(text),
},
}
# 검색 시: 벡터 유사도 + 메타데이터 사전 필터
def search(vector_db, query_vec, top_k=5):
return vector_db.query(
vector=query_vec,
top_k=top_k,
filter={
"doc_type": "manual",
"date": {"$gte": "2026-01-01"}, # 올해 이후 문서만
},
)
메타데이터는 검색 이후 재랭킹에도 쓴다. 벡터 유사도로 상위 20개를 뽑은 뒤, 최신 문서에 가중치를 주거나 같은 출처의 청크를 묶어 다양성을 확보하는 식이다. 출처 정보는 답변에 근거를 표기하는 데도 필수라, 메타데이터를 붙이는 순간 검색 품질과 신뢰성이 함께 올라간다.
청크 크기와 임베딩·컨텍스트 한계
청크 크기는 자유롭게 정할 수 없다. 두 가지 상한이 있다. 첫째, 임베딩 모델의 입력 토큰 한계다. 대부분의 임베딩 모델은 512~8192토큰의 상한을 두는데, 이를 넘기면 뒷부분이 조용히 잘려 나가 임베딩에 반영되지 않는다. 청크가 상한 안에 들어가는지 반드시 토큰 기준으로 확인해야 한다. 글자 수와 토큰 수는 한국어에서 특히 크게 다르다.
둘째, LLM의 컨텍스트 윈도우다. 검색한 청크 여러 개를 프롬프트에 넣으므로, 청크 크기 × top_k가 컨텍스트를 넘지 않아야 한다. 500토큰 청크를 10개 넣으면 5000토큰이고, 여기에 질문과 지시문까지 더해진다. 청크가 클수록 근거는 풍부하지만 넣을 수 있는 청크 개수가 줄어, 다양한 출처를 참고하기 어려워진다. 반대로 청크가 작으면 여러 출처를 담을 수 있지만 각 근거의 맥락이 얕아진다. 이 균형은 문서 성격과 질문 유형에 따라 달라지므로, 정답은 평가로 찾는다.
부모-자식 청킹과 평가 기반 튜닝
검색 정확도와 생성 맥락이라는 상충을 우아하게 푸는 기법이 부모-자식 청킹(parent-child chunking)이다. 검색은 작은 자식 청크로 하고, LLM에 넘길 때는 그 자식이 속한 큰 부모 청크를 확장해서 넘긴다. 정밀한 검색과 풍부한 맥락을 동시에 얻는 것이다.
def build_parent_child(document: str, parent_size=2000, child_size=400):
"""부모 청크를 만들고 그 안을 다시 자식으로 쪼갠다."""
store = {} # child_id -> parent_text
parents = recursive_split(document, parent_size)
for p_idx, parent in enumerate(parents):
children = recursive_split(parent, child_size)
for c_idx, child in enumerate(children):
child_id = f"{p_idx}-{c_idx}"
store[child_id] = {"child_text": child, "parent_text": parent}
return store # 검색은 child_text 임베딩, 생성은 parent_text 전달
어떤 청킹 전략이든 평가 없이는 튜닝할 수 없다. 청크 크기, 오버랩, 분할 방식을 감으로 정하지 말고 실제 지표로 비교해야 한다. 최소한 두 가지를 본다. 검색 단계의 recall(정답을 담은 청크가 상위 k개 안에 들어오는 비율)과, 최종 답변의 정확도(사람 평가 또는 LLM 판정)다. 질문-정답 쌍으로 된 평가 셋을 만들어 두고, 청크 크기 300·500·800, 오버랩 0·10%·20% 같은 조합을 격자 탐색하면 데이터가 최적점을 알려준다.
def evaluate_recall(eval_set, retriever, k=5) -> float:
"""정답 청크가 상위 k 검색 결과에 포함된 비율."""
hits = 0
for item in eval_set: # item: {question, gold_chunk_id}
results = retriever(item["question"], top_k=k)
result_ids = {r["id"] for r in results}
if item["gold_chunk_id"] in result_ids:
hits += 1
return hits / len(eval_set)
# 청킹 설정별로 recall을 비교해 최적 조합 선택
for size in (300, 500, 800):
for overlap_pct in (0.0, 0.1, 0.2):
retriever = build_retriever(size, int(size * overlap_pct))
print(size, overlap_pct, evaluate_recall(eval_set, retriever))
마무리
RAG의 품질은 생성 모델보다 청킹에서 갈리는 경우가 훨씬 많다. 청킹의 본질은 “몇 자로 자를까”가 아니라 “의미 단위를 어떻게 보존할까”다. 고정 크기의 무자비한 절단에서 벗어나 재귀적 분할로 큰 의미 단위를 지키고, 오버랩으로 경계 손실을 막고, 구조 인식으로 코드·표·헤더를 보존하고, 메타데이터로 검색과 재랭킹의 지렛대를 확보한다. 임베딩과 컨텍스트 한계 안에서 부모-자식 청킹으로 검색 정밀도와 맥락을 함께 잡고, 마지막으로 recall과 답변 정확도라는 지표로 감이 아닌 데이터로 튜닝한다. 이 순서를 지키면 같은 문서·같은 LLM으로도 검색 품질과 답변 신뢰성이 눈에 띄게 달라진다.
자주 묻는 질문
Q. 청크 크기와 오버랩의 적당한 시작값이 있나요?
A. 일반 산문 문서라면 토큰 기준 300~500 크기에 10~20% 오버랩(30~100토큰)이 무난한 출발점입니다. 다만 이건 어디까지나 초기값일 뿐, 문서 성격에 따라 크게 달라집니다. 문맥 연속성이 중요한 매뉴얼·계약서는 크기와 오버랩을 늘리고, 자기완결적인 FAQ는 작게 잡고 오버랩을 없애도 됩니다. 반드시 평가 셋으로 몇 가지 조합을 비교한 뒤 확정하세요.
Q. 한국어 문서는 글자 수로 잘라도 되나요?
A. 권장하지 않습니다. 임베딩 모델과 LLM의 한계는 모두 토큰 기준인데, 한국어는 글자 수와 토큰 수가 크게 벌어집니다. 글자 수로 상한을 맞추면 실제 토큰은 임베딩 한계를 넘겨 뒷부분이 조용히 잘릴 수 있습니다. 해당 모델의 토크나이저로 토큰을 직접 세어 상한을 관리하고, 분할 경계도 글자보다 문장·문단 같은 의미 단위를 우선하세요.
Q. 코드와 표가 섞인 기술 문서는 어떻게 자르는 게 좋나요?
A. 일반 재귀 분할을 그대로 쓰면 코드 블록이 중간에서 끊기고 표 헤더가 데이터와 분리됩니다. 마크다운 헤더를 섹션 경계로 삼고, 펜스 코드 블록은 통째로 하나의 청크로 유지하며, 표는 헤더 행을 각 청크에 반복해서 넣는 구조 인식 청킹이 필요합니다. 더불어 섹션 경로를 메타데이터로 붙여두면 검색 필터링과 출처 표기에 그대로 활용할 수 있습니다.