홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

1만 자를 임베딩하는 줄 알았는데, 2천 토큰에서 잘리고 있었습니다

정기창·2026년 8월 7일

지난 편 블로그 RAG 검색 정확도를 측정했더니, 고칠 건 정확도가 아니었습니다에서 파이프라인 결함 네 개를 예고하며, 그중 가장 컸던 하나를 다음으로 미뤄뒀습니다. 1만 자를 임베딩하는 줄 알았는데, 실제로는 2천 토큰에서 잘리고 있었다는 이야기입니다. 처음 이 의심이 들었을 때는 저도 잘 믿기지 않아서, 결국 벡터를 직접 꺼내 비교해봤습니다. 그 실험이 꽤 결정적이었습니다.

코드는 분명히 1만 자에서 잘랐습니다

임베딩을 만들기 전에 텍스트를 다듬는 함수는 이렇게 생겼습니다.

export function prepareTextForEmbedding(title: string, content: string): string {
  const cleanContent = stripHtml(content);
  return `${title}\n\n${cleanContent}`.slice(0, 10000); // Gemini 토큰 제한 고려
}

제목과 본문을 붙인 뒤 slice(0, 10000)으로 1만 자에서 자릅니다. 주석에도 "토큰 제한 고려"라고 적어뒀으니, 저는 이 1만 자가 곧 임베딩에 들어가는 전부라고 믿고 있었습니다. 대부분의 글은 1만 자 안에 들어오니 이 컷은 거의 발동하지도 않았고요.

문제는 이 1만 자가 API에 도달하기 전의 한계였지, API가 실제로 읽는 한계가 아니었다는 점입니다.

API에는 다른 한계가 있었습니다

gemini-embedding-001의 입력 한계는 글자 수가 아니라 토큰 2,048개입니다. 그리고 토큰은 글자와 1:1이 아닙니다. 영어 산문은 한 토큰에 여러 글자가 담기지만, 한글과 코드는 토큰이 훨씬 빨리 찹니다.

문제의 글(NestJS 내부 동작을 다룬, 코드가 많은 글)로 토큰 수를 직접 세어보니 1만 자가 3,582 토큰이었습니다. 그러면 2,048 토큰은 이 글에서 대략 5,700자쯤입니다. 즉 이 글은 5,700자 언저리에서 API가 나머지를 버리고 있었다는 계산이 나옵니다. 더 곤란한 건, API가 이걸 에러로 알려주지 않는다는 점입니다. 그냥 조용히 앞부분만 임베딩하고 정상 응답을 돌려줍니다.

믿기지 않아서, 벡터를 직접 비교했습니다

계산이 그렇다 쳐도 정말 그런지는 확인해야 했습니다. 방법은 단순합니다. 같은 글을 앞에서부터 길이를 늘려가며 잘라 각각 임베딩하고, 1만 자 전체를 임베딩한 벡터와 얼마나 같은지(코사인 유사도)를 재보는 것입니다. 만약 API가 정말 5,700자쯤에서 자른다면, 그 지점을 넘어선 prefix들은 전체와 완전히 똑같은 벡터를 내놓아야 합니다. 뒤를 어차피 안 보고 있으니까요.

prefix 길이 1만 자 전체 벡터와의 코사인 유사도
2,000자 0.982
3,000자 0.994
4,000자 0.997
6,000자 1.000000 (완전히 동일)
8,000자 1.000000 (완전히 동일)

결과가 이랬습니다. 6,000자에서 코사인 유사도가 1.000000이 됐고, 3072개 숫자를 하나씩 대조해도 단 하나도 다르지 않았습니다. 6,000자를 넣든 1만 자를 넣든, 서로 다른 두 번의 API 호출이 비트 단위로 똑같은 벡터를 돌려준 것입니다. API가 6,000자 이후를 아예 안 봤다는 뜻이고, 앞서 계산한 5,700자와도 앞뒤가 맞았습니다. (코드나 한글이 더 많은 글이라면 토큰이 더 빨리 차서, 잘리는 지점은 이보다 앞이었을 겁니다.)

