임베딩 검색 구현기 - embedding
2026.05.09
0. 벡터 임베딩 처음 구현해본 후기
1. 임베딩의 본질 — "특정 모델만의 좌표계"
시작 시점의 멘탈 모델
"어깨너머로 본 벡터 임베딩. 1536차원이라는데 대체 그게 무슨 차원이라는 거지?"
끝 시점의 멘탈 모델
- 인간이 1536차원 배열을 보고 의미를 읽을 수는 없다. 대신 "두 벡터의 거리가 얼마나 가까운가"가 의미적 유사도를 표현한다.
- 더 중요한 깨달음: 임베딩은 "절대적 의미 좌표계"가 아니라 특정 모델만의 좌표계다.
- text-embedding-3-small의 좌표 ≠ BGE-M3의 좌표
- 두 벡터를 비교하려면 같은 모델로 만들어야만 한다.
- → 인덱싱한 모델과 검색 시 쓰는 모델이 반드시 같아야 한다!
- 차원 수(1536, 3072 등)는 "표현 해상도". 높을수록 미세한 의미 차이를 담을 수 있지만, 저장·연산 비용도 비례해서 증가.
2. 모델 선택 트레이드오프
1. API vs 로컬(self-hosted)
| API (OpenAI, Cohere 등) | 로컬 (BGE-M3, E5 등) | |
|---|---|---|
| 초기 비용 | 0 (호출당 과금) | GPU/서버 비용 선투자 |
| 운영 | 호출만 하면 됨 | 모델 로딩, 메모리, 배치 처리 직접 관리 |
| 데이터 | 외부로 나감 | 내부에서 끝남 |
| 속도 | 네트워크 + 큐 대기 | GPU 있으면 빠름, CPU면 느림 |
| 모델 선택권 | 제공자가 주는 것만 | 허깅페이스에 있는 거 다 가능 |
| 100만건 비용 예시 | text-embedding-3-small ~$X | 전기세 + 시간 |
언제 어느 쪽? > 이번엔 그냥 API 사용함
- API: 프로토타입, 1회성 인덱싱, 데이터 민감도 낮음, 운영 인력 적음 → 이번 프로젝트
- 로컬: 지속적으로 임베딩 만들어야 함, 데이터 외부 반출 불가, 한국어 특화 모델 쓰고 싶음
2. small vs large (같은 제공자 안에서)
OpenAI 기준 예시:
text-embedding-3-small: 1536차원, $0.02/1M 토큰text-embedding-3-large: 3072차원, $0.13/1M 토큰 (약 6.5배)
3. 차원 수의 의미
- 임베딩 생성 비용: 일반적으로 차원 ↑ → 토큰당 가격 ↑
- 저장 비용: 1536 vs 3072 → DB 용량 2배
- 검색 비용: 거리 계산이 차원에 비례. HNSW 같은 ANN 인덱스 빌드/탐색도 차원에 비례해서 느려짐
3. 임베딩 입력 텍스트 설계
왜 입력 텍스트 설계가 중요한가
"임베딩은 모델 고르는 게 아니라 입력 텍스트를 설계하는 일이다."
임베딩 품질의 70%는 "어떤 텍스트를 넣느냐"에서 결정된다고 함. 모델은 도구일 뿐이고, 입력이 곧 좌표를 정하는...
26개 컬럼 → 1개 문자열로
원본 데이터: 26개 컬럼 (자연어 + 범주형 혼합)
전략 검토:
- A안. 모든 컬럼을 단순 concat
- 단점: 신호가 묻힘. 짧은 컬럼은 긴 컬럼에 가려짐.
- B안. 자연어 컬럼만 선별 + 단순 concat
- 단점: 컬럼 경계가 사라져서 모델이 어떤 정보가 어떤 측면인지 구분 못함.
- C안. 자연어 컬럼 10개 선별 + 섹션 태그 + 순서 설계 ← 채택
- 컬럼별로
[섹션명]태그를 앞에 붙여 모델에게 "이건 어떤 측면" 신호 제공 - 핵심 요약을
[요약]태그로 가장 앞에 배치 (트랜스포머의 앞 토큰 가중)
- 컬럼별로
최종 입력 텍스트 형식
[요약] (페르소나 핵심 요약)
[직업] ...
[성격] ...
[관심사] ...
...
왜 26 → 10인가
- 범주형 컬럼은 임베딩보다 메타데이터 필터로 다루는 게 효율적 (예: age_group은 WHERE 절로 필터, 임베딩 텍스트엔 불필요)
- 짧은 컬럼이나 거의 모든 행에서 같은 값을 갖는 컬럼은 신호 기여도 낮음
- 임베딩 토큰 비용 = 텍스트 길이에 비례. 신호 낮은 컬럼 빼는 게 비용에도 이득
4. 쿼리 augmentation (HyDE)
적용했더니?
매칭 점수 분포 변화. 상위 10건 평균 > 0.42 → 0.5+ 그냥 사람이 봐도 유사한 사례가 확실히 많이 나옴
왜 작동하는가??
임베딩 좌표계에서 "짧은 쿼리 텍스트"와 "긴 페르소나 문서"는 형식·길이·문체가 달라서 자연스럽게 멀리 떨어진다. 같은 의미를 담고 있어도 좌표상으론 다른 동네에 있는 셈. 그러니, 사용자가 쿼리를 짧게 입력하면 덜 정확한 결과가 나오게 된다.
HyDE는 쿼리를 "페르소나 문서처럼 생긴 가상 문서"로 변환한 뒤 임베딩한다. 즉 좌표계의 같은 동네로 옮긴 다음 비교한다. 의미가 같으면 진짜로 가까운 위치가 나오기 시작한다.
캐시 설계 (2단)
L1 (HyDE 결과 캐시):
- key: hyde:gpt-4o-mini:{sha256(query + filters)}
- value: HyDE가 생성한 11섹션 텍스트
- TTL: 30d
- 목적: 같은 (쿼리, 필터) → LLM 재호출 회피
L2 (임베딩 캐시):
- key: emb:text-embedding-3-small:{sha256(11섹션 텍스트)}
- value: 1536차원 벡터
- TTL: 30d
- 목적: 같은 텍스트 → 임베딩 재호출 회피
왜 2단인가
- L1만 있으면: 다른 쿼리지만 HyDE 결과가 같은 케이스를 못 잡음 (드물지만 있음)
- L2만 있으면: HyDE는 매번 호출 (LLM 비용 + 500ms~1s 지연)
- 2단으로 분리: temperature=0 → HyDE 결과 결정적 → L1 miss인데 같은 텍스트가 나오면 L2 hit으로 임베딩 비용 절감
HyDE의 함정도 있다
- HyDE는 LLM이 잘못된 사실을 만들어내도 그게 임베딩되어 검색에 영향을 줄 수 이다.
- 따라서 사실성보다 "형식 흉내"가 목적임을 인지하고 프롬프트 설계~.
- temperature=0 보장 안 하면 캐시 hit율이 급락 (매번 다른 텍스트 → L2 miss)
5. OpenAI Batch API 의 함정
왜 Batch API였나
- 100만건 동기 호출은 RPM/TPM 한도에 막힘
- Batch API는 50% 할인 + 24h 내 비동기 처리
부딪힌 함정
- "Enqueued token limit"은 동시 큐 한도(20M)이지 일일 한도가 아니다. 공식 문서를 처음엔 일일 한도로 오해.
- batch 파일 1개가 enqueue 한도를 통째로 넘으면 (50K건 ≈ 80M 토큰) 파일 전체가 거부됨...
- 해결: 12K건 단위로 분할. 동시 N개를 큐에 올려두고 끝나는 대로 다음 거 투입.
- 부수 함정: batch가 "성공적으로 등록됐지만 즉시 failed" 상태로 떨어지는 케이스. SDK 예외가 안 뜨므로 status 폴링 + 검사 필수.
6. 검증 패턴
1만건 샘플에서 했어야 했지만 못 한 검증
- 매칭 점수 분포의 상한이 0.42에서 막히는 이유 분석 → 입력 텍스트 설계 문제인지, 모델 한계인지, 쿼리 측 문제인지 분리해서 봤어야 → 100만건 다 돌리고 나서 HyDE 도입한 건 사후 대응. 1만건 단계에서 발견했으면 더 빨랐을 것.
- 의미적으로 가까워야 할 페르소나 쌍의 유사도가 실제로 가까운지 sanity check (예: 같은 직업, 비슷한 성격의 페르소나 5쌍을 직접 골라서 유사도 측정)
- "전혀 안 비슷한 쌍"의 유사도 분포도 같이 봤어야 (false match 기준선)