RAG 시리즈 세 번째 글이다. 개념편에서 RAG가 뭔지 잡았고, 실습편에서 Chroma랑 pgvector로 직접 만들어봤다. 그런데 막상 실습한 RAG를 좀 굴려보면 이런 일이 생긴다.
"분명히 문서에 답이 있는데, 왜 엉뚱한 걸 찾아오지?"
RAG의 답변 품질은 결국 "검색이 관련 문서를 제대로 찾아왔는가"에 달려 있다. 아무리 좋은 LLM을 써도, 검색이 엉뚱한 문서를 가져오면 답도 엉뚱해진다. 오늘은 검색 정확도를 좌우하는 가장 중요한 요소, 청킹(chunking)을 중심으로 RAG 품질을 올리는 법을 다룬다.
청킹이 뭔데? 왜 문서를 쪼개야 하지?
실습편에서는 짧은 문장들을 그대로 넣었다. 근데 실무 문서는 다르다. 수십 페이지짜리 PDF, 긴 위키 문서, 몇천 줄짜리 매뉴얼이다.
이런 긴 문서를 통째로 임베딩하면 문제가 생긴다.
- 의미가 뭉개진다. 한 문서에 여러 주제가 섞여 있으면, 그걸 벡터 하나로 압축했을 때 특징이 흐려진다. "연차 정책"과 "출장 규정"이 한 벡터에 뭉쳐지면 둘 다 애매하게 표현된다.
- LLM에 넣을 때 너무 크다. 검색해서 통째로 넘기면 프롬프트가 터진다.
그래서 문서를 의미 있는 작은 조각(chunk)으로 쪼개는 것, 이게 청킹이다. 조각 단위로 임베딩하고 저장해야 검색이 정확해진다.
[긴 문서 하나]
│ 청킹
▼
[조각1] [조각2] [조각3] [조각4] ...
각각 임베딩 → 벡터DB에 저장청킹 전략 1: 고정 크기로 자르기
가장 단순한 방법은 글자 수(또는 토큰 수) 기준으로 뚝뚝 자르는 거다. 예를 들어 500자씩.
def chunk_by_size(text: str, size: int = 500) -> list[str]:
return [text[i:i+size] for i in range(0, len(text), size)]
간단하고 빠르다. 근데 치명적인 단점이 있다. 문장 중간이나 단어 중간에서 잘린다.
...연차는 입사 첫해부터 15일 제공하며 퇴사 시 미사용 |여기서 잘림|
분은 수당으로 정산한다...이러면 "미사용 연차 수당"이라는 핵심 정보가 두 조각으로 찢어져서, 어느 쪽도 제대로 검색이 안 될 수 있다.
청킹 전략 2: 겹치기(overlap) 주기
위 문제를 완화하는 게 오버랩이다. 조각을 자를 때 앞 조각의 끝부분을 다음 조각 앞에 조금 겹쳐서 넣는다.
def chunk_with_overlap(text: str, size: int = 500, overlap: int = 100) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + size
chunks.append(text[start:end])
start += size - overlap # overlap만큼 뒤로 덜 간다
return chunks
overlap=100이면 각 조각이 앞 조각의 마지막 100자를 다시 포함한다. 이러면 경계에서 잘린 문맥이 한쪽 조각에는 온전히 들어갈 확률이 높아진다. 실무에서 아주 흔하게 쓰는 기본 전략이다.
청킹 전략 3: 의미 단위로 자르기
더 좋은 건 글자 수가 아니라 의미 단위(문단, 문장, 제목)로 자르는 거다. 문단은 보통 하나의 완결된 생각을 담으니까, 문단 경계로 자르면 의미가 온전히 보존된다.
def chunk_by_paragraph(text: str) -> list[str]:
# 빈 줄(문단 구분) 기준으로 나누기
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
return paragraphs
실무에서는 이걸 더 정교하게 다듬은 도구를 많이 쓴다. 예를 들어 LangChain의 RecursiveCharacterTextSplitter는 문단 → 문장 → 단어 순으로 "가능한 한 큰 의미 단위를 유지하면서" 목표 크기에 맞게 잘라준다.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
)
chunks = splitter.split_text(long_text)
크기 기준의 안정성과 의미 기준의 정확성을 적당히 섞은 방식이라, 잘 모르겠으면 이걸 기본값으로 쓰면 대체로 무난하다.
청크 크기는 어떻게 정하지?
정답은 없고 트레이드오프다.
| 청크 크기 | 장점 | 단점 |
|---|---|---|
| 작게 (200~300자) | 검색이 정밀함, 핵심만 콕 | 문맥이 부족할 수 있음 |
| 크게 (1000자+) | 문맥이 풍부함 | 여러 주제가 섞여 검색이 흐려짐 |
보통 300800자 + 오버랩 1020% 정도에서 시작해서, 실제 데이터로 결과를 보며 조정한다. 문서 성격에 따라 다르다. FAQ처럼 짧은 Q&A는 작게, 설명이 긴 기술 문서는 크게 가는 식이다.
청킹 말고도 정확도를 올리는 방법들
청킹이 가장 크지만, 몇 가지 더 있다. 개념만 짚어둔다.
검색 개수(top_k) 조정. 실습에서 n_results=2로 했는데, 너무 적으면 답에 필요한 조각을 놓치고, 너무 많으면 관련 없는 게 섞여 LLM이 헷갈린다. 3~5개에서 시작해보자.
메타데이터 필터링. 조각을 저장할 때 카테고리, 날짜, 문서 출처 같은 메타데이터를 같이 넣어두면, 검색할 때 "인사 규정 문서 중에서만" 같은 필터를 걸 수 있다. 검색 범위를 좁혀 정확도를 올린다.
하이브리드 검색. 의미 기반(벡터) 검색에 전통적인 키워드 검색을 섞는 방식이다. 벡터 검색은 의미엔 강하지만 정확한 고유명사(제품 코드, 사람 이름)엔 약할 때가 있는데, 키워드 검색이 그걸 보완해준다.
리랭킹(re-ranking). 벡터DB로 후보를 넉넉히(예: 20개) 뽑은 다음, 더 정교한 모델로 다시 순위를 매겨 상위 몇 개만 LLM에 넘기는 방식이다. 한 단계 더 걸러서 품질을 높인다.
정리
RAG 답변이 이상하면, LLM을 탓하기 전에 검색이 제대로 된 문서를 찾아왔는지부터 의심하자. 그리고 검색 품질의 8할은 청킹에서 갈린다.
- 긴 문서는 의미 있는 조각으로 쪼개서(청킹) 저장한다.
- 경계에서 잘리는 문제는 오버랩으로 완화한다.
- 글자 수보다 문단·문장 같은 의미 단위로 자르는 게 좋다. (
RecursiveCharacterTextSplitter가 기본으로 무난) - 청크 크기, top_k, 메타데이터 필터, 하이브리드 검색, 리랭킹으로 더 끌어올릴 수 있다.
이걸로 RAG 시리즈 3부작을 마무리한다. 개념 → 실습 → 품질 개선까지 왔으니, 이제 작은 RAG 정도는 직접 만들고 튜닝할 수 있는 감이 잡혔을 거다. 여기서 더 나아가고 싶으면 LangChain으로 전체 파이프라인을 엮거나, 실제 문서(PDF, 노션)를 붙여보는 걸 추천한다.
'프로그래밍 > AI·LLM' 카테고리의 다른 글
| [AI/LLM] 직접 만들어보는 RAG — Chroma와 pgvector 실습편 (0) | 2026.07.03 |
|---|---|
| [AI/LLM] RAG가 뭔데? 임베딩과 벡터DB까지 한 번에 이해하기 (1) | 2026.07.03 |