얼마나 많은 글이 뒷부분을 잃고 있었나

그렇다면 이게 얼마나 넓은 문제인지 봐야 했습니다. 발행된 216편의 준비 텍스트 길이 분포는 이랬습니다.

구간 준비 텍스트 길이
중앙값 4,581자
상위 25% 6,205자
상위 10% 8,026자
실효 창(~5,700자) 초과 78편 (전체의 36%)

세 편 중 한 편이 후반부를 벡터 검색에서 잃고 있었습니다. 더 나쁜 건 키워드 검색도 이 뒷부분을 못 본다는 점이었습니다. 키워드 쪽은 제목·요약·태그만 훑고 본문은 아예 안 봅니다(1편에서 정리했습니다). 그러니 잘린 뒷부분에만 있는 내용은 벡터로도 키워드로도 찾을 수 없는 사각지대였습니다.

그런데 왜 정확도 측정에선 안 걸렸나

1편에서 Precision@1이 88%나 나왔다고 적었습니다. 파이프라인에 이런 구멍이 있었는데 어떻게 88%가 나왔을까요. 답은 사람들이 실제로 친 검색어에 있었습니다.

실제 검색어는 대부분 제목이나 주제어 수준의 짧은 말이었고, 그런 정보는 글 맨 앞에 있습니다. 아무도 "이 글 8천 자쯤에 나오는 그 내용"을 검색하지 않았으니, 뒷부분이 잘렸다는 사실이 결과에 드러날 일이 없었던 것입니다. 측정이 문제를 못 본 게 아니라, 문제를 드러낼 검색어가 애초에 표본에 없었던 것입니다. 결함은 조용한 종류일수록 무섭다는 생각이 들었습니다. 정확도 숫자만 봤다면 영영 몰랐을 문제였으니까요.

돌이켜 생각해보면 근본 원인은 잘림 그 자체가 아니라, 글 하나를 벡터 하나로 뭉갠 설계였습니다. 아무리 긴 글이라도 벡터는 딱 하나뿐이니, 3072개 숫자에 글 전체를 눌러 담아야 하고 — 실제로는 API가 앞부분만 담고 나머지를 버린 것입니다. 그렇다면 해법은 자명해집니다. 글을 통째로 넣지 말고 쪼개서 넣는 것입니다. 다음 편에서는 이 잘림을 청킹으로 풀고 taskType으로 한 겹 더 정밀하게 만든 과정, 그리고 그 마이그레이션이 정확히 50번째 글마다 죽어버린 이야기를 하겠습니다.

RAG임베딩gemini토큰 제한벡터 검색디버깅

관련 글

청킹과 taskType으로 임베딩을 고치고, 마이그레이션에서 또 터졌습니다

글을 통째로 벡터 하나에 담던 설계를 조각내 다시 세웠습니다. 헤딩 경계 청킹과 taskType 비대칭으로 잘림을 풀었고, 그 재임베딩 배치가 정확히 50번째 글마다 죽던 MongoDB 커서 타임아웃까지 잡은 기록입니다.

관련도 96%

블로그 RAG 검색 정확도를 측정했더니, 고칠 건 정확도가 아니었습니다

반년 전 붙여둔 하이브리드 검색이 정말 정확한지 추측 대신 검색어 16개로 직접 측정했습니다. Precision@1은 88%로 나쁘지 않았지만, 재는 과정에서 정작 고쳐야 할 것은 정확도가 아니라 파이프라인의 숨은 결함 네 개라는 걸 알게 됐습니다.

관련도 95%

개인 블로그에 AI 검색 달기 (2) - MongoDB Atlas Vector Search 구현

MongoDB Atlas Vector Search 인덱스 설정부터 NestJS에서 하이브리드 검색을 구현하는 과정. $vectorSearch의 null 필터 제한사항과 RRF 알고리즘, 유사도 임계값 튜닝까지.

관련도 93%