
RAG로 챗봇을 만들어 놓고 나서, 분명 원본 문서에 정확한 답이 있는데도 챗봇이 엉뚱한 소리를 하는 경험 한 번쯤 해보셨을 겁니다. 검색된 조각을 열어보면 문장이 중간에서 뚝 끊겨 있고, 그 앞뒤 맥락이 통째로 날아간 경우가 많습니다. 이럴 때 가장 먼저 손볼 수 있는 값이 청크 오버랩이고, 그래도 부족하면 검색용 조각과 LLM에 넘길 조각을 아예 분리하는 부모 자식 청크 구조로 넘어가는 것이 순서입니다.
청크가 문장 중간에서 잘리면 왜 답변이 이상해질까요?
텍스트 분할기는 정해진 글자 수(chunk_size)를 기준으로 문서를 자릅니다. 이 과정에서 문장, 심지어 단어 중간에서 경계가 생기는 일이 흔합니다. 잘린 조각을 임베딩하면 벡터 자체가 원래 문장이 담고 있던 의미의 절반만 담게 되고, 검색 시점에는 질문과 가장 가까운 벡터로 이 반쪽짜리 조각이 뽑혀 나옵니다.
문제는 여기서 끝나지 않습니다. LLM은 이 조각만 보고 답을 생성해야 하므로, 앞 문장이나 뒤 문장에 있던 조건·예외 사항을 알 방법이 없습니다. 예를 들어 “다만 계약 해지 시에는 예외로 한다”라는 문장이 앞 청크 끝에, 정작 계약 조건 본문이 다음 청크 시작에 걸쳐 있으면 LLM은 예외 조항 없이 원칙만 답하게 됩니다. 청크 오버랩은 바로 이 경계 문제를 완화하려고 만든 설정값입니다.
청크 오버랩의 작동 원리와 chunk_overlap 설정
청크 오버랩은 인접한 두 청크가 일정 글자(또는 토큰) 수만큼 서로 겹치도록 만드는 방식입니다. 앞 청크의 끝부분 N자를 다음 청크 시작 부분에도 그대로 복사해 넣기 때문에, 경계에 걸친 문장이 적어도 한쪽 청크에는 온전히 들어가게 됩니다.
LangChain의 RecursiveCharacterTextSplitter는 chunk_size와 chunk_overlap을 함께 받는 구조입니다. 공식 문서에도 나오듯, 재귀적으로 구분자를 적용해 문단→문장→단어 순으로 쪼개면서 지정한 오버랩만큼 겹치는 구간을 유지합니다.

