홈시리즈멘토링

© 2026 정기창. All rights reserved.

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

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

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

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

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

정기창·2026년 8월 8일

앞선 두 편 진단과 잘림 발견에서 파이프라인 결함 네 개를 확인했습니다. 이제 고칠 차례입니다. 넷 중 셋은 몇 줄짜리 수정으로 끝났고, 하나 — 임베딩 잘림 — 만 구조를 바꿔야 했습니다. 그리고 그 구조를 바꾸는 마이그레이션에서, 예상 못 한 곳이 한 번 더 터졌습니다.

작은 셋은 먼저 조용히 고쳤습니다

큰 수술에 들어가기 전에, 코드 몇 줄이면 끝나는 결함 셋을 먼저 정리했습니다.

  • RRF 점수 표준화. 순위에 없는 문서에까지 점수를 얹던 비표준 계산을 걷어냈습니다. 등장한 리스트에서만 순위 점수를 더하도록 바꾸니, 그동안 두 값에 고정돼 있던 최고 점수가 비로소 검색 품질을 반영하는 신호가 됐습니다.
  • 벡터 검색에 필터 추가. 발행 여부만 보던 벡터 검색에 언어·숨김·임시저장 필터를 더해, 숨긴 글이 검색에 새어 나오던 구멍을 막았습니다.
  • 수정 시 자동 재임베딩. 최초 발행 때 한 번만 만들어지던 임베딩을, 발행된 글의 본문을 고칠 때도 다시 만들도록 연결했습니다. 첫 발행 때 저장보다 임베딩을 먼저 해서 옛 내용이 임베딩되던 순서 버그도 같이 바로잡았습니다.

셋 다 파급이 작아 먼저 처리할 수 있었습니다. 남은 하나가 진짜였습니다.

큰 하나 — 잘림은 '쪼개기'로 풀었습니다

2편에서 정리했듯, 잘림의 근본 원인은 글 하나를 벡터 하나에 통째로 담은 설계였습니다. 그래서 글을 조각(chunk)으로 나눠, 조각마다 따로 임베딩해 따로 저장하기로 했습니다.

조각을 나누는 규칙은 이렇게 잡았습니다.

- 목표 길이 : 조각 하나에 1,500~1,800자
- 경계     : 헤딩(h2/h3)·문단 경계에서 자름 (문장 중간에서 끊지 않게)
- 겹침     : 조각 사이를 200자쯤 겹침 (경계에 걸친 내용 손실 방지)
- 제목     : 모든 조각 앞에 글 제목을 붙임 (맥락 유지)
- 짧은 글  : 1,800자 이하면 그냥 한 조각

검색할 때는 조각 단위로 벡터 검색을 한 뒤, 같은 글에서 나온 조각들 중 가장 잘 맞은 점수로 그 글을 대표하게 했습니다. 이제 8천 자 지점에 있던 내용도, 자기 조각 안에서는 맨 앞이라 잘리지 않습니다. 216편이 774개의 조각이 됐고, 글 하나의 벡터 하나가 사각지대를 만들던 구조가 사라졌습니다.

배포에는 순서 함정이 하나 있었습니다. 코드를 먼저 배포하면 그 순간엔 아직 조각이 하나도 없습니다. 그래서 조각 인덱스가 비어 있으면 기존 문서 벡터 방식으로 조용히 폴백하게 해뒀습니다. 코드 배포와 데이터 마이그레이션을 분리해도 그사이 검색이 깨지지 않도록 한 것입니다.

문서와 질문을 다르게 임베딩하기

쪼개는 김에, 덜 알려진 디테일도 하나 반영했습니다. Gemini 임베딩은 taskType을 받습니다. 문서를 저장할 때와 검색어를 임베딩할 때를 다른 값으로 지정할 수 있습니다.

// 문서(조각)를 저장할 때
await model.embedContent({
  content: { role: 'user', parts: [{ text }] },
  taskType: TaskType.RETRIEVAL_DOCUMENT,
});

// 검색어를 임베딩할 때
await model.embedContent({
  content: { role: 'user', parts: [{ text: query }] },
  taskType: TaskType.RETRIEVAL_QUERY,
});

같은 문장이라도 "저장되는 문서"와 "그 문서를 찾는 질문"은 역할이 다릅니다. 이 둘을 비대칭으로 임베딩하면 검색 매칭이 더 정확해진다는 게 Gemini 쪽 권장인데, 그동안은 둘 다 기본값으로 뭉뚱그려 쓰고 있었습니다. 어차피 전체 재임베딩을 한 번 돌릴 참이라, 이것도 같은 김에 반영했습니다.

그런데 마이그레이션이 정확히 50번째 글마다 죽었습니다

배포를 마치고 216편 전체 재임베딩을 돌렸습니다. 그런데 진행률이 50 근처까지 올라가다 다시 처음으로 리셋되고, 또 리셋됐습니다. 로그를 보니 이랬습니다.

진행률: 50/216
MongoServerError: cursor id 2254021263345793428 not found
    at FindCursor.fetchBatch ...
