의미 단위로 분할하는 SemanticChunker
의미로 분할하는 SemanticChunker
지난 글에서 정리한 RecursiveCharacterTextSplitter는 결국 길이 기준으로 텍스트를 자르는 도구였다.
그래서 하나의 청크 안에 서로 다른 주제가 섞여 있다가 중간에 끊기는 한계가 있었다.
이번엔 이 문제를 보완하는 SemanticChunker를 정리해본다.
SemanticChunker는 뭐가 다른가
SemanticChunker도 긴 텍스트를 짧은 청크로 나눈다는 목적은 같다. 다만 동작 방식이 다르다.
- 각 문장을 임베딩 벡터로 변환한다 (OpenAI Embedding API 사용)
- 인접한 문장 쌍 사이의 코사인 거리를 계산한다
- 거리가 일정 기준(threshold)보다 크게 벌어지는 지점 = 의미가 바뀌는 지점으로 보고 그 지점에서 텍스트를 자른다
즉 길이가 아니라 문장 간 의미적 차이를 기준으로 자르는 방식이라, 주제가 바뀌는 지점을 상대적으로 잘 포착한다.
여기서 코사인 거리는 다음과 같이 정의된다.
코사인 거리 = 1 - 코사인 유사도값의 범위는 0~2 사이이며, 0에 가까울수록 두 문장이 유사하고 2에 가까울수록 다르다는 뜻이다.
실습 코드
ch02_SEMANTIC_TEXT_SPLITTER.ipynb
import os
import urllib.request
from langchain_openai import OpenAIEmbeddings
from langchain_experimental.text_splitter import SemanticChunker
os.environ['OPENAI_API_KEY']="발급받은 API 키"SemanticChunker를 생성할 때는 사용할 임베딩 객체를 인자로 넘긴다.
text_splitter= SemanticChunker(OpenAIEmbeddings())
texts= text_splitter.create_documents([file])
print("분할된 청크의 수:",len(texts))OpenAI Embedding API는 유료이기 때문에 실습할 때는 너무 긴 텍스트를 넣지 않는 게 좋다.
짧은 샘플 텍스트로도 청크가 실제로 문맥이 바뀌는 지점에서 나뉘는지 확인할 수 있다.
예를 들어 인물의 커리어를 다루는 텍스트라면, "기대감을 안고 지원했다"는 내용의 청크 다음에 "그러나 현실은 냉혹했다"로 반전되는 청크가 이어지는 식으로, 흐름이 바뀌는 부분에서 잘려 있는 걸 확인할 수 있다.
세 가지 분할 방식
SemanticChunker는 코사인 거리를 기준으로 어디서 자를지 정할 때, 세 가지 방식 중 하나를 고를 수 있다. breakpoint_threshold_type 파라미터로 지정한다.
breakpoint_threshold_amount의 기본값은 95.
- 문장 쌍마다 코사인 거리를 계산해서 크기순으로 정렬한다
- 예를 들어 값이 95면, 전체 거리 중 95%가 그보다 작은 지점(=상위 5% 거리)을 기준값으로 삼는다
- 이 기준값보다 큰 거리를 가진 지점에서 자른다
문장 100개가 있으면 거리값은 99개가 생기는데, 이걸 정렬해서 95번째로 큰 값을 기준으로 삼는 식이다.
text_splitter= SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95,
)
texts= text_splitter.create_documents([file])
print('분할된 청크의 수:', len(texts))극단적으로 의미 차이가 큰 지점에서만 자르기 때문에, 진짜 주제가 바뀌는 곳을 잘 찾아내는 편이다.
breakpoint_threshold_amount의 기본값은 3.
- 모든 거리값의 평균과 표준편차를 구한다
- 기준값 = 평균 + (threshold_amount × 표준편차)
- 이 기준값보다 큰 지점에서 자른다
예를 들어 평균이 0.5, 표준편차가 0.1이면 기준값은 0.5 + (3 × 0.1) = 0.8이 된다.
text_splitter= SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="standard_deviation",
breakpoint_threshold_amount=3,
)
texts= text_splitter.create_documents([file])평균적인 문장 간 차이보다 훨씬 크게 벌어지는 지점을 잡아내는 방식이라, 주제 전환이나 이야기의 큰 흐름이 바뀌는 지점을 잘 감지한다.
breakpoint_threshold_amount의 기본값은 1.5.
- 거리값들의 1사분위수(Q1, 25% 지점)와 3사분위수(Q3, 75% 지점)를 구한다
- IQR(사분위 범위) = Q3 - Q1
- 기준값 = 거리 평균 + (threshold_amount × IQR)
- 이 기준값보다 큰 지점에서 자른다
예를 들어 Q1이 0.3, Q3가 0.7, 평균이 0.5, threshold_amount가 1.5라면 IQR은 0.4, 기준값은 0.5 + (1.5 × 0.4) = 1.1이 된다.
text_splitter= SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="interquartile",
breakpoint_threshold_amount=1.5,
)
texts= text_splitter.create_documents([file])극단값(이상치)에 덜 민감해서, 전체적인 흐름은 해치지 않으면서도 중요한 주제 전환은 잘 포착하는 편이다.
세 방식 비교
같은 텍스트라도 방식에 따라 청크 개수가 꽤 달라진다.
실제로 같은 텍스트를 세 방식으로 나눠보면 청크 개수 자체가 다르게 나오는데, 어떤 방식이 "정답"이라기보다는 문서 특성에 따라 실험적으로 골라야 하는 값에 가깝다.
정리
SemanticChunker는 길이가 아니라 문장 간 코사인 거리(의미적 차이)를 기준으로 텍스트를 분할한다- 자르는 기준을 정하는 방식은 백분위수 / 표준편차 / 사분위수 세 가지가 있고,
breakpoint_threshold_type으로 선택한다 RecursiveCharacterTextSplitter보다 문맥을 더 잘 보존하지만, 임베딩 API 호출 비용이 든다는 트레이드오프가 있다- RecursiveCharacterTextSplitter의 결과가 만족스럽지 않을 때 대안으로 고려할 만하다