임베딩 하나로 분류와 검색까지

#embeddings#zero-shot#pgvector#postgis

임베딩은 보통 문서 검색의 전반부로 소개된다. 청킹하고, 임베딩하고, 저장하고, 검색하고. 그 프레이밍은 임베딩을 과소평가한다. 임베딩은 재사용 가능한 의미 표현이고, 검색은 그중 하나의 용도일 뿐이다.

이 글은 같은 벡터를 두 번 쓰는 이야기다. 오프라인에서는 라벨링된 학습 데이터도 파인튜닝도 없이 일관성 없는 공공데이터 라벨을 다섯 개 제품 카테고리로 바꾸는 제로샷 분류에, 온라인에서는 PostGIS 기하와 한 SQL 문 안에서 결합되는 공간·의미 하이브리드 검색에 쓴다.


다섯 개 카테고리, 제각각인 소스

제품이 게스트에게 노출하는 카테고리는 정확히 다섯 개다. 코딩·디지털, 외국어, 운동, 인문, 문화·예술.

소스들은 서로와도, 자기 자신과도, 이 분류 체계와도 일치하지 않는다. 전국평생학습강좌표준데이터는 품질이 들쭉날쭉한 자체 카테고리 필드를 갖고 있고, KSPO는 종목 기준이고, 서울 열린데이터 강좌는 쓸 만한 카테고리가 아예 없다. 상당수 레코드는 제목만 있다.

진짜 어려운 건 애매한 중간지대가 아니라, 표면 토큰이 적극적으로 오도하는 경우들이다.

  • AI 캘리그라피 — “AI”가 들어있지만 예술
  • 스포츠 드론 코딩 — “코딩”이 들어있지만 주로 운동
  • 원어민과 함께하는 태권도 — 어학 신호가 있지만 무도

키워드 분류기는 셋 다 틀리고, 자신 있게 틀린다. 중요한 건 의미이고, 의미야말로 임베딩이 포착하는 것이다.

왜 파인튜닝도, LLM 분류도 아닌가

선택지는 셋이었다.

  1. LLM에게 레코드마다 묻는다. 2만 7천 건 × 매일 밤. 비용도 문제지만 더 큰 문제는 비결정론이다. 같은 강좌가 어제는 운동, 오늘은 예술이 되면 사용자가 본 결과가 새로고침마다 달라진다.
  2. 소형 분류기를 파인튜닝한다. 라벨 데이터가 필요하다. 한국어 평생학습 강좌 라벨셋은 존재하지 않고, PoC 예산으로 수천 건을 손으로 라벨링하는 건 가설 검증과 무관한 비용이다.
  3. 프로토타입 기반 제로샷 분류. 카테고리마다 서술문 하나를 쓰고, 그 서술문과 강좌 텍스트를 같은 임베딩 공간에 놓고, 가장 가까운 것에 배정한다.

셋째는 학습 데이터가 0건이고, 결정론적이고(같은 벡터 → 같은 argmax), 새 카테고리 추가가 문단 하나 쓰는 일이고, 강좌당 비용이 임베딩 호출 한 번 — 캐시되면 0 — 이다. PoC에는 이게 맞는 도구다.

분류기는 코드가 아니라 다섯 개의 문단이다

프로토타입은 키워드 목록이 아니라, 헷갈리는 사례를 명시적으로 호명하며 경계를 긋는 서술문이다. 운동 프로토타입은 “…요가·필라테스·헬스·수영·줄넘기·스포츠 드론…” 을 포함시키고, 코딩 프로토타입은 같은 경계를 반대편에서 닫는다 — “스포츠 경기나 예술 창작이 주목적이고 디지털 도구는 보조 수단인 활동은 포함하지 않습니다.”

프로토타입을 쓰는 건 진짜 도메인 작업이고, 그중 대부분을 배제 조항이 해낸다. 프롬프트 작성과 같은 사고를 임베딩 공간에 적용하는 셈이다. 무엇이 들어오는지 말하고, 똑같이 중요하게, 들어올 것처럼 보이지만 아닌 것을 말한다.

