템플릿 제안을 embedding으로 — 한국어 동의어와 마주한 하루
정확히 같은 제목 두 번 쳐야 뜨던 "Template으로 만들까요?" 제안을, 의미가 비슷하면 잡도록 바꾸는 과정. M0 free tier 한도, 차원 통일, 호출 1회 재사용, 그리고 한국어에서 드러난 모델의 한계까지.
템플릿 제안을 embedding으로 — 한국어 동의어와 마주한 하루
Fecit은 사용자가 같은 제목의 할 일을 두 번 만들면 “이걸 Template으로 저장할까요?” 라고 물어봅니다. 한 번만 만들면 일회성, 두 번이면 반복 의도가 있다고 보는 거죠.
그런데 “같은 제목” 의 정의가 너무 빡셌습니다. 로컬에 저장된 카운터가 Levenshtein 거리 0.8 이상이어야 같다고 인식했는데, 이건 타이핑 오타나 문장부호 차이만 잡는 수준입니다. *“달리기 30분”*과 *“조깅 30분”*은 완전 다른 두 줄로 카운트됐습니다. 사용자 입장에선 둘 다 같은 일인데요.
의미적으로 비슷한 것까지 묶어 주려면 embedding이 필요했습니다. Fecit은 이미 OpenAI 임베딩을 다른 곳(마켓 추천, 오버뷰 검색)에 쓰고 있어서, 새 인프라가 필요한 건 아니었습니다. 그저 task 쪽에도 끌어다 쓰면 되는 일이었는데, 막상 시작하니 결정해야 할 게 줄줄이 나왔습니다.
Atlas의 인덱스 한도
먼저 부딪힌 건 MongoDB Atlas의 제약이었습니다. Fecit은 M0 free tier에서 굴리고 있는데, 이 티어는 vector search 인덱스를 최대 3개까지만 만들 수 있습니다. 이미 두 개(overview, product 추천용)가 자리 잡고 있어서, task 용도로 한 칸이 남아 있는 셈이었습니다.
처음엔 그 한 칸을 task record에 쓰고, “비슷한 template이 있는지” 검사는 in-memory cosine으로 처리할 생각이었습니다. 일반 사용자라면 보유 template 수가 백 개 안쪽이라 메모리 검색도 충분히 빠를 거라 봤거든요.
그런데 “시스템 사용자” 한 명이 변수였습니다. Fecit이 기본으로 제공하는 공유 template 8만 개를 들고 있는 계정이 있었습니다. in-memory로 가면 매 검색마다 8만 × 2KB = 160MB가 서버 메모리에 올라와야 합니다. 네트워크가 박살납니다.
결국 Flex 티어로 업그레이드했습니다. 한 달 $8부터 시작해서 사용량 따라 최대 $30 cap. vector search 인덱스 한도가 10개로 늘어나서, 마음 편하게 task record와 template 양쪽에 인덱스를 뒀습니다.
차원 통일
다음 결정은 임베딩 차원이었습니다. text-embedding-3-small은 기본 1536차원인데, 차원을 줄여도(Matryoshka representation) 정확도 손실이 거의 없습니다. 짧은 task 제목 비교라면 512면 충분합니다.
| 항목 | 1536 | 512 |
|---|---|---|
| 벡터 1개 크기 | 6KB | 2KB |
| MTEB 점수 | 62.3% | 61.6% |
| API 비용 | 동일 | 동일 |
OpenAI 비용은 입력 토큰 기준이라 차원과 무관합니다. 차이는 storage와 검색 속도에서만 납니다. 1/3 절감이 누적되면 의미 있는 차이가 되니, 처음부터 512로 가는 게 맞아 보였습니다.
문제는 기존 데이터가 모두 1536차원이었다는 점입니다. 차원이 다른 벡터끼리는 cosine 비교 자체가 안 되니, 시스템 전체를 통일해야 했습니다. product, overview, achiever, 그리고 새로 추가될 task — 전부 512로 마이그레이션. 마이그레이션 스크립트를 짜서 product/overview를 모두 재임베딩하고(약 7,200건, OpenAI 호출 비용 10센트 미만), achiever와 task의 임베딩은 비워서 자연스럽게 새로 누적되도록 했습니다.
한 번의 호출, 두 번의 사용
Record 생성 라우트를 보니, 이미 OpenAI를 호출하고 있었습니다. 새로 만들어진 record의 제목과 설명을 합쳐 임베딩한 뒤, 사용자별 누적 “취향 벡터” (achiever.embedding)에 가중평균으로 섞는 코드였습니다. 마켓 추천 등에 쓰이는 신호죠.
여기에 “비슷한 template 검사용 임베딩” 호출을 또 추가하면 record 하나 만들 때마다 OpenAI를 두 번 부르게 됩니다. 비용이야 trivial이지만, 같은 작업을 두 번 한다는 게 거슬렸습니다.
해결은 단순했습니다. 새로 만든 임베딩 헬퍼(get_or_compute_title_embedding)가 제목만 입력으로 받게 하고, achiever 누적도 거기서 나온 임베딩을 그대로 받아 가도록 인터페이스를 바꿨습니다. description은 신호에서 빠지지만, 대부분의 사용자가 description을 비워 두니 손실이 크지 않습니다. record 생성당 OpenAI 호출이 절반이 됐습니다.
”캐시”가 아니라 “기록”
같은 제목이 반복해서 들어올 가능성을 고려해서 임베딩 결과를 저장하기로 했는데, 처음엔 이걸 cache라고 부르고 30일 TTL을 걸 생각이었습니다. 그런데 가만히 보니 이건 캐시의 본질과 다릅니다.
캐시는 “원본이 변할 수 있어서, 일정 시간 지나면 stale이 될 수 있다” 는 가정이 깔린 구조입니다. HTTP 응답 캐시처럼요. 그런데 임베딩은 순수 함수의 출력입니다. 같은 입력 + 같은 모델 → 항상 같은 출력. 시간이 지난다고 stale 되지 않습니다.
그래서 만료 정책 자체를 빼버렸습니다. embedding_history_collection이라는 이름으로 영구 보관. 흔한 제목(“운동 30분”, “회의”)일수록 누적되어 hit rate가 올라가고, OpenAI 호출이 점차 줄어듭니다. 시간이 지날수록 강해지는 자산.
응답에 묶기
마지막 결정은 호출 구조였습니다. 두 가지 정보가 필요했습니다 — “이 사용자가 비슷한 record를 몇 개 만들었나”, 그리고 “이미 비슷한 template이 있나”. 별도 엔드포인트 두 개로 분리할 수도 있었습니다.
그런데 둘 다 방금 만든 record의 제목 임베딩을 query vector로 쓰는 작업입니다. record 생성과 분리하면 클라이언트가 같은 정보를 위해 라운드트립을 두세 번 내야 하고, 서버는 같은 임베딩을 또 만들어야 합니다.
그래서 record 생성 응답에 두 신호를 같이 실어 보내기로 했습니다.
const taskRecord = await createTaskRecord({...});
if (taskRecord.similarCount >= 1 && !taskRecord.hasSimilarTemplate) {
setSaveSuggestion({...}); // "Template으로 만들까요?" 배너
}
서버는 record 저장 → 제목 임베딩 → record 인덱스와 template 인덱스에 동시에 vectorSearch (asyncio.gather로 병렬) → 응답에 similar_count, has_similar_template 포함. 한 번의 라운드트립으로 끝납니다. 모바일은 기존 별도 호출들(TitleCounterStorage, hasSimilarTemplate)을 모두 들어내고, 응답의 두 필드만 보면 됩니다.
한국어가 어렵다
여기까지 만들고 직접 써 보니 “비슷하다” 의 기준이 생각보다 까다로웠습니다. cosine threshold를 0.85로 두고 시작했는데, 막상 “느린가?” 라고 친 record가 “살짝 느린듯?” 과 매칭되어 제안이 떴습니다. (참고로 이건 임베딩이 아니라 별개의 정규식 검색에서 ?가 메타로 해석된 버그였습니다 — 이건 따로 잡았습니다.)
문제는 진짜 동의어들이었습니다. 직접 cosine을 측정해 보니:
달리기 ↔ 조깅: 0.399
달리기 ↔ 뛰기: 0.382
달리기 ↔ 런닝: 0.404
달리기 30분 ↔ 회의 30분: 0.617 (의미 다른데도 "30분" 공통이라 inflate)
운동 30분 ↔ 회의 30분: 0.699
text-embedding-3-small이 한국어 동의어 판별에 약합니다. 게다가 “30분” 같은 짧은 공통 구문이 score를 부풀려서, 의미 매칭과 false positive 사이에 깔끔한 임계치가 안 잡혔습니다.
text-embedding-3-large로 바꿔 다시 측정해 보니 의미 있는 개선이 있었지만 — 달리기↔뛰기가 0.382에서 0.667로 — 동의어 일부는 여전히 약했고, “런닝” 같은 영어 외래어는 그대로였습니다. 비용은 6.5배지만 절대값이 워낙 작아 비용은 부담이 아니었는데, 개선폭이 dramatic 하지 않아서 일단 small + 0.85 threshold를 유지하기로 했습니다. 운영하면서 데이터가 쌓이면 다시 검토하는 걸로.
회고
완벽한 솔루션이 없는 영역이라는 걸 작업하면서 받아들였습니다. 한국어처럼 형태 변화가 많고 외래어가 섞인 언어에서 짧은 task 제목의 의미를 파악하는 건, 범용 임베딩 모델이 잘 못하는 일이었습니다. “전혀 못한다” 는 아니고 “부분적으로 한다” — 이 애매함이 운영 결정을 어렵게 만듭니다. 임계치를 낮추면 false positive가 늘고, 높이면 진짜 동의어를 놓칩니다.
당분간은 false negative가 많은 보수적 설정으로 둡니다. 사용자가 “왜 안 떠?” 라고 느끼는 건 “왜 이상한 게 떠?” 라고 느끼는 것보다 덜 거슬리니까요. 멀티링구얼 전용 모델(BGE-M3 같은)이나 약간의 휴리스틱(공통 단어 가중치 조정)을 얹는 건 데이터를 좀 더 본 뒤에 결정하려고 합니다.
기능 자체는 단순한데, 그 뒤에 깔린 결정들 — 차원 통일, 호출 재사용, 영구 저장, 응답 번들 — 이 일주일 가까이 머리를 굴리게 했습니다. 작은 기능 하나 만드는 데 맥락을 놓치면 일이 두 배가 되는 영역이라, 그날 그날 정리하면서 가야 했습니다.