청킹 — 검색 단위를 정하는 일이 절반이다
한 줄 요약
청크는 검색의 최소 단위이자 컨텍스트에 들어가는 단위다. 너무 크면 노이즈가 섞이고 너무 작으면 문맥이 끊긴다. 좋은 청크의 기준은 하나다. 그 청크만 읽고도 질문 하나에 답할 수 있어야 한다.
왜 이게 필요했나
40쪽짜리 운영 매뉴얼을 통째로 한 건으로 색인했다고 하자. "백업 보존 기간은?" 이라고 물으면 매뉴얼이 1위로 나온다. 검색은 성공했다. 그런데 컨텍스트에 40쪽을 넣으니 토큰 예산을 넘고, 넣을 수 있다 해도 정답 한 줄이 긴 글의 가운데에 묻혀 모델이 놓친다. 반대로 매뉴얼을 한 줄씩 잘라 색인하면 "그 값은 30일이다" 라는 줄이 나오는데, 무엇의 값인지는 앞 줄에 있어 알 수 없다.
문서를 통째로 색인하면 두 가지가 무너진다. 첫째, 긴 문서는 여러 주제를 담고 있어 벡터가 평균으로 뭉개진다. 둘째, 검색에 성공해도 컨텍스트에 문서 전체를 넣어야 해서 토큰이 폭발한다. 그래서 문서를 잘라야 하는데, 어떻게 자르느냐가 검색 품질을 좌우한다. 그리고 한 번 정한 방식은 바꾸기 비싸다. 청킹 전략을 바꾸면 전체를 다시 색인해야 하기 때문이다.
어떻게 동작하나
고정 길이 분할은 N 글자 또는 N 토큰마다 자르는 방식이다. 구현이 단순하고 크기가 예측 가능하지만 문장 중간에서 끊겨 의미가 깨진다. 길이를 잴 때는 글자보다 토큰이 낫다. 임베딩 모델과 생성 모델의 한도가 모두 토큰으로 정해져 있기 때문이다.
구분자 기반 분할은 문단이나 문장 경계에서 자른다. 의미 단위를 보존하지만 길이가 들쭉날쭉해진다. 실무에서는 문단으로 먼저 나누고, 너무 긴 문단만 문장으로 다시 나누고, 너무 짧은 조각은 이웃과 합치는 식으로 두 방식을 섞는다.
겹침(overlap) 은 인접 청크가 일부를 공유하게 하는 기법이다. 경계에 걸친 정보가 어느 쪽에서도 검색되지 않는 문제를 완화한다. 대가는 저장량과 중복 검색이다. 400토큰 청크에 50토큰을 겹치면 청크가 350토큰마다 시작하므로 청크 수가 약 14% 늘고, 같은 내용이 상위 결과에 두 번 올라와 컨텍스트 자리를 낭비하기도 한다.
계층적 분할은 작은 청크로 검색하고 그 청크가 속한 큰 단위(문단이나 절)를 컨텍스트로 넣는 방식이다. 검색 정밀도와 문맥 완결성을 함께 얻으려는 절충이다.
메타데이터는 청크의 부족한 문맥을 채운다. 문서 제목, 절 경로, 갱신일을 청크에 붙여 두면 검색 결과에 문맥을 보태고 필터링에도 쓸 수 있다. 청크 텍스트 앞에 제목과 절 이름을 덧붙여 색인하는 것만으로 검색 품질이 눈에 띄게 오르는 경우가 많다.
크기를 정하는 실용적인 기준으로 돌아가면, 청크 하나를 떼어 읽었을 때 "그것은" 이 무엇을 가리키는지 알 수 없다면 문맥이 부족한 것이고, 한 청크에 서로 무관한 주제가 셋 들어 있다면 벡터가 뭉개진 것이다.
현장에서 무엇이 잘못되나
마침표로 자르다 엉뚱한 곳이 잘린다. 마침표는 문장 끝에만 오지 않는다. 3.5, v1.2, 도메인 이름, 약어, 줄임표에서 문장이 쪼개진다. 반대로 목록, 표, 코드처럼 마침표가 없는 부분은 하나로 이어 붙어 거대한 청크가 된다. 증상은 청크 길이 분포의 양 끝에 몇 글자짜리 조각과 수천 글자짜리 덩어리가 함께 보이는 것이다.
표와 코드 블록이 찢어진다. 고정 길이로 자르면 표의 헤더와 본문이 분리되어 양쪽 다 쓸모없어진다. 숫자만 남은 청크는 무슨 숫자인지 알 수 없다. 헤더를 조각마다 반복해 넣거나 표는 통째로 한 청크로 두는 예외 처리가 필요하다.
청크의 꼬리가 조용히 잘린다. 임베딩 모델에는 입력 길이 한도가 있고, 그보다 긴 텍스트는 대개 앞부분만 남기고 잘린다. 예를 들어 sentence-transformers 는 모델의 max_seq_length 를 넘는 입력을 그 길이까지만 쓰고, BERT 계열에서 흔한 값은 512토큰이다. 오류는 나지 않는다. 청크 뒷부분에 있던 내용은 벡터에 반영되지 않아 영영 검색되지 않는다. 청크 최대 길이는 임베딩 모델의 한도보다 작게 잡는다.
청크 번호가 밀린다. 청크 id 를 "문서의 몇 번째 조각" 으로만 매기면 문서 앞부분에 문장 하나만 추가되어도 뒤쪽 번호가 전부 한 칸씩 밀린다. 평가 집합의 정답 라벨, 캐시, 사용자 피드백 기록이 전부 엉뚱한 청크를 가리키게 된다. 오래 쓸 시스템이라면 문서 id 와 내용 해시로 안정된 id 를 만든다.
어떻게 확인하나
청크를 만들었다면 색인하기 전에 분포와 표본을 본다.
import statistics, random
rows = [line.rstrip("\n").split("\t", 2)
for line in open("chunks.tsv", encoding="utf-8")]
lens = [len(r[2]) for r in rows]
print(len(rows), min(lens), statistics.median(lens), max(lens))
print(sum(1 for n in lens if n < 15), "very short chunks")
for r in random.sample(rows, 5):
print(r[0], r[1], r[2])
숫자에서는 최솟값과 최댓값을 먼저 본다. 아주 짧은 조각이 많으면 분할 규칙이 문장이 아닌 곳을 자르고 있는 것이고, 아주 긴 조각이 있으면 구분자가 없는 영역이 있는 것이다. 무작위 표본 다섯 개는 소리 내어 읽어 본다. 청크 하나만 보고 "이것으로 어떤 질문에 답할 수 있나" 를 말할 수 없다면 그 청크는 검색에 걸려도 쓸모가 없다.
평가 질문이 있다면 한 가지를 더 한다. 각 질문의 정답 문장이 한 청크 안에 온전히 들어 있는지 확인하는 것이다. 정답이 두 청크에 걸쳐 있다면 그 질문은 어떤 검색기로도 온전히 맞힐 수 없다. 마지막으로 같은 질의를 문서 단위 색인과 청크 단위 색인에서 각각 돌려 Recall@K 를 비교하면, 청킹이 실제로 도움이 되었는지 숫자로 말할 수 있다.
다음 실습에서 할 것
문서 30건을 마침표 기준으로 잘라 90여 개의 청크로 나누고, 앞뒤 공백을 지우고 빈 조각을 버리는 규칙을 지켜 저장한다. 그 청크 단위로 TF-IDF 색인을 만들어 8개 질의를 검색하고, Recall@3 과 MRR 로 검색 품질을 잰 뒤, 컨텍스트 조립과 근거 판정, 코퍼스 밖 질문 거절까지 RAG 파이프라인 한 벌을 완성한다. 마침표 분할이 어디서 어색하게 자르는지도 직접 확인해 본다.