RAG 시스템의 기초부터 다시 파헤쳐 보고자 데이터를 나누는 과정부터 다시 공부해봤다.
Greg Kamradt의 영상을 통해 학습했으며 큰 뼈대를 정리해두고자 한다.
왜 Text Splitting이 필요할까?
예전 LLM에는 context limit이 있어 문서를 통째로 넣을 수 없었다. 요즘은 수백만 토큰을 받는 모델도 있지만, 문서 하나를 context에 넣을 수 있어도 검색 대상이 되는 대량의 문서는 넣을 수 없다.
넣을 수 있더라도 비용과 속도, 긴 입력 중간을 놓치는 문제가 있다. 그래서 잘라야 한다.
따라서 핵심은 잘 자르는 것 자체보다 나중에 할 검색에 맞게 준비하는 것이다.
("Goal of Splitting/Chunking - Prepare your data for anticipated tasks")
그리고 설계만큼 중요한 것이 평가다. 영상에서는 RAGAS(https://www.ragas.io/)를 예시로 소개한다.
이제부터 글자 단위로 나누는 것부터 시작해 데이터의 특성을 고려한 복잡한 방법까지 차근차근 살펴보자.
Level 1. Character Splitting
단순하게 글자 수로 자르는 것이다. 의미는 보지 않고 정한 사이즈대로 자른다.
이때 청크끼리 겹치는 구간(overlap)을 두는데, 경계에서 잘린 문장의 앞뒤 맥락을 잃지 않기 위해서다.
Greg이 만든 사이트에서 실험해 볼 수 있다. (https://www.chunkviz.com/ )
➡️ 하지만 의미를 보지 않으니 문장이나 단어 중간에서도 잘린다.
Level 2. Recursive Character
아직까지 단순 텍스트 자르기이지만 좀 더 복잡한 규칙을 사용한다.
우선순위를 가진 여러 개의 기준(구분자)을 순서대로 큰 단위부터 시도하고, 크기를 넘으면 더 작은 단위로 내려간다.
- "\n\n" - 문단 단위로 큼직하게 자른다.
- "\n" - 문단이 아직 크다면 그 덩어리를 다시 줄바꿈 단위로 더 잘게 자른다.
- " " - 그래도 크면 띄어쓰기 단위로 자른다.
- "" - 그래도 크면 글자 단위로 쪼갠다.
➡️ 글이 원래 문단으로 나뉘어 있다는 점을 이용하여, 가장 기본적이면서도 추천하는 분할 방법이다.
다만 텍스트가 깔끔하게 주어졌을 때의 이야기이며 PDF 속 표나 이미지는 자르기 전에 추출하는 것부터 문제다.
Level 3. Document Specific
이제부터 데이터의 타입을 고려한다.
PDF 안에 표는 HTML로 뽑고, 이미지는 추출해서 따로 관리하는 것을 뜻한다.
unstructured(https://unstructured.io/) 를 이용해 비정형 데이터를 정형화할 수 있다.
문제는 이미지와 텍스트 임베딩 모델이 다르면 벡터 공간이 달라서 제대로 비교할 수 없다는 것이다.
영상에서는 이미지를 요약 텍스트로 바꿔 같은 모델에 넣는 방법을 소개한다.
➡️ 여기까지는 모두 크기와 형식을 기준으로 잘랐다. 실제 내용이 어디서 바뀌는지 아직 보지 않았다.
Level 4. Semantic Splitting
이전까지는 책들을 크기에 따라 책장에 넣는 느낌이었다면, 이제는 장르처럼 카테고리에 따라 분류한다.
실제 의미가 바뀌는 지점에서 자르는 것이다.
여기서는 임베딩을 쓴다. 임베딩이 비슷하면 비슷한 의미일 것이고, 거리가 유의미하게 크면 청크를 나눠야 한다는 생각이다.
1. 텍스트를 문장 단위로 나눈다.
2. 문장마다 임베딩한다.
3. 이웃 문장 간 거리를 계산해 시각화한다.
4. threshold(예: 95 percentile)를 넘는 곳부터 다른 청크로 나눈다.
➡️ 임베딩이 청킹까지 해 주는 것은 아니다. 임베딩은 거리만 알려 주고, 자르는 건 코드와 threshold다.
또 이웃한 문장끼리만 비교하니, 떨어져 있는 같은 주제의 문장은 한 청크로 묶지 못한다.
Level 5. Agentic Splitting
이번에는 LLM이 chunk의 구성을 직접 판단한다.
먼저 LLM으로 글을 명제(proposition)로 나눈다. 명제는 그 문장 자체로 하나의 의미를 갖는 문장이다.
- 그렉은 공원에 갔다. 그는 걷는 것을 좋아한다. ( 여기서 proposition은 "그렉은 공원에 갔다.", "그렉은 걷는 것을 좋아한다. "이다)
- "그는"을 "그렉은"으로 바꾼 건 문장 하나만 떼어 놓아도 뜻이 통하게 하기 위해서다.
그다음 명제를 기존 청크에 넣을지 새로 만들지 판단하고, chunk_summary와 chunk_title을 갱신한다.
비싸고 복잡하지만 좀 더 효과적으로 chunking이 가능할 수도 있다.
명제로 나누는 프롬프트는 LangSmith에 공개되어 있다.
(https://smith.langchain.com/hub/wfh/proposal-indexing)
Bonus. Alternative Representation
청크 크기에는 딜레마가 있다. 작게 자르면 검색은 정확할 수 있어도 LLM에 넘길 맥락이 부족하다. 반대로 크게 자르면 맥락은 충분하지만 벡터 안에 여러 주제가 섞일 수 있다.
그래서 원문 대신 원문을 가공한 텍스트로 검색하는 게 더 나을 수도 있다. 질문과 비교할 가공 텍스트는 벡터 DB에, 원문은 별도 저장소에 두고 id로 잇는다. (Multi-vector indexing)
- summaries : LLM이 만든 chunk 요약으로 검색한다.
- hypothetical questions : 이 청크가 답이 될 질문들로 검색한다.
- child documents : 작게 자른 자식 청크들로 검색하고, 그 부모 청크를 돌려준다.
graph_document는 조금 다르다. 벡터가 아니라 구조로 데이터를 저장한다.
마치며
5단계를 순서대로 살펴보며 각각 어떤 문제를 해결하기 위해 나온 방법론인지 알아봤다.
특히 text splitting에서 이렇게 다양한 방법론이 나오는 이유는 데이터 타입도 다양하고, 의미가 바뀌는 지점도 규칙만으로 잡히지 않기 때문이지 않을까 생각했다.
영상은 2024년에 제작되어 이후 업데이트된 것들을 더 찾아볼 필요가 있다.