경계 손실이 왜 검색 품질을 무너뜨리는가
RAG에서 문서를 고정 길이로 자르면 하나의 논리적 단위가 청크 경계에서 두 조각으로 쪼개진다. 정의와 그 부연 설명, 표의 헤더와 값, 조건문과 예외 처리가 서로 다른 청크로 흩어지면 임베딩 벡터는 문맥의 절반만 표현한다. 그 결과 사용자가 "환불 조건의 예외"를 물어도, 예외를 담은 청크는 조건이라는 앵커 문맥을 잃어 유사도 상위에 올라오지 못한다. 이것이 경계 손실(boundary loss)이며, 오버랩은 이를 완화하는 1차 방어선이다.
오버랩은 얼마가 적정한가
실무 기준은 청크 크기의 10~20%다. 512토큰 청크라면 50~100토큰 정도가 무난하다. 너무 작으면 경계 문장이 여전히 잘리고, 너무 크면 동일 내용이 여러 청크에 중복 색인되어 검색 결과가 near-duplicate로 도배되고 저장·임베딩 비용만 늘어난다.
| 오버랩 비율 | 경계 보존 | 중복/비용 | 권장 상황 |
|---|---|---|---|
| 0% | 나쁨 | 최소 | 이미 문단 단위로 정제된 문서 |
| 10~20% | 양호 | 수용 가능 | 대부분의 일반 문서 |
| 30%+ | 과도 | 높음 | 권장하지 않음 |
문장 경계를 존중하는 분할
토큰 수만 세어 자르지 말고 문장 경계에서 끊어야 한다. 아래는 문장 단위로 누적하되 목표 크기 초과 시 마지막 몇 문장을 다음 청크로 넘겨 오버랩을 만드는 방식이다.
from typing import List
def chunk_by_sentence(sentences: List[str], size=512, overlap=80, tok=len):
chunks, cur, cur_len = [], [], 0
for s in sentences:
sl = tok(s)
if cur_len + sl > size and cur:
chunks.append(" ".join(cur))
# 뒤에서부터 overlap 토큰만큼 문장을 되감아 다음 청크 시작으로
back, kept = 0, []
for prev in reversed(cur):
back += tok(prev)
kept.insert(0, prev)
if back >= overlap:
break
cur, cur_len = kept, back
cur.append(s); cur_len += sl
if cur:
chunks.append(" ".join(cur))
return chunks
구조를 아는 문서는 구조로 자른다
마크다운·HTML·코드처럼 명시적 구조가 있으면 오버랩보다 계층 분할이 우선이다. 헤더 경로를 메타데이터로 보존하면 잘려도 소속 맥락이 살아남는다.
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_split = MarkdownHeaderTextSplitter(headers)
docs = md_split.split_text(raw_markdown)
# 헤더 단위가 여전히 크면 문자 기준으로 2차 분할 + 오버랩
child = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=80)
final = child.split_documents(docs) # h1/h2/h3 메타데이터 자동 승계
헤더 프리픽스 주입
2차 분할된 청크 본문 앞에 "h2 > h3" 경로를 문자열로 덧붙여 임베딩하면, 오버랩 없이도 청크가 자기 위치를 설명하게 되어 경계 손실이 크게 줄어든다.
검색 단계에서 경계를 복원한다
색인은 작게, 반환은 넓게가 핵심이다. 작은 청크로 정확히 매칭시킨 뒤, 검색 시점에 앞뒤 인접 청크를 함께 붙여 LLM에 넘기면 오버랩을 과하게 주지 않고도 문맥을 복원할 수 있다. 인접 청크는 원문 순서 인덱스로 조회한다.
-- chunks(doc_id, seq, text, embedding) 구조 가정
-- 매칭된 청크의 seq 기준 ±1 이웃까지 확장 조회
SELECT c.seq, c.text
FROM chunks c
JOIN (
SELECT doc_id, seq
FROM chunks
ORDER BY embedding <=> :query_vec -- pgvector 코사인 거리
LIMIT 1
) hit ON c.doc_id = hit.doc_id
WHERE c.seq BETWEEN hit.seq - 1 AND hit.seq + 1
ORDER BY c.seq;
운영 시 주의점
- 중복 반환 제거: 오버랩과 이웃 확장을 함께 쓰면 같은 문장이 두 번 프롬프트에 들어간다. 반환 후 seq 기준으로 텍스트를 병합·중복 제거해야 토큰 낭비와 혼선을 막는다.
- 오버랩은 재색인 비용: 오버랩을 늘리면 청크 수가 증가해 임베딩 호출과 벡터 저장 비용이 비례해 커진다. 값 변경 시 전체 재색인이 필요하므로 초기 실험에서 확정하는 편이 좋다.
- 토크나이저 일치: 분할 시 세는 토큰과 임베딩 모델의 토큰이 다르면 실제 청크가 모델 상한을 넘겨 잘릴 수 있다. 가능하면 동일 토크나이저로 길이를 측정한다.
- 평가로 검증: 오버랩·청크 크기는 문서 특성에 따라 최적값이 다르다. 정답 문단이 검색 상위 k에 들어오는 비율(recall@k)로 후보 설정을 비교해 데이터 기반으로 고정한다.