프로토타입이 곧 분류기이므로, 단어 하나를 고치면 이후 모든 결정이 바뀐다. 그래서 프로토타입 레지스트리는 콘텐츠 해시와 리뷰어 승인으로 잠겨 있다. 한 글자만 달라져도 해시 검증에 실패해서, 누군가 다시 검토하고 승인할 때까지 사용이 막힌다. 프롬프트 성격의 산출물을 코드 리뷰가 코드를 다루듯 다루는 것이다.

아무도 안 쓰는 전제조건, 정규화

이 모든 것 이전에 원본 레코드가 임베딩할 가치가 있는 무언가가 되어야 한다. 정규화 계층은 지루하고, 결정론적이고, 완전히 하중을 받는다. 그리고 진짜 버그가 사는 곳이다. 라이브 피드에서 실제로 발견한 사례:

/**
 * 라이브에서 관측된 `operDay` 형식: "월", "월요일", "화+목", "매주 화,목".
 * "요일" 접미사를 스캔 전에 제거해야 합니다. "월요일"을 그냥 문자 단위로
 * 훑으면 "요일"의 '일'까지 걸려서 유령 일요일 슬롯이 추가됩니다.
 */
export function decodeKoreanDays(text: string): number[] {
  const cleaned = text.replace(/요일/g, "");
  // …월화수목금토일 스캔
}

월요일만 하는 강좌가 조용히 월·일 강좌가 된다. 하위 모든 단계 — 일정 매칭, 랭킹, “오늘 저녁에 다닐 수 있다”는 주장 — 가 그 오류를 물려받고, 아무리 정교한 임베딩도 이걸 잡아내지 못한다.

벡터의 정체성: 계약, 버전, 그리고 해시 펜스

시스템의 모든 임베딩은 정확히 하나의 함수(buildSemanticInput)로 만들어진 정규 입력에서 나온다. 이 함수가 하지 않는 일이 흥미롭다. 소문자화하지 않고, 유니코드 정규화하지 않고, 자르지 않는다. 저 변환 중 무엇이든 하면 임베딩되는 내용이 바뀌고, 모든 벡터가 바뀌고, 저장된 모든 결정이 무효가 된다. 그래서 전처리는 버전 관리되고, 그 버전은 DB에 있는 모든 벡터의 정체성 일부다.

이 정체성이 하중을 받는 지점이 stale write 문제다.

  1. 강좌 #123을 읽는다. 정규 입력 해시 abc…
  2. 임베딩한다 (네트워크, 수 초일 수도)
  3. 그 사이 수집기가 재동기화되어 #123의 설명이 바뀐다. 새 해시 def…
  4. 벡터를 기록한다

4단계는 방금 #123에게 더 이상 갖고 있지 않은 텍스트로 계산된 벡터를 찍었다. 아무것도 throw하지 않고, 벡터는 형식상 멀쩡하고, 거기서 유도된 카테고리는 조용히 영구히 틀리게 된다.

해법은 입력 해시에 대한 compare-and-set이고, 리포지토리 안 SQL에서 평가된다. 펜스에 걸린 쓰기는 예외가 아니라 카운터다. 그 강좌는 다음 밤에 새 해시로 다시 임베딩되고 다시 분류된다.

프로바이더 응답도 신뢰할 수 없는 입력으로 다룬다. 스키마 → 개수 → 차원 → 유한성 → 순서 순으로 fail-closed 검증한다. 순서 검증은 다들 건너뛰는 항목인데, 조용히 어긋난 짝짓기는 모든 강좌에 옆 강좌의 벡터를 붙이는 결과를 내고, 겉보기에 그럴듯한 출력을 만들면서 밖에서는 사실상 디버깅이 불가능하다.

전송 전 프라이버시 프리플라이트: 정제가 아니라 차단

공공 강좌 설명에는 강사 전화번호, 오픈채팅 아이디, 개인 이메일이 섞여 있다. 이걸 서드파티 임베딩 API로 보내는 건 외부 전송 데이터 최소화 문제라서, 캐시 조회나 프로바이더 호출 이전에 결정론적 스크리닝이 돈다.