from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
)
chunks = splitter.split_text(long_text)
for i, c in enumerate(chunks[:2]):
print(f"chunk {i}: {len(c)}자, 끝부분: ...{c[-50:]}")
이렇게 실행하면 chunk 0의 끝 50자와 chunk 1의 시작 부분이 겹치는 것을 바로 확인할 수 있습니다. chunk_size 1000에 chunk_overlap 200이면 전체 문서 대비 청크 개수가 오버랩이 없을 때보다 대략 20~25% 늘어난다고 보면 됩니다. LlamaIndex의 SentenceSplitter 역시 같은 이름의 chunk_overlap 파라미터를 제공하는데, 이쪽은 문장 경계를 우선 존중한 뒤 남는 자리에 오버랩을 채우는 방식이라 LangChain과 결과물이 조금 다르게 나옵니다. 두 라이브러리의 스플리터 옵션은 LangChain 공식 텍스트 스플리터 문서에서 파라미터별 동작을 확인할 수 있습니다.
오버랩만 늘린다고 문맥 문제가 다 풀리진 않습니다
청크 오버랩을 200에서 400, 600으로 계속 늘리면 좋아질 것 같지만 실제로는 한계가 뚜렷합니다. 오버랩 비율이 chunk_size의 20~30%를 넘어가면 같은 문장이 여러 청크에 중복 저장되면서 벡터 스토어 용량과 임베딩 비용이 눈에 띄게 늘어납니다. 게다가 검색 결과 상위권에 내용이 겹치는 청크 두세 개가 동시에 올라오면, 정작 필요했던 다른 정보가 순위에서 밀려나는 역효과도 생깁니다.
더 근본적인 한계도 있습니다. 오버랩은 인접한 청크 사이의 경계 문제만 완화할 뿐, 문서 앞쪽 제목이나 정의가 수천 자 떨어진 본문 청크에서 필요한 경우에는 손을 쓰지 못합니다. 예를 들어 매뉴얼 3페이지 앞에 나온 “이 섹션은 프리미엄 요금제에만 해당함”이라는 단서가 본문 청크와 몇 개 건너 떨어져 있다면, 오버랩 몇백 자로는 절대 연결되지 않습니다. 이런 경우엔 오버랩 값을 조정하는 대신 구조 자체를 바꿔야 합니다.
문맥이 통째로 필요할 땐 부모-자식 청크 구조를 검토해 보세요
부모 자식 청크는 검색에 쓰는 조각과 LLM에 넘기는 조각을 아예 분리하는 방식입니다. 작은 자식 청크(예: 300~400자)로 정밀하게 검색한 뒤, 실제로 프롬프트에 넣는 건 그 자식 청크가 속한 훨씬 큰 부모 청크(예: 2000자) 전체입니다. 검색 정확도는 작은 단위에서, 문맥 보존은 큰 단위에서 각각 챙기는 구조입니다.
LangChain은 이 패턴을 ParentDocumentRetriever로 제공합니다.

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)
vectorstore = Chroma(collection_name="parent_child", embedding_function=OpenAIEmbeddings())
store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(docs)
results = retriever.invoke("계약 해지 예외 조건은?")
print(len(results[0].page_content)) # 자식이 아니라 부모 청크 길이가 출력됨
이 코드를 실행하면 벡터 스토어에는 자식 청크의 임베딩만 저장되고, 실제 results에는 그 자식이 속한 부모 청크 전체 텍스트가 담겨 나옵니다. 검색 단계에서 자식 청크가 짧아 정밀도가 높고, 답변 생성 단계에서는 부모 청크 덕분에 앞뒤 조건 문장까지 함께 넘어갑니다. 다만 부모 청크가 너무 크면 프롬프트 토큰 비용이 그만큼 늘어나므로 무작정 부모 크기를 키우는 것도 답은 아닙니다. RAG의 전반적인 개념과 검색-생성 결합 구조는 위키백과 검색 증강 생성 문서에서도 간단히 정리돼 있습니다.
오버랩 방식과 부모-자식 청크, 상황별 선택 기준
두 방식은 경쟁 관계라기보다 적용 범위가 다릅니다. 아래 기준으로 나눠보면 어느 쪽을 먼저 시도할지 판단하기 쉬워집니다.
| 구분 | 청크 오버랩 | 부모-자식 청크 |
|---|---|---|
| 해결하는 문제 | 인접 청크 경계에서 문장이 잘리는 것 | 검색 단위와 필요한 문맥 범위가 크게 다른 것 |
| 구현 난이도 | 파라미터 값 하나만 조정 (chunk_overlap) | 스플리터 2개, 저장소 2개(vectorstore+docstore) 구성 필요 |
| 비용 영향 | 오버랩 비율만큼 저장·임베딩량 증가 | 부모 청크 크기만큼 프롬프트 토큰 증가 |
| 적합한 문서 | 서술형 글, 블로그처럼 문장이 촘촘히 이어지는 문서 | 계약서·매뉴얼처럼 조건·예외가 멀리 떨어져 나오는 문서 |
| 한계 | 몇 문단 이상 떨어진 문맥은 못 살림 | 부모 청크가 커지면 관련 없는 내용도 함께 딸려 옴 |
문서가 짧은 뉴스 기사나 블로그 글이라면 chunk_overlap을 150~250 사이로 잡는 것만으로 충분한 경우가 많습니다. 반대로 사내 규정집이나 API 레퍼런스처럼 조건절이 본문과 멀리 떨어진 문서라면 처음부터 부모-자식 구조로 설계하는 편이 시행착오를 줄여줍니다.
지금 만드는 RAG라면 오버랩 200부터 켜고 필요할 때 부모-자식 구조로 넘어가세요
정리하면, 청크가 잘려 문맥이 끊기는 문제는 먼저 chunk_overlap 값을 chunk_size의 15~20% 수준으로 설정해 경계 문장 유실부터 막는 게 순서입니다. 그래도 답변이 조건이나 예외를 놓친다면, 오버랩 값을 더 키우기보다 ParentDocumentRetriever 같은 부모 자식 청크 구조로 넘어가 검색은 작은 단위로, 응답 생성은 큰 단위로 분리하는 편이 효과적입니다. 지금 운영 중인 RAG 파이프라인이 있다면, 최근 오답 사례 몇 개를 골라 검색된 청크의 앞뒤 200자를 직접 붙여 읽어보는 것부터 시작해보세요. 그 자리에서 바로 원인이 오버랩 부족인지, 구조 자체의 한계인지 드러납니다.
