이 글의 목표 — ① 문서를 왜 조각내야 하는지 ② 어떻게 잘라야 잘 자른 것인지 ③ 글자가 숫자로 바뀌는 과정 ④ 컴퓨터가 "의미가 비슷하다"를 계산으로 판단하는 방법을 이해합니다.
1편에서 RAG의 준비 단계를 이렇게 정리했습니다.
문서 → 조각내기 → 숫자로 변환 → 데이터베이스 저장
(청킹) (임베딩)
이번 편은 앞의 두 작업을 다룹니다. 이 두 단계가 RAG 품질의 대부분을 결정하기 때문에, 시리즈에서 가장 중요한 내용이라고 해도 지나치지 않습니다.
문서를 검색에 쓸 수 있는 작은 단위로 나누는 작업을 청킹이라고 합니다. 잘라 놓은 조각 하나를 청크라고 부릅니다.
50쪽짜리 매뉴얼을 통째로 하나의 단위로 다루면 안 되는 이유는 세 가지입니다.
첫째, 검색이 부정확해집니다. 50쪽 전체를 하나로 뭉뚱그리면 "이 문서는 매뉴얼입니다" 정도의 뭉툭한 성격만 남습니다. 안에 담긴 신청 절차, 수수료, 반출 규정 같은 세부 내용이 서로 희석되어 버립니다.
둘째, 필요한 부분만 꺼낼 수 없습니다. 수수료를 물었는데 50쪽 전체를 가져오면 LLM이 그 안에서 다시 찾아야 합니다. 처음부터 수수료 부분만 정확히 꺼내 오는 편이 훨씬 낫습니다.
셋째, 분량 제한이 있습니다. LLM이 한 번에 읽을 수 있는 양에는 한계가 있습니다. 문서 몇 개만 넣어도 금방 한도를 넘깁니다.
주의 — 검색이 잘 안 될 때 대부분의 사람들이 모델부터 바꾸려고 합니다. 그러나 실제 원인은 대개 청크가 너무 크거나 엉뚱한 지점에서 잘린 것입니다. 청킹은 가장 큰 영향을 주면서도 가장 소홀히 다뤄지는 단계입니다.
단순하게 생각하면 "500자마다 자르기"가 떠오릅니다. 실제로 그런 방식도 있고 고정 크기 청킹이라고 부릅니다. 그런데 이렇게 자르면 문장 한가운데나 심지어 단어 중간에서 끊깁니다.
"3분기 매출은 1,250억|원으로 전년 대비..."
↑ 여기서 잘림
그래서 실무에서는 재귀적 청킹을 씁니다. 이름은 어렵게 들리지만 원리는 간단합니다. 구분자를 우선순위대로 시도하되, 조각이 아직 크면 더 작은 구분자로 다시 자르는 방식입니다.
| 순위 | 구분자 | 의미 |
|---|---|---|
| 1 | 빈 줄 | 문단 단위로 자릅니다 |
| 2 | 줄바꿈 | 문장 단위로 자릅니다 |
| 3 | 공백 | 단어 단위로 자릅니다 |
| 4 | 없음 | 마지막 수단으로 글자 단위로 자릅니다 |
최대 크기를 100자로 정했다고 가정하고 따라가 보겠습니다.
핵심은 문제가 되는 조각만 더 잘게 나눈다는 점입니다. 이미 적당한 크기인 부분은 그대로 둡니다. 그래서 결과가 자연스럽고, 대부분의 조각이 문단이나 문장 경계에서 깔끔하게 끊깁니다.
아무리 잘 잘라도 조각과 조각 사이에서는 흐름이 끊깁니다. 앞 조각의 마지막 부분을 다음 조각 앞에 조금 겹쳐 두면 이 문제가 완화됩니다. 이것을 오버랩이라고 합니다.
조각 1: "...신청서를 제출합니다. 심의는 7일이 걸립니다."
조각 2: "심의는 7일이 걸립니다. 승인 후 수수료를 납부합니다..."
└─────── 겹치는 부분 ───────┘
분량은 보통 조각 크기의 10~20%로 잡습니다. 500자로 자른다면 50~100자 정도를 겹칩니다.
널리 쓰이는 방식은 세 가지입니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 고정 크기 | 구현이 단순하고 빠릅니다. 조각 크기를 예측할 수 있습니다 | 문장이나 단어 중간에서 잘립니다 |
| 재귀적 | 의미 경계에서 잘리고 설정이 간단합니다 | 내용이 바뀌는 지점을 완벽히 감지하지는 못합니다 |
| 시맨틱 | 주제가 바뀌는 지점을 정확히 찾아 가장 자연스럽게 나눕니다 | 준비 과정이 무거워 느리고 비용이 큽니다 |
시맨틱 청킹은 문장들을 미리 숫자로 바꿔 보고, 앞뒤 문장의 성격이 급격히 달라지는 지점을 주제 전환으로 판단해 자릅니다. 정확하지만 문서 한 건마다 계산을 여러 번 돌려야 합니다.
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 처음 만들어 보는 경우 | 재귀적 | 설정 두 줄로 충분한 품질이 나옵니다 |
| 사내 매뉴얼·규정 | 재귀적 | 제목과 문단 구조가 있어 잘 맞습니다 |
| 뉴스·논문 등 긴 글 | 시맨틱 | 주제 전환이 문단과 일치하지 않습니다 |
| 코드 문서 | 고정 크기 + 별도 구분자 | 함수 단위로 자르는 편이 낫습니다 |
| 표 형태 데이터 | 행 단위 | 행 하나가 곧 의미 단위입니다 |
같은 재귀적 청킹을 쓰더라도, 문서 성격에 맞춰 크기와 기준을 조정해야 합니다. 실제 사례를 보시면 감이 잡히실 것입니다.
| 문서 유형 | 청크 단위 | 이유 |
|---|---|---|
| 이용 매뉴얼 | 절차 단위 (1단계~N단계를 한 조각으로) | 단계가 흩어지면 순서 정보가 사라집니다 |
| 조사 메타정보 | 조사명 + 항목 목록 전체 | 한 조사의 정보가 통째로 검색되어야 합니다 |
| 운영 규정 | 조항 단위 | "제 몇 조" 단위로 인용되므로 쪼개면 안 됩니다 |
여기까지가 청킹입니다. 이제 잘라 놓은 조각을 컴퓨터가 다룰 수 있는 형태로 바꿀 차례입니다.
컴퓨터는 숫자만 다룹니다. "의미가 비슷한 문서를 찾아라"라는 요구를 컴퓨터가 처리하게 하려면, 먼저 글을 숫자로 바꿔야 합니다.
이 변환을 하는 프로그램을 임베딩 모델이라고 하고, 결과로 나오는 숫자 묶음을 벡터라고 합니다.
지도에서 어떤 장소를 가리킬 때 위도와 경도 두 숫자를 씁니다. 숫자 두 개로 평면 위 한 점이 정해집니다. 벡터도 같습니다. 다만 숫자가 두 개가 아니라 수백에서 수천 개입니다.
지도 위의 점 : (37.5, 127.0) ← 숫자 2개
임베딩 벡터 : (0.12, -0.08, ..., 0.33) ← 숫자 768개
숫자의 개수를 차원이라고 부릅니다. 768차원이라는 말은 숫자가 768개라는 뜻입니다.
중요한 점은 의미가 비슷한 글끼리 가까운 위치에 놓이도록 모델이 학습되어 있다는 것입니다. "수수료"에 관한 조각과 "이용료"에 관한 조각은 서로 가까운 곳에, "반출 규정"에 관한 조각은 멀리 떨어진 곳에 놓입니다.
두 벡터가 얼마나 비슷한지는 둘 사이의 각도로 잽니다. 이것을 코사인 유사도라고 합니다.
| 값 | 의미 |
|---|---|
| 1.0 | 완전히 같은 방향 — 사실상 같은 의미 |
| 0.8 안팎 | 상당히 관련 있음 — 검색에서 통과시키는 구간 |
| 0.0 | 직각 — 서로 무관한 의미 |
| -1.0 | 정반대 방향 |
차원이 768이든 4096이든 계산 원리는 지도 위 두 점 사이의 각도를 재는 것과 완전히 같습니다. 우리 머릿속에 그림으로 그릴 수 없을 뿐, 수학적으로는 다르지 않습니다.
이것을 확인해 주는 유명한 예가 있습니다.
벡터("왕") - 벡터("남자") + 벡터("여자") ≈ 벡터("여왕")
"남자에서 여자로 가는 방향"과 "왕에서 여왕으로 가는 방향"이 거의 같다는 뜻입니다. 성별이라는 개념이 이 공간의 특정 방향으로 자리를 잡고 있는 것입니다. 누가 정해 준 것이 아니라 학습 과정에서 저절로 그렇게 되었습니다.
"사과를 맛있게 먹었다"라는 문장이 벡터가 되는 과정을 순서대로 보겠습니다.
먼저 문장을 모델이 아는 최소 단위로 쪼갭니다. 이 단위를 토큰이라고 합니다. 단어 전체일 수도 있고 단어의 일부일 수도 있습니다.
"안녕하세요 반갑습니다"
→ [안녕] [하세요] [반갑] [습니다]
→ [1234, 5678, 9012, 3456] ← 각 토큰에 붙은 번호
번호를 붙일 때 참고하는 목록을 어휘 사전이라고 합니다. 모델을 만들 때 대량의 글에서 자주 나오는 조각을 골라 미리 만들어 두고, 이후에는 바뀌지 않습니다. BERT는 약 3만 개, 최신 모델은 10만 개 이상의 토큰을 가지고 있습니다.
토큰 번호로 임베딩 테이블이라는 큰 표에서 해당 줄을 꺼냅니다. 이 표는 어휘 개수 × 차원 수 크기입니다. 어휘 3만 개에 768차원이면 2,300만 개의 숫자가 들어 있는 표가 됩니다.
이 표의 값들은 처음에는 아무 의미 없는 무작위 숫자입니다. 학습을 거치면서 의미가 비슷한 토큰끼리 비슷한 값을 갖도록 조정됩니다. 학습이 끝나면 고정되고, 이후에는 그냥 꺼내 쓰기만 합니다.
여기까지만 하면 "사과가 배를 먹었다"와 "배가 사과를 먹었다"가 똑같아집니다. 단어들의 순서 정보가 없기 때문입니다. 그래서 몇 번째 토큰인지를 나타내는 값을 더해 줍니다.
각 토큰이 문장 안의 다른 토큰들을 둘러보고, 관련 있는 쪽의 정보를 자기 값에 섞어 넣습니다. "먹었다"가 "사과를"을 참고하는 것이 이 단계입니다.
이 과정을 어텐션이라고 부릅니다. Transformer라는 구조의 핵심이며, 자세한 동작은 이 시리즈 5편에서 다룹니다. 지금은 단어들이 서로 정보를 주고받아 문맥을 반영한다는 정도로 충분합니다.
4단계와 추가 가공을 여러 번 반복합니다. 작은 모델은 12번, 큰 모델은 수십 번 반복합니다. 반복할수록 표현이 정교해집니다.
지금까지는 토큰마다 벡터가 하나씩 있었습니다. 이것을 합쳐 문장 전체를 대표하는 벡터 하나를 만듭니다. 이 작업을 풀링이라고 합니다.
| 방식 | 설명 |
|---|---|
| 평균 내기 | 모든 토큰 벡터의 평균을 냅니다. 가장 널리 쓰입니다 |
| 맨 앞 토큰 | 문장 시작을 알리는 특수 토큰의 값을 대표로 씁니다 |
| 맨 뒤 토큰 | 마지막 토큰의 값을 씁니다. 최신 모델 일부가 이 방식입니다 |
최종 결과는 숫자 배열 하나입니다.
"사과를 맛있게 먹었다"
→ [0.1243, -0.0872, 0.3341, ..., 0.2201] (숫자 768개)
이 벡터가 원문 텍스트, 출처, 쪽 번호와 함께 데이터베이스에 저장됩니다. 원문을 같이 저장하는 이유는, 검색으로 찾아낸 뒤 LLM에게 건네줄 것은 숫자가 아니라 원문이기 때문입니다.
임베딩 모델은 종류가 많습니다. 언어, 차원, 운영 방식이 각각 다릅니다.
| 모델 | 차원 | 특징 |
|---|---|---|
| Qwen3-Embedding-8B | 4,096 | 한국어 성능이 높고 사내 서버 설치가 가능합니다 |
| text-embedding-3-large | 3,072 | 성능이 높지만 외부 API이며 과금됩니다 |
| text-embedding-3-small | 1,536 | 비용 절감형이며 성능도 준수합니다 |
| multilingual-e5-large | 1,024 | 여러 언어를 고르게 지원합니다 |
| KoSimCSE-RoBERTa | 768 | 한국어 문장 유사도에 특화되어 있습니다 |
| all-MiniLM-L6 | 384 | 매우 가볍고 빠릅니다 |
사내 문서를 다룬다면 선택지가 크게 좁아집니다. 1편의 보안 안내에서 말씀드린 대로, 외부 API로 문서를 보낼 수 없으므로 사내 서버에 설치할 수 있는 모델이어야 합니다.
| 구간 | 장점 | 단점 |
|---|---|---|
| 384~768 | 검색이 빠르고 저장 공간을 적게 씁니다 | 미묘한 의미 차이를 구분하기 어렵습니다 |
| 1,024~1,536 | 속도와 정확도의 균형이 좋습니다 | 양쪽 장점을 절반씩 가집니다 |
| 3,072 이상 | 복잡한 의미 관계까지 잡아냅니다 | 검색이 느리고 저장·계산 비용이 큽니다 |
차원의 저주 — 차원이 지나치게 커지면 모든 점 사이의 거리가 비슷해져서 오히려 가까운 것을 골라내기 어려워집니다. 무조건 높은 차원을 고르면 되는 것이 아니라 다루는 자료에 맞춰야 하며, 실무에서는 768~1,536 구간이 가장 많이 쓰입니다.
정리하면 다음과 같습니다.
문서 ─▶ 청킹 ─▶ 조각 여러 개
│
├─▶ 임베딩 ─▶ 벡터 (숫자 묶음)
└─▶ 원문·출처·쪽번호
│
▼
데이터베이스에 함께 저장
문서 한 개가 벡터 한 개를 갖나요?
아닙니다. 청킹으로 나뉜 조각 하나가 벡터 하나를 갖습니다. 문서 100개를 각각 20조각으로 나눴다면 벡터는 2,000개가 됩니다. 규모가 큰 시스템에서는 수백만 개의 벡터를 다루게 됩니다. 다만 문서 분류나 추천처럼 문서 전체를 하나로 다루는 작업에서는 문서당 벡터 하나를 쓰기도 합니다.
수백 글자를 숫자 768개로 압축하면 내용이 손실되지 않나요?
손실됩니다. 벡터에서 원문을 복원할 수는 없습니다. 그런데 목적이 원문 복원이 아니라 비슷한 것 찾기이므로 문제가 되지 않습니다. 게다가 원문은 벡터와 함께 그대로 저장해 두기 때문에, 검색으로 찾아낸 뒤에는 온전한 원문을 LLM에게 건넬 수 있습니다.
조각 하나가 그 공간의 점 하나가 되는 것이 맞나요?
맞습니다. 조각 하나에서 나온 숫자들이 그대로 좌표가 됩니다. 의미가 비슷한 조각들은 그 공간에서 서로 가까운 곳에 모여 있게 됩니다.
오버랩을 크게 잡으면 더 좋은가요?
어느 정도까지는 그렇지만 무한정은 아닙니다. 겹치는 만큼 조각 수가 늘어나 저장 공간과 검색 시간이 함께 늘어납니다. 또 같은 내용이 여러 조각에 들어 있으면 검색 결과 상위가 중복된 조각으로 채워질 수 있습니다. 10~20%에서 시작하시기를 권합니다.
청킹과 임베딩을 직접 프로그래밍해야 하나요?
아닙니다. 이미 만들어진 도구가 있습니다. 4편에서 다룰 LangChain에 재귀적 청킹이 기본으로 들어 있고, 설정 두 줄이면 동작합니다. 임베딩도 모델을 불러와 함수를 한 번 호출하면 됩니다.
한국어 문서인데 영어 모델을 쓰면 안 되나요?
동작은 하지만 성능이 떨어집니다. 영어 위주로 학습된 모델은 한국어 토큰을 지나치게 잘게 쪼개고, 한국어 표현들 사이의 미묘한 차이도 잘 잡지 못합니다. 한국어 자료를 다룬다면 한국어를 충분히 학습한 모델을 고르시기 바랍니다.
이제 문서가 벡터로 바뀌어 데이터베이스에 들어갔습니다. 다음 편에서는 수백만 개의 벡터 중에서 정확한 것을 골라내는 방법을 다룹니다.
댓글 0