핵심 결정은 이것이다. 탐지된 입력은 조용히 정제되지 않고 차단된다. 재작성하면 정규 문자열이 바뀌고, 그러면 해시가 바뀌고, 그러면 펜스 계약이 깨진다. 차단은 불변식을 지키면서 그 레코드를 검토 대상으로 드러낸다. 야간 배치 로그의 privacyBlocked: 2 같은 카운터가 바로 이 경로다.

캐시는 콘텐츠 주소 기반(SHA-256)이고 폐기 가능하다. 모든 캐시 벡터는 이미 어떤 강좌 행에 있으므로, 통째로 지워도 프로바이더 호출 0건에 재구성된다. Chapter 4에서 이 성질 덕분에 캐시 DB를 홈 서버 로컬에 두고 Neon 무료 티어 용량을 아낄 수 있었다.

abstention을 걷어낸 이유

현재 운영 로직은 의도적으로 단순하다. 5지 코사인 argmax, 임계값 없음, abstention 없음.

원래 설계는 이게 아니었다. 이전 반복에서는 정밀도·재현율·top-2 마진을 평가하고 저확신 레코드의 abstention을 탐색했다. 평가는 유효했지만 승격 가능한 운영점이 나오지 않았다. 대신 분류 체계를 닫기로 한 근거는 이렇다. 게스트는 다섯 중 정확히 하나를 고르고, abstain은 그 강좌가 모든 게스트에게 보이지 않게 된다는 뜻이다. 진짜로 닫힌 분류 체계와 이 정도 규모의 코퍼스에서는, 가끔 틀렸지만 존재하는 배정이 조용히 사라진 강좌보다 제품적으로 낫다.

이건 모델링 결과가 아니라 제품 판단이다. 정확도 발견처럼 포장하기보다 그대로 밝히는 편이 낫다. 실제 분포(2026-08-15 야간 배치, 26,910건):

카테고리건수
운동22,206
문화·예술3,945
인문323
코딩·디지털266
외국어170

KSPO가 코퍼스의 대부분이라 운동이 압도적이다. 이 편향은 분류기 문제가 아니라 소스 구성 문제이고, 어떤 소스를 추가로 붙일지가 다음 단계의 제품 결정이다.


같은 벡터로 검색하기: 필터가 랭킹보다 먼저다

이제 벡터가 DB에 있다. Upstage 비대칭 임베딩의 두 역할 덕분에 같은 벡터가 분류와 검색에 모두 쓰인다. 강좌 텍스트는 passage 역할, 프로토타입과 사용자 의도는 query 역할이다. 오프라인에서 프로토타입과 비교되어 courses.category가 되고, 온라인에서 사용자 의도와 비교되어 랭킹의 30%가 된다.

표준 벡터 검색 패턴은 유사도로 top-k를 뽑고 그다음 필터링한다. 문서 QA에서는 괜찮지만 여기서는 정확성 버그다. 어떤 강좌는 의미적으로 완벽하면서 동시에 완전히 다닐 수 없다 — 지난달에 끝났거나, 화요일 오전이거나, 반대 방향으로 40km 떨어져 있다. 유사도 랭킹이 먼저 돌면 근사하게 매칭된 만료 강좌가 다닐 수 있는 평범한 강좌를 앞지른다.

그래서 모든 하드 게이트(활성·미만료·카테고리·예산·우회 타원·일정)는 SQL WHERE 절에 있고, 코사인 유사도는 이미 필터된 집합 안에서만 계산된다. 그리고 Chapter 1의 불변식이 여기서 구현된다. 필터링이 랭킹보다 먼저 일어나고, 랭킹은 필터링을 되돌릴 수 없다.

courses.category = $category 게이트는 눈여겨볼 만하다. 앞서 argmax로 쓴 그 컬럼이 여기서 하드 게이트로 쓰인다. 분류가 틀리면 그 강좌는 이 카테고리 게스트에게 존재하지 않는다 — abstain 대신 argmax를 고른 이유가 바로 이 줄이다.

두 초점 타원: 기하가 질문의 모양과 일치한다

순진한 회랑 모델 — 집→회사 선분에서 강좌까지의 수직 거리 — 은 “30km 선에서 50m 벗어남”과 “2km 선에서 50m 벗어남”을 똑같이 편리하다고 본다. 올바른 질문은 이게 내 경로에서 얼마나 떨어져 있나? 가 아니라 이것이다.

