한글 문서를 벡터 DB에 올리다 만난 두 개의 벽: 바이트와 토큰
![]()
들어가며
팀에서 쌓아온 한국어 문서 660여 개를, 누구나 로컬 세팅 없이 "맥락 검색"할 수 있게 만들고 싶었습니다. 방법은 요즘 흔한 그림입니다 — 문서를 벡터 DB(Cloudflare Vectorize)에 올려두고, Claude가 API로 검색해 참고하게 하는 거죠.
그런데 막상 업로드를 돌리자 전부 다 실패했습니다. 400 Bad Request가 임베딩에서도, 저장에서도 쏟아졌어요. 문서 내용은 멀쩡한데 말이죠.
원인을 하나씩 파고들다 보니, 둘 다 “한글이라서” 생긴 문제였습니다. 이 글은 벡터 DB가 처음인 분을 위해, 제가 만난 두 개의 벽과 그걸 넘은 과정을 정리한 기록입니다.
잠깐, 벡터 DB가 뭔가요?
이미 아시는 분은 건너뛰셔도 됩니다. 아주 짧게만 짚고 갈게요.
컴퓨터는 "이 문장이 저 문장과 의미가 비슷한가?"를 글자 그대로는 판단 못 합니다. 그래서 임베딩(embedding) 이라는 걸 씁니다. 문장을 AI 모델에 넣으면, 그 의미를 나타내는 숫자 목록(예: 1024개의 숫자)으로 바꿔줍니다. 이 숫자 목록이 벡터예요.
의미가 비슷한 문장끼리는 이 숫자 공간에서 가까운 위치에 놓입니다. 그래서 "가나다라 우주 달기지"으로 검색하면, 같은 단어가 없어도 의미가 가까운 문서를 찾아줍니다. 이 벡터들을 저장하고 "가까운 것 찾기"를 해주는 저장소가 벡터 DB(Vectorize)입니다.
핵심만: 문서 → (임베딩 모델) → 벡터 → 벡터 DB에 저장. 제 문제는 이 저장 단계에서 터졌습니다.
사전 결정: 번역하지 말고, 한글을 그대로 임베딩하자
벽 이야기 전에 하나 짚을 게 있습니다. 원래 이 문서들은 검색을 위해 한→영 번역을 거쳐 색인하고 있었어요. 영어로 바꿔야 임베딩 품질이 좋다는 통념 때문이었죠.
이번엔 그 번역 레이어를 통째로 없앴습니다. @cf/baai/bge-m3라는 다국어 임베딩 모델을 쓰면 한국어 원문을 그대로 넣어도 되거든요. 파이프라인이 훨씬 단순해집니다.
여기서 초보자가 꼭 기억할 개념 하나 — 벡터 DB의 차원(dimension)은 생성 후 변경 불가입니다. bge-m3는 1024차원이라 인덱스도 1024로 만들어야 하고, 검색할 때도 반드시 같은 모델로 임베딩해야 벡터가 정렬됩니다. 모델을 바꾸면 처음부터 다시 만들어야 해요.
자, 이제 벽입니다.
🧱 첫 번째 벽: “id가 너무 길다” — 바이트의 함정
업로드가 실패하는데, 정작 에러 메시지가 안 보였습니다. 코드가 이렇게 되어 있었거든요.
response.raise_for_status() # 400이면 여기서 그냥 예외 던지고 끝
raise_for_status()는 상태 코드만 보고 예외를 던지면서 서버가 보내준 진짜 에러 본문을 삼켜버립니다. 그래서 "400인 건 알겠는데 왜?"를 알 수가 없었어요. 첫 교훈은 여기서 나옵니다 — 에러 본문을 직접 찍어보기 전엔 추측하지 말자. 응답 .text를 그대로 출력하도록 바꾸자 범인이 바로 나왔습니다.
id too long; max is 64 bytes, got 85 bytes
벡터를 저장할 때는 각 벡터에 고유한 id를 붙여야 합니다. 저는 별생각 없이 파일명을 id로 썼어요. 예를 들면 manager-가나다라-우주-달기지-20260615 같은 한글 파일명이요.
문제는 여기서 시작됩니다. Vectorize의 id 한도는 64바이트인데, "글자 수"가 아니라 "바이트 수"입니다. 그리고 한글은 UTF-8 인코딩에서 글자 하나가 3바이트를 차지합니다.
영어: "manager" → 7글자 = 7바이트
한글: "가나다라" → 4글자 = 12바이트 (글자당 3바이트!)
즉 64바이트면 한글은 겨우 21자 정도밖에 못 담습니다. 파일명이 조금만 길어도 바로 초과예요. 영어였다면 64자까지 됐을 텐데, 같은 길이의 한글은 3분의 1에서 막히는 겁니다.
해결: 읽는 용도와 저장 용도를 분리
id는 사람이 읽으라고 있는 게 아니라 고유하기만 하면 됩니다. 그래서 경로를 SHA-256으로 해시해서 id로 쓰고, 사람이 읽어야 할 원래 경로는 메타데이터에 따로 보관했습니다.
import hashlib
# ❌ Before: 한글 파일명을 그대로 id로 → 64바이트 초과
vector_id = doc_path.stem
# ✅ After: 해시를 id로 (항상 64바이트 이내), 경로는 메타데이터에 유지
vector_id = hashlib.sha256(doc_path.as_posix().encode()).hexdigest() # 64 hex chars
metadata = {"path": doc_path.as_posix(), "title": title, ...}
해시 id는 길이가 항상 일정하고 ASCII라 바이트 걱정이 없습니다. 검색 결과에서 "어떤 문서였지?"는 metadata.path를 보면 되고요. 읽기 위한 값과 시스템용 키를 분리한다 — 되짚어보면 당연한 원칙인데, 한글 바이트에 부딪히고서야 체감했습니다.
🧱 두 번째 벽: 토큰 한도 — 한글은 더 무겁다
id를 고치자 절반 정도는 올라갔는데, 임베딩 단계에서 또 400이 났습니다. 이번 메시지는 이거였어요.
AiError: input too large ... max 60000 tokens per request
임베딩 모델(bge-m3)은 한 번의 요청에 최대 60,000 토큰까지만 받습니다. 그리고 저는 문서를 50개씩 묶어서 한 번에 임베딩하고 있었어요. 요청 수를 줄이려는 최적화였죠.
여기서 두 번째 한글 특성이 등장합니다. 토큰은 대략 "AI가 텍스트를 잘라 세는 단위"인데, 언어마다 글자당 토큰 밀도가 다릅니다. 영어는 보통 4글자가 1토큰쯤 되지만, 한글은 훨씬 촘촘해서 글자 하나가 거의 1토큰에 가까울 때도 있습니다.
제가 처음 세운 가정은 "글자당 0.34토큰"이었는데, 실제 한글 문서는 그 3배까지 튀었습니다. 그래서:
4,000자 × 50개 = 200,000자
가정(0.34): 68,000 토큰 → 이미 초과
실제(밀도 높은 한글): 100,000 토큰 이상 → 한참 초과
시행착오: 세 번 고쳐서야 잡았다
솔직히 이건 한 번에 못 잡았습니다. 부끄럽지만 과정을 그대로 적습니다.
- 본문을 4,000자로 자르기 — 너무 긴 문서가 배치를 터뜨리는 건 막았지만, 여전히 50개 묶음이 한도를 넘었습니다.
- “글자 수 예산” 기반 배치 — “한 요청에 글자 총합 N자까지만” 규칙으로 바꿨습니다. 그런데 문서마다 토큰 밀도가 제각각이라, 어떤 배치는 통과하고 어떤 배치는 여전히 터졌어요. 밀도가 일정하다는 가정 자체가 틀렸던 겁니다.
- 오버플로 시 재귀 분할 — 결국 확실한 방법으로 갔습니다. “일단 배치를 보내보고, 토큰 초과로 실패하면 그 배치를 반으로 쪼개서 재시도한다.” 문서 하나는 4,000자로 잘려 있으니 아무리 밀도가 높아도 60,000 토큰 미만이 보장됩니다. 즉 쪼개다 보면 반드시 성공하는 지점이 옵니다.
def embed_with_split(self, docs):
try:
return self._embed(docs) # 통째로 시도
except TokenLimitError:
if len(docs) == 1:
raise # 단일 문서인데도 실패면 진짜 문제
mid = len(docs) // 2 # 아니면 반으로 쪼개서
return self.embed_with_split(docs[:mid]) + self.embed_with_split(docs[mid:])
이 방식의 좋은 점은, 밀도를 미리 정확히 예측할 필요가 없다는 겁니다. 예측이 빗나가도 런타임에 알아서 쪼개지니까요. 물론 char 예산은 보수적으로 낮게 잡아 재시도 횟수는 줄였습니다.
이렇게 하니 664개 전량 업로드 성공, 에러 0. 한국어 쿼리로 검색도 정상 동작했습니다.
마치며: 한글은 "글자"가 아니라 "바이트와 토큰"으로 다뤄진다
두 벽을 넘고 나니 공통점이 보였습니다. 둘 다 제가 한글을 "글자 수"로만 생각한 데서 나온 문제였어요.
- id 한도는 글자가 아니라 바이트 — 한글은 UTF-8에서 글자당 3바이트
- 임베딩 한도는 글자가 아니라 토큰 — 한글은 토큰 밀도가 높고, 그마저 문서마다 다름
영어 문서였다면 둘 다 조용히 지나갔을 겁니다. 영어권 도구의 기본값(64바이트, 60k 토큰)은 은연중에 영어를 기준으로 잡혀 있으니까요. 한글을 다룰 때는 이 "숨은 기준"을 의심해봐야 한다는 걸 배웠습니다.
정리하면 이렇게 남깁니다.
- 키(id)에는 사람이 읽는 문자열을 쓰지 말고 해시를 쓰자. 읽을 값은 메타데이터로 분리.
- 배치 한도는 개수나 글자 수가 아니라, 실제 제약(토큰)을 기준으로. 예측이 어려우면 실패 시 쪼개는 안전장치를 두자.
raise_for_status()로 에러를 삼키지 말자. 진짜 원인은 응답 본문에 있다.
혹시 한국어 문서로 RAG나 벡터 검색을 준비 중이시라면, 바이트와 토큰 이 두 단어를 미리 떠올려보시길요.