진행률: 1/216   ← 큐가 재시도하며 처음부터 다시

원인은 글 목록을 훑는 방식이었습니다. 저는 글을 MongoDB 커서로 한 배치(50개)씩 가져오며 처리하고 있었습니다. 평소엔 문제가 없었는데, 강제 재임베딩은 글 하나에 약 25초가 걸립니다(문서 + 조각들 임베딩 + 무료 티어 페이싱). 그러면 50개를 소비하는 데 20분이 넘습니다. 그런데 MongoDB 커서는 10분 동안 쓰이지 않으면 만료됩니다. 첫 배치 50개를 다 쓰고 다음 배치를 가지러 가는 순간, 커서는 이미 죽어 있었던 것입니다.

이 버그가 그동안 안 터진 이유가 흥미롭습니다. 평소 재임베딩은 이미 임베딩된 글을 건너뛰어서 아주 빨랐습니다. 커서가 순식간에 소비되니 10분을 넘길 일이 없었던 것입니다. '전부 강제로 다시'라는, 평소와 다른 부하가 처음으로 이 잠복 버그를 깨웠습니다.

고치는 건 오히려 단순했습니다. 커서를 버리고, slug 목록을 먼저 배열로 통째로 읽었습니다.

// ❌ 커서로 훑기 — 한 배치(50개) 소비에 20분,
//    그사이 커서가 10분 만료에 걸린다
const cursor = model.find(filter).select('slug').lean().cursor({ batchSize: 50 });
for await (const post of cursor) {
  await reembed(post.slug);
}

// ✅ slug 목록을 먼저 배열로 (216개 = 수 KB, 메모리 무해)
const posts = await model.find(filter).select('slug').lean().exec();
for (const post of posts) {
  await reembed(post.slug);
}

slug만 뽑으면 216개라야 수 KB밖에 안 됩니다. 애초에 커서로 스트리밍할 이유가 없는 크기였습니다. 임베딩이라는 느린 작업을 커서로 감싸는 순간, 커서 수명이 작업 속도의 인질이 된다는 걸 놓치고 있었던 것입니다.

덤으로 대가도 치렀습니다. 세 번의 재시도가 그날 무료 API의 하루 요청 쿼터를 꽤 태웠고, 그래서 재실행은 쿼터가 리셋되는 시각(태평양 자정, 한국 시간 오후 4시)까지 기다렸다가 돌렸습니다. 참고로 이 전 과정은 처음부터 끝까지 무료 Gemini API 안에서 이뤄졌습니다. 무료 키는 한도를 넘으면 요금이 나가는 게 아니라 그냥 429로 거절되니, 분당 호출 수만 지키면 됩니다.

고쳐졌는지는 어떻게 확인했나

재실행한 배치는 216편 전부, 조각 774개를 실패 없이 완주했습니다. 그리고 가장 확인하고 싶었던 것 — 예전에 잘려서 안 보이던 그 뒷부분 내용이 이제 검색되는가 — 를 직접 쳐봤습니다.

2편에 나온 그 NestJS 글의 8천 자 지점(원래 실효 창 바깥)에 있던 내용으로 검색하니, 그 글이 5번째 조각으로 매칭돼 검색 1위로 떴습니다. 통째 벡터였다면 영영 안 보였을 내용입니다. 사각지대가 닫힌 것입니다. RRF 표준화도 함께 확인됐는데, 최고 점수가 예전의 고정값이 아니라 표준 RRF 만점으로 제대로 나왔습니다.

재본다는 것

돌이켜보면 이번 일의 절반은 '고치기'였고 절반은 '재는 법'이었습니다. 잘림은 코드만 봐선 안 보였고 벡터를 직접 꺼내 비교해야 보였습니다. 커서 타임아웃은 평소엔 잠복해 있다가 부하가 바뀌자 나타났습니다. 결국 시스템은 정상일 때가 아니라 평소와 다르게 굴릴 때 진짜 모습을 보여준다는 생각이 들었습니다.

솔직히, 검색하는 사람이 거의 없는 블로그의 검색을 이렇게까지 파고든 게 실용적으로 남는 장사인지는 잘 모르겠습니다. 다만 "잘 되는 것 같다"와 "재보니 이렇더라" 사이의 거리는, 한 번 건너보면 다음부터는 재는 쪽을 먼저 택하게 됩니다. 이 시리즈가 남긴 건 고쳐진 검색보다도 그 습관 쪽인지도 모르겠습니다.

RAG청킹임베딩MongoDBtaskType마이그레이션

관련 글

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

코드는 1만 자에서 잘랐지만, gemini-embedding-001은 그보다 앞선 2,048 토큰에서 조용히 나머지를 버리고 있었습니다. 같은 글의 prefix를 길이별로 임베딩해 비교하니 6,000자부터 벡터가 비트 단위로 동일했고, 글 셋 중 하나가 뒷부분을 검색에서 잃고 있었습니다.

관련도 96%

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

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

관련도 95%

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

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

관련도 94%