이 강좌를 들렀다 집에 가면 이동 거리가 얼마나 늘어나는가?

집 H, 회사 W, 강좌 C에 대해 추가 거리는 detour(C) = d(C,H) + d(C,W) − d(H,W)이고, d(C,H) + d(C,W) ≤ 상수를 만족하는 점들의 집합은 정의상 H와 W를 초점으로 하는 타원이다. 기하 도형이 질문의 모양과 정확히 일치한다. 우회 예산은 출퇴근 거리의 60%로 잡되 5km 바닥과 10km 상한을 둔다.

정확한 타원 조건은 GiST 인덱스를 탈 수 없으므로 SQL은 2단 게이트를 쓴다. 인덱스를 타는 외접원 프리필터(ST_DWithin + 마진), 그다음 정확한 detour ≤ budget 조건. 마진이 없으면 이 “최적화”는 경계 근처의 유효 후보를 조용히 떨어뜨리는데, 데이터 문제처럼 보이기 때문에 최악의 성능 버그 부류다.

한 가지 더. 후보 구성은 카카오 경로 폴리라인에 의존하지 않는다. 타원은 집·회사 앵커만 필요하므로, 카카오 장애가 어떤 강좌가 존재하는지를 바꾸지 못한다. 경로는 지도 언더레이일 뿐이다.

모르는 것에 점수 주기

공공 일정 데이터는 결측이 잦아서, “일정 없음”이 기본값이 아니라 일급 값이어야 한다. 일정은 3-상태다.

  • known_compatible — 시간표 점수 만점 (1.0)
  • unknown — 자격 있음, 중립 점수 (0.5), “시간 미정”으로 표시
  • known_incompatible — SQL에서 제외, 스코어러에 도달하지 않음

unknown을 부적합으로 취급하면 데이터 공백 때문에 코퍼스의 상당 부분을 버린다. 적합으로 취급하면 결측 데이터가 검증된 좋은 매칭을 앞지른다. 중립은 ‘자격은 있지만 결코 유리하지는 않음’이고, 그게 정직한 위치다.

랭킹은 순수 TypeScript의 버전 고정 가중합(semantic 0.30, route 0.25, timetable 0.20, …)이고, 유닛 테스트는 개별 값이 아니라 합이 정확히 1.0임을 고정한다. ‘모른다’에 해당하는 모든 성분은 0.5를 명시적 값으로 받는다. 성분을 생략하면 나머지 전부가 조용히 재가중되고, 0으로 매기면 데이터 공백이 벌점으로 바뀐다. 예외가 하나 있다. 좌표 없는 오프라인 강좌의 경로 성분은 의도적으로 중립 아래(0.3)다. 위치를 알 수 없는 물리 장소는 애초에 장소가 필요 없는 온라인 강좌보다 나쁜 베팅이기 때문이다. 그런 강좌는 상위 30 슬롯 중 최대 5개까지만 차지할 수 있다 — 상한이 없으면 지오코딩이 부실한 레코드 뭉치가 “이건 당신 퇴근길 위에 있다” 가 존재 이유인 강좌들을 밀어낸다.


정리

임베딩 시스템은 DB 컬럼 하나 붙인 벡터 API 호출이 아니라, 버전 관리된 ML 데이터 파이프라인이다. 정확한 입력 계약, 역할이 구분된 벡터 공간, 순서까지 검증하는 응답 경계, 재작성 대신 차단하는 프라이버시 게이트, 폐기 가능한 캐시, stale write를 막는 해시 펜스, 해시로 잠긴 프로토타입.

그리고 그 벡터는 두 번 쓰인다. 그 사이에서 결정론적 시스템 — SQL 게이트, PostGIS 타원, 3-상태 일정, 버전 고정 스코어러 — 이 자격과 사실 범위를 소유한다. 모델에 도달하는 것은 모든 하드 제약을 이미 만족한 최대 30개의 후보다.

Chapter 3은 언어 모델이 그 제한된 집합으로 무엇을 유용하게 할 수 있는지, 그리고 그 밖의 일을 하지 못하게 막는 네 개의 독립 메커니즘에 대한 이야기다.