상세 컨텐츠

본문 제목

# 텍스트 임베딩(Text Embeddings) 입문: 의미를 숫자로 바꾸는 의미 기반 검색의 핵심 도구

AI

by Robert_ 2026. 5. 22. 01:51

본문

텍스트 임베딩(Text Embeddings) 입문: 의미를 숫자로 바꾸는 의미 기반 검색의 핵심 도구

지난 글에서 문서를 청크로 나누는 방법 까지 배웠어요. 이제 청크들이 손에 있어요. 그런데 사용자가 질문을 던지면 수백·수천 개의 청크 중 어느 게 관련 있는지 어떻게 골라낼까요?

가장 단순한 답은 키워드 검색 입니다. "버그" 라는 단어가 들어간 청크를 찾는 거죠. 하지만 사용자가 "오류" 라고 물으면? "glitch" 라고 물으면? 키워드 매칭은 동의어·맥락을 이해 못해서 막힙니다.

여기서 의미 기반 검색(Semantic Search) 이 등장해요. 그리고 그 핵심 기술이 오늘의 주인공 — 텍스트 임베딩(Text Embeddings) 입니다. "의미를 숫자로 바꿔서, 비슷한 의미끼리 가까운 거리에 두는" 기술이에요.

오늘은 임베딩이 정확히 무엇인지, 숫자가 무엇을 의미하는지, VoyageAI (Anthropic 권장 임베딩 제공자) 를 어떻게 쓰는지, 그리고 한국어에서 어떤 모델을 골라야 하는지까지 정리합니다.

🎯 이 글에서 배우는 것

  • 키워드 검색 vs 의미 기반 검색 의 차이
  • 텍스트 임베딩이 만들어지는 3단계 흐름
  • 임베딩의 숫자들이 무엇을 표현 하는가
  • VoyageAI API 셋업 + generate_embedding 함수
  • 실제로 동작하는지 검증 — 8가지 테스트 + 실측값 공개
  • 임베딩 모델 선택 가이드 (한국어 포함)
  • 다음 강의(검색·비교) 의 미리보기

🔍 1. 키워드 검색의 한계

[질문]   "이 시스템에 결함이 있나요?"
              ↓
[키워드 검색]   "결함" 단어 매칭
              ↓
[결과]   ❌ 청크에 "결함" 단어가 없음 → 검색 실패

하지만 그 청크에는...
"이 시스템은 여러 버그를 가지고 있습니다."
"오류가 빈번히 발생합니다."
"안정성에 문제가 있습니다."

이런 표현이 들어 있어도 키워드가 다르면 매칭 X

키워드 검색은 "동일한 단어" 만 잡습니다. 동의어·문맥·뉘앙스를 이해 못해요.

💡 이게 의미 기반 검색이 필요한 이유 입니다. 질문과 답이 다른 단어로 표현되어도 의미가 같으면 매칭되어야 해요.

🧮 2. 의미 기반 검색의 원리

핵심 아이디어:

"의미가 비슷한 텍스트는 비슷한 위치(좌표) 에 두자."

[2차원 그림으로 비유]

      ↑ "행복"
      |
   😊 즐겁다 (0.9, 0.8)
   😄 기쁘다 (0.85, 0.75)
   📈 좋다 (0.7, 0.6)
   ─────────────────→ "긍정"
   📉 나쁘다 (-0.7, -0.6)
   😞 슬프다 (-0.85, -0.75)
   😢 우울하다 (-0.9, -0.8)
      |
      ↓ "불행"

비슷한 의미는 가까운 좌표, 반대 의미는 먼 좌표 에 위치해요. 그러면 거리 계산 만으로 의미 유사도를 파악할 수 있죠.

텍스트 임베딩 은 이 좌표를 수백~수천 차원 으로 확장한 것입니다.

🔢 3. 임베딩이 만들어지는 과정

[1] 텍스트 입력
    "이 시스템에 버그가 있나요?"
              ↓
[2] 임베딩 모델 (Voyage / OpenAI / sentence-transformers 등)
              ↓
[3] 숫자 벡터 출력
    [0.12, -0.45, 0.88, 0.03, ..., -0.21]
    (보통 512~3072 차원)
    각 숫자는 -1 ~ +1 범위

핵심 특성 4가지

특성 설명
고차원 벡터 보통 512 ~ 3072 차원
각 차원 [-1, +1] 정규화된 범위
(준)결정론적 같은 입력 → 거의 같은 출력 (실측 cos ≈ 1.0, 호출에 따라 L2 0 ~ 0.01 의 미세 잡음 가능)
연속적 의미 공간 비슷한 의미 = 가까운 거리

차원 수의 의미

512차원: 더 작고 빠르고 저렴, 정확도 약간 ↓
1024차원: 균형점
1536차원 (OpenAI ada-002): 정확도 ↑, 비용 ↑
3072차원 (Voyage 3-large): 최고 정확도, 가장 비쌈

📌 추천: 1024 차원 정도가 정확도·비용 균형이 좋습니다. 시작점으로 추천.

🎭 4. 숫자들은 정확히 무엇을 의미하나?

이게 흥미롭고 까다로운 부분입니다.

"각 숫자가 정확히 무엇을 의미하는지, 우리는 모릅니다."

이론적으로는 "행복도 점수", "바다 관련도", "긍정 톤" 같은 의미가 들어있다고 상상할 수 있어요. 하지만:

  • ❌ 모델 학습 중 자동으로 결정된 차원들
  • 인간이 직접 해석 불가
  • ❌ 모델마다 차원의 의미가 완전히 다름
  • 거리(distance) 만 의미가 있음 — 가까우면 유사, 멀면 비유사
embedding("행복") = [0.12, -0.45, 0.88, ...]
                          ↑
                  이 값이 "행복도" 라는 보장 X
                  하지만 "행복" 끼리는 가까움 ✅

🔑 핵심: 임베딩의 개별 숫자는 블랙박스, 하지만 벡터 간 거리는 의미 있음.

🚀 5. VoyageAI — Anthropic 권장 임베딩 제공자

Anthropic 은 자체 임베딩 모델을 제공하지 않습니다. 대신 VoyageAI 를 권장해요.

왜 VoyageAI?

  • ✅ Anthropic 이 직접 추천하는 파트너
  • 무료 시작 (가입 시 토큰 크레딧 제공)
  • 다국어 지원 강함 (한국어 포함)
  • ✅ 도메인 특화 모델 (voyage-code-2, voyage-finance-2 등)
  • ✅ Claude API 와의 통합 사례가 많음

셋업 3단계

Step 1: 가입 + API 키 발급

VoyageAI 콘솔 에서 회원가입 후 API 키 발급. 무료 티어 로 시작 가능.

Step 2: 환경 변수에 추가

.env 파일에:

VOYAGE_API_KEY="your_key_here"

Step 3: 라이브러리 설치

pip3 install voyageai python-dotenv

코드 — generate_embedding 함수

from dotenv import load_dotenv
import voyageai

load_dotenv()
client = voyageai.Client()


def generate_embedding(
    text: str,
    model: str = "voyage-3-large",
    input_type: str = "query",
) -> list[float]:
    """
    텍스트를 임베딩 벡터로 변환.

    Args:
        text: 임베딩할 텍스트
        model: 사용할 모델 (기본 voyage-3-large)
        input_type: "query" 또는 "document"

    Returns:
        부동소수점 리스트 (벡터)
    """
    result = client.embed([text], model=model, input_type=input_type)
    return result.embeddings[0]

사용 예시

vec = generate_embedding("이 시스템에 버그가 있나요?")
print(len(vec))     # 1024 (voyage-3-large 의 차원)
print(vec[:5])       # 예: [-0.0482, 0.018, 0.0461, -0.0502, -0.0149]

📌 실측 결과 — 차원은 정확히 1024, 각 원소는 float. L2 norm 은 1.000000 (정규화된 단위 벡터). 위 출력값은 2026-05-22 기준 실제 측정치이지만, 모델 업데이트에 따라 미세하게 달라질 수 있어요.

⚠️ 6. input_type 파라미터의 미묘함

VoyageAI 의 임베딩은 input_type 에 따라 결과가 달라요.

input_type 용도
"query" 사용자 질문 / 검색어
"document" 인덱싱할 문서 청크
None 일반 (구분 없음)
# 청크 임베딩할 때 (대량)
chunk_vec = generate_embedding(chunk_text, input_type="document")

# 사용자 질문 임베딩할 때 (실시간)
query_vec = generate_embedding(user_question, input_type="query")

왜 구분하나?

querydocument 는 같은 의미라도 표현이 다릅니다. 질문은 "~인가요?" 같이 짧고 의문문, 문서는 서술문 으로 길죠. 모델이 이 차이를 다르게 인코딩 해서 검색 정확도가 5~15% 향상 됩니다.

🔬 실측: input_type 이 얼마나 강하게 영향을 주는가?

같은 문장을 querydocument 로 각각 임베딩한 뒤 코사인 유사도를 측정해봤어요.

입력 텍스트: "이 시스템은 여러 버그를 가지고 있습니다"
cos(query_vec, document_vec) = 0.7433

같은 문장인데 0.7433 — 1.0 과 한참 떨어져 있어요. 즉 input_type 은 단순 라벨이 아니라 인코딩 자체 를 바꿉니다. 청크는 document, 질문은 query 로 안 맞춰주면 검색 점수가 의미 없는 값이 됩니다.

📌 꼭 지키기: 청크는 input_type="document", 질문은 input_type="query". 안 지키면 검색 품질이 떨어져요.

🧪 7. 실제로 동작하나? — 검증 스크립트로 직접 확인 (보너스)

📝 이 섹션은 원문 강의에 없는 작성자(블로거)의 보충 자료 입니다. 한국 독자에게 유용할 법한 실무 팁/패턴을 모은 것이며, Anthropic 공식 가이드는 아니라는 점 참고해주세요.

블로그에서 "동의어가 가깝다", "input_type 이 중요하다" 같은 주장을 보면 의심부터 들죠. 그래서 8가지 테스트 로 직접 검증해봤어요. 모두 통과한 실측 결과를 공개합니다.

검증 코드 (test_embeddings.py)

"""
voyage-3-large 의 핵심 주장 8가지를 모두 검증.

⚠️ Voyage 무료 티어는 3 RPM 제한이 있어 호출 사이에 22초씩 대기합니다.
   전체 실행 시간 ~4분.
"""
from dotenv import load_dotenv
import time
import numpy as np
import voyageai

load_dotenv()
client = voyageai.Client()

MODEL = "voyage-3-large"
MIN_INTERVAL_SEC = 22.0  # 무료 티어 3 RPM 대응
_last_call_ts = time.time()
_cache: dict = {}


def _wait_for_rate_limit() -> None:
    global _last_call_ts
    elapsed = time.time() - _last_call_ts
    if elapsed < MIN_INTERVAL_SEC:
        time.sleep(MIN_INTERVAL_SEC - elapsed)
    _last_call_ts = time.time()


def embed(texts: list[str], input_type: str) -> list[list[float]]:
    """캐싱 + rate-limit 인지 임베딩 호출."""
    key = (tuple(texts), input_type)
    if key in _cache:
        return _cache[key]
    _wait_for_rate_limit()
    result = client.embed(texts, model=MODEL, input_type=input_type)
    _cache[key] = result.embeddings
    return result.embeddings


def cosine(a, b) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

이후 위 헬퍼로 다음 8가지를 검증합니다. 전체 실행 가이드 + 완성된 코드 + 실제 실행 로그는 아래 "🛠 직접 실행 5단계 가이드" 항목에 모두 정리해 두었어요.

🧪 검증 결과 한눈에 보기

# 테스트 측정값 결과
1 차원 / 타입 1024차, list[float]
2 정규화 (L2 norm) 1.000000
3 (준)결정론 L2 차이 0 ~ 0.011 / cos 0.9999 ~ 1.0 (호출마다 다를 수 있음)
4 input_type 효과 같은 텍스트 query↔doc cos 0.7433
5 의미 유사도 (동의어) "결함" ↔ "버그" 0.6486, "결함" ↔ "비빔밥" 0.1596
6 Cross-lingual 한글 질문 ↔ 영문 청크(관련) 0.6241 vs 영문↔영문 0.6469 vs 무관 0.2027
7 키워드 0매칭 → 의미 검색 성공 키워드 0/4, 의미 검색 관련 청크 0.6599 최상위
8 배치 임베딩 5문장 1콜, 40토큰

🔥 가장 흥미로운 발견 3가지

① 결정론은 "거의" 만 보장된다

같은 입력을 두 번 호출했을 때 운이 좋으면 L2 = 0.0 (cos = 1.000000) 으로 완벽히 동일, 다른 실행에서는 L2 ≈ 0.011 (cos ≈ 0.9999) 의 미세 잡음이 발생합니다. 즉 Voyage 는 bit-level 결정론을 보장하지 않아요 — 서버 라우팅·연산 환경에 따라 다릅니다.

실행 A: cos(v1, v2) = 1.000000   # 완전히 동일
실행 B: cos(v1, v2) = 0.999938   # 미세 잡음

코사인 유사도가 어떤 경우든 1.0 에 너무 가까워 검색 결과 순위는 흔들리지 않지만, 임베딩을 정확히 비교해야 하는 곳에 쓰면 깨질 수 있어요.

⚠️ 시사점: 임베딩을 키로 캐싱하거나 hash 로 비교하면 안 됩니다. 텍스트 자체 를 캐시 키로 쓰세요.

② input_type 은 "5~15% 향상" 보다 훨씬 강한 효과

같은 문장의 query 벡터와 document 벡터의 코사인 유사도가 0.7433 입니다. 단순 인코딩 차이가 아니라 다른 벡터 공간 에 가깝게 매핑돼요. 청크는 document, 질문은 query — 이 약속을 안 지키면 검색이 거의 무작위가 됩니다.

③ Cross-lingual 이 진짜로 된다

한글 질문: "이 시스템에 버그가 있나요?"
영문 청크: "This system has several known bugs and stability issues."
→ cos = 0.6241 ← 관련 있음 ✅

한글 질문 ↔ 무관 영문 청크 ("Pasta with tomato sauce...")
→ cos = 0.2027 ← 명확히 다름

영문 질문↔영문 청크 (0.6469) 와 거의 비슷한 점수가 나와요. 한국 사용자가 영어 문서를 검색 하는 cross-lingual RAG 가 실제로 동작합니다.

Test 5: 의미 유사도 시각화

질문 "이 시스템에 결함이 있나요?" 에 대한 각 후보의 코사인 유사도 (실측).

버그(동의어)        0.6486 █████████████████████████
안정성(동의어)       0.4882 ███████████████████
오류(동의어)        0.4453 █████████████████
행복(무관)         0.2050 ████████
요리(무관)         0.1596 ██████

동의어 3개가 모두 상위, 무관어가 하위 — 의미 기반 검색이 의도대로 동작하는 결정적 증거에요.

🛠 직접 실행 5단계 가이드

Step 1 — 작업 디렉토리 준비

mkdir -p voyageAI && cd voyageAI

Step 2 — .env 에 API 키 추가

# 키 이름은 반드시 VOYAGE_API_KEY (다른 이름은 인식 X)
echo 'VOYAGE_API_KEY=pa-여기에본인키' > .env

# 확인
grep -c VOYAGE_API_KEY .env   # 1 이 나오면 OK

Step 3 — 의존성 설치

pip3 install voyageai python-dotenv numpy

# 설치 확인
python3 -c "import voyageai, dotenv, numpy; print('OK')"

Step 4 — 빠른 연결 테스트 (generate_embed.py)

먼저 가볍게 1회 호출만 해서 인증·차원을 확인합니다.

# generate_embed.py
from dotenv import load_dotenv
import voyageai

load_dotenv()
client = voyageai.Client()


def generate_embedding(
    text: str,
    model: str = "voyage-3-large",
    input_type: str = "query",
) -> list[float]:
    """
    텍스트를 임베딩 벡터로 변환.

    Args:
        text: 임베딩할 텍스트
        model: 사용할 모델 (기본 voyage-3-large)
        input_type: "query" 또는 "document"

    Returns:
        부동소수점 리스트 (벡터)
    """
    result = client.embed([text], model=model, input_type=input_type)
    return result.embeddings[0]


vec = generate_embedding("이 시스템에 버그가 있나요?")
print(len(vec))     # 1024 (voyage-3-large 의 차원)
print(vec[:5])       # 예: [-0.0482, 0.018, 0.0461, -0.0502, -0.0149]
python3 generate_embed.py

Step 5 — 전체 검증 스크립트 (test_embeddings.py)

위에서 본 헬퍼들에 8개 테스트 함수를 묶은 완성본입니다. 그대로 복사해서 저장하세요.

"""
블로그 32_claude_text_embeddings.md 의 핵심 개념 동작 검증.

테스트 항목:
  1. 기본 임베딩 생성 + 차원/타입 확인
  2. 정규화(norm ≈ 1.0) 확인
  3. (준)결정론(같은 입력 → 거의 같은 출력) 확인
  4. query vs document input_type 의 결과 차이
  5. 의미 유사도 — 동의어("버그/오류/결함") vs 무관어("행복")
  6. Cross-lingual — 한글 chunk ↔ 영문 query 매칭
  7. 미니 RAG — 키워드 검색 실패, 의미 검색 성공 비교
  8. 배치 임베딩 (여러 텍스트 한 번에)

⚠️ Voyage AI 무료 티어는 3 RPM 제한이 있어, 호출 사이에 ~22초씩 대기합니다.
   전체 실행 시간은 약 3~4 분.

실행:
    python3 test_embeddings.py
"""
from dotenv import load_dotenv
import time
import numpy as np
import voyageai

load_dotenv()
client = voyageai.Client()

MODEL = "voyage-3-large"
EXPECTED_DIM = 1024
MIN_INTERVAL_SEC = 22.0  # 무료 티어 3 RPM 대응 (20초 + 버퍼)

_last_call_ts = time.time()  # 직전 실행이 rate limit 에 닿았을 수 있으니 보수적으로 시작
_cache: dict[tuple[tuple[str, ...], str], list[list[float]]] = {}


def _wait_for_rate_limit() -> None:
    global _last_call_ts
    elapsed = time.time() - _last_call_ts
    if _last_call_ts and elapsed < MIN_INTERVAL_SEC:
        wait = MIN_INTERVAL_SEC - elapsed
        print(f"    ⏳ rate-limit 대기 {wait:.1f}s ...")
        time.sleep(wait)
    _last_call_ts = time.time()


def embed(texts: list[str], input_type: str) -> list[list[float]]:
    """캐싱 + rate-limit 인지 임베딩 호출."""
    key = (tuple(texts), input_type)
    if key in _cache:
        return _cache[key]
    _wait_for_rate_limit()
    result = client.embed(texts, model=MODEL, input_type=input_type)
    _cache[key] = result.embeddings
    return result.embeddings


def embed_one(text: str, input_type: str = "query") -> list[float]:
    return embed([text], input_type)[0]


def cosine_similarity(a: list[float], b: list[float]) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))


def print_header(title: str) -> None:
    print(f"\n{'=' * 60}\n  {title}\n{'=' * 60}")


def test_1_basic_embedding() -> list[float]:
    print_header("Test 1. 기본 임베딩 생성")
    vec = embed_one("이 시스템에 버그가 있나요?", "query")
    print(f"  차원: {len(vec)}")
    print(f"  타입: {type(vec).__name__} / 원소 타입: {type(vec[0]).__name__}")
    print(f"  앞 5개: {[round(v, 4) for v in vec[:5]]}")
    assert len(vec) == EXPECTED_DIM, f"기대 {EXPECTED_DIM}, 실제 {len(vec)}"
    assert all(isinstance(v, float) for v in vec[:5])
    print("  ✅ 통과")
    return vec


def test_2_normalization(vec: list[float]) -> None:
    print_header("Test 2. 정규화(norm ≈ 1.0) 확인")
    norm = float(np.linalg.norm(vec))
    print(f"  L2 norm: {norm:.6f}")
    assert abs(norm - 1.0) < 1e-3, f"정규화되지 않음 (norm={norm})"
    print("  ✅ 통과 — 단위 벡터 (코사인 유사도 = 내적)")


def test_3_near_determinism() -> None:
    print_header("Test 3. (준)결정론 — 같은 입력 → 거의 같은 출력")
    text = "동일한 텍스트의 임베딩은 동일해야 합니다"
    v1 = embed_one(text, "query")
    _cache.clear()  # 두 번째 호출은 새 API 응답이어야 의미가 있음
    v2 = embed_one(text, "query")
    diff = float(np.linalg.norm(np.array(v1) - np.array(v2)))
    sim = cosine_similarity(v1, v2)
    print(f"  L2 차이:            {diff:.4e}")
    print(f"  코사인 유사도:      {sim:.6f}")
    print(f"  → Voyage API 는 호출마다 미세한 수치 잡음이 있음 (bit-identical X)")
    print(f"  → 하지만 코사인 유사도는 사실상 1.0 이라 검색 결과는 안정적")
    assert sim > 0.9999, f"동일 입력의 유사도가 너무 낮음 (sim={sim})"
    print("  ✅ 통과 — 검색 용도로는 충분히 안정적")


def test_4_input_type_matters() -> None:
    print_header("Test 4. input_type='query' vs 'document' 결과 차이")
    text = "이 시스템은 여러 버그를 가지고 있습니다"
    v_query = embed_one(text, "query")
    v_doc = embed_one(text, "document")
    sim = cosine_similarity(v_query, v_doc)
    print(f"  같은 텍스트인데 input_type 만 다르게:")
    print(f"  cos(query_vec, document_vec) = {sim:.4f}")
    print(f"  → 1.0 이 아니라는 것은 input_type 이 인코딩에 반영된다는 뜻")
    assert sim < 0.9999, "input_type 이 결과에 영향을 주지 않음"
    print("  ✅ 통과 — 청크/질문은 반드시 올바른 input_type 사용")


def test_5_semantic_similarity() -> None:
    print_header("Test 5. 의미 유사도 — 동의어는 가깝고, 무관어는 멀다")
    candidates = {
        "버그(동의어)": "이 시스템은 여러 버그를 가지고 있습니다",
        "오류(동의어)": "오류가 빈번히 발생합니다",
        "안정성(동의어)": "안정성에 문제가 있습니다",
        "행복(무관)": "오늘은 정말 행복한 하루였어요",
        "요리(무관)": "김치찌개를 맛있게 끓이는 비법을 알려드릴게요",
    }
    query = embed_one("이 시스템에 결함이 있나요?", "query")
    docs = embed(list(candidates.values()), "document")

    sims = [(label, cosine_similarity(query, v)) for label, v in zip(candidates, docs)]
    sims.sort(key=lambda x: -x[1])

    print(f"  질문: '이 시스템에 결함이 있나요?'\n")
    for label, sim in sims:
        bar = "█" * int(max(sim, 0) * 40)
        print(f"  {label:<14} {sim:.4f} {bar}")

    syn_top = [s for s in sims[:3] if "동의어" in s[0]]
    irr_bottom = [s for s in sims[-2:] if "무관" in s[0]]
    assert len(syn_top) == 3, "동의어가 상위 3 위 안에 들지 않음"
    assert len(irr_bottom) == 2, "무관어가 하위 2 위 안에 없음"
    print("\n  ✅ 통과 — 동의어 상위 3 위, 무관어 하위 2 위")


def test_6_cross_lingual() -> None:
    print_header("Test 6. Cross-lingual — 한글 chunk ↔ 영문 query")
    queries = embed(
        ["이 시스템에 버그가 있나요?", "Are there bugs in this system?"],
        "query",
    )
    docs = embed(
        [
            "This system has several known bugs and stability issues.",
            "Pasta with tomato sauce is my favorite dish.",
        ],
        "document",
    )
    ko_query, en_query = queries
    en_chunk, unrelated = docs

    sim_ko_en = cosine_similarity(ko_query, en_chunk)
    sim_en_en = cosine_similarity(en_query, en_chunk)
    sim_ko_unrelated = cosine_similarity(ko_query, unrelated)

    print(f"  한글 질문 ↔ 영문 청크(관련):    {sim_ko_en:.4f}")
    print(f"  영문 질문 ↔ 영문 청크(관련):    {sim_en_en:.4f}")
    print(f"  한글 질문 ↔ 영문 청크(무관):    {sim_ko_unrelated:.4f}")
    assert sim_ko_en > sim_ko_unrelated + 0.1, "Cross-lingual 매칭이 약함"
    print("  ✅ 통과 — 언어가 달라도 의미 유사도 잡힘")


def test_7_keyword_vs_semantic() -> None:
    print_header("Test 7. 키워드 검색 vs 의미 검색 — 블로그 §1 시나리오 재현")
    question = "이 시스템에 결함이 있나요?"
    chunks = [
        "이 시스템은 여러 버그를 가지고 있습니다.",
        "오류가 빈번히 발생합니다.",
        "안정성에 문제가 있습니다.",
        "오늘 점심은 맛있는 비빔밥을 먹었습니다.",
    ]

    print(f"  질문: '{question}'\n")
    print("  [키워드 검색 — '결함' 단어 매칭]")
    kw_hits = 0
    for c in chunks:
        hit = "결함" in c
        kw_hits += hit
        print(f"    {'✅' if hit else '❌'} {c}")
    print(f"  → 매칭 수: {kw_hits} / {len(chunks)} (전부 실패)\n")

    print("  [의미 검색 — 코사인 유사도]")
    q_vec = embed_one(question, "query")
    c_vecs = embed(chunks, "document")
    sims = sorted(
        [(c, cosine_similarity(q_vec, v)) for c, v in zip(chunks, c_vecs)],
        key=lambda x: -x[1],
    )
    for c, s in sims:
        print(f"    {s:.4f}  {c}")

    assert kw_hits == 0, "키워드 검색이 의도와 달리 매칭됨"
    top_chunk = sims[0][0]
    assert "점심" not in top_chunk and "비빔밥" not in top_chunk
    print("\n  ✅ 통과 — 키워드 0 매칭, 의미 검색은 관련 청크를 상위로 반환")


def test_8_batch_embedding() -> None:
    print_header("Test 8. 배치 임베딩 (1 API 호출로 N 개)")
    texts = [f"테스트 문장 번호 {i}" for i in range(5)]
    _wait_for_rate_limit()
    result = client.embed(texts, model=MODEL, input_type="document")
    print(f"  요청 텍스트 수: {len(texts)}")
    print(f"  반환된 벡터 수: {len(result.embeddings)}")
    print(f"  각 벡터 차원: {len(result.embeddings[0])}")
    print(f"  소비된 총 토큰: {result.total_tokens}")
    assert len(result.embeddings) == len(texts)
    assert all(len(v) == EXPECTED_DIM for v in result.embeddings)
    print("  ✅ 통과 — 배치 호출로 비용/지연 절감 가능")


def main() -> None:
    print(f"\n🚀 VoyageAI 임베딩 테스트 시작 (모델: {MODEL})")
    print(f"   ⏳ 무료 티어 3 RPM 제한으로 약 3~4 분 소요됩니다.\n")
    t0 = time.time()
    vec = test_1_basic_embedding()
    test_2_normalization(vec)
    test_3_near_determinism()
    test_4_input_type_matters()
    test_5_semantic_similarity()
    test_6_cross_lingual()
    test_7_keyword_vs_semantic()
    test_8_batch_embedding()
    print(f"\n🎉 모든 테스트 통과! (총 {time.time() - t0:.1f}s)\n")


if __name__ == "__main__":
    main()

실행:

python3 test_embeddings.py

📜 실제 실행 로그 (2026-05-23 측정)

아래는 같은 코드를 실제로 돌렸을 때의 완전한 stdout 입니다. 본인 환경 결과와 비교해보세요.

🚀 VoyageAI 임베딩 테스트 시작 (모델: voyage-3-large)
   ⏳ 무료 티어 3 RPM 제한으로 약 3~4 분 소요됩니다.


============================================================
  Test 1. 기본 임베딩 생성
============================================================
    ⏳ rate-limit 대기 22.0s ...
  차원: 1024
  타입: list / 원소 타입: float
  앞 5개: [-0.0482, 0.018, 0.0461, -0.0502, -0.0149]
  ✅ 통과

============================================================
  Test 2. 정규화(norm ≈ 1.0) 확인
============================================================
  L2 norm: 1.000000
  ✅ 통과 — 단위 벡터 (코사인 유사도 = 내적)

============================================================
  Test 3. (준)결정론 — 같은 입력 → 거의 같은 출력
============================================================
    ⏳ rate-limit 대기 21.6s ...
    ⏳ rate-limit 대기 21.8s ...
  L2 차이:            0.0000e+00
  코사인 유사도:      1.000000
  → Voyage API 는 호출마다 미세한 수치 잡음이 있음 (bit-identical X)
  → 하지만 코사인 유사도는 사실상 1.0 이라 검색 결과는 안정적
  ✅ 통과 — 검색 용도로는 충분히 안정적

============================================================
  Test 4. input_type='query' vs 'document' 결과 차이
============================================================
    ⏳ rate-limit 대기 21.8s ...
    ⏳ rate-limit 대기 21.8s ...
  같은 텍스트인데 input_type 만 다르게:
  cos(query_vec, document_vec) = 0.7433
  → 1.0 이 아니라는 것은 input_type 이 인코딩에 반영된다는 뜻
  ✅ 통과 — 청크/질문은 반드시 올바른 input_type 사용

============================================================
  Test 5. 의미 유사도 — 동의어는 가깝고, 무관어는 멀다
============================================================
    ⏳ rate-limit 대기 21.8s ...
    ⏳ rate-limit 대기 21.8s ...
  질문: '이 시스템에 결함이 있나요?'

  버그(동의어)        0.6486 █████████████████████████
  안정성(동의어)       0.4882 ███████████████████
  오류(동의어)        0.4453 █████████████████
  행복(무관)         0.2050 ████████
  요리(무관)         0.1596 ██████

  ✅ 통과 — 동의어 상위 3 위, 무관어 하위 2 위

============================================================
  Test 6. Cross-lingual — 한글 chunk ↔ 영문 query
============================================================
    ⏳ rate-limit 대기 21.7s ...
    ⏳ rate-limit 대기 21.4s ...
  한글 질문 ↔ 영문 청크(관련):    0.6241
  영문 질문 ↔ 영문 청크(관련):    0.6469
  한글 질문 ↔ 영문 청크(무관):    0.2027
  ✅ 통과 — 언어가 달라도 의미 유사도 잡힘

============================================================
  Test 7. 키워드 검색 vs 의미 검색 — 블로그 §1 시나리오 재현
============================================================
  질문: '이 시스템에 결함이 있나요?'

  [키워드 검색 — '결함' 단어 매칭]
    ❌ 이 시스템은 여러 버그를 가지고 있습니다.
    ❌ 오류가 빈번히 발생합니다.
    ❌ 안정성에 문제가 있습니다.
    ❌ 오늘 점심은 맛있는 비빔밥을 먹었습니다.
  → 매칭 수: 0 / 4 (전부 실패)

  [의미 검색 — 코사인 유사도]
    ⏳ rate-limit 대기 21.8s ...
    0.6599  이 시스템은 여러 버그를 가지고 있습니다.
    0.4866  안정성에 문제가 있습니다.
    0.4460  오류가 빈번히 발생합니다.
    0.1728  오늘 점심은 맛있는 비빔밥을 먹었습니다.

  ✅ 통과 — 키워드 0 매칭, 의미 검색은 관련 청크를 상위로 반환

============================================================
  Test 8. 배치 임베딩 (1 API 호출로 N 개)
============================================================
    ⏳ rate-limit 대기 21.6s ...
  요청 텍스트 수: 5
  반환된 벡터 수: 5
  각 벡터 차원: 1024
  소비된 총 토큰: 40
  ✅ 통과 — 배치 호출로 비용/지연 절감 가능

🎉 모든 테스트 통과! (총 242.3s)

💡 재밌는 관찰: 위 로그의 Test 3 는 L2 = 0.0 / cos = 1.000000 으로 완벽히 동일했지만, 다른 실행에서는 L2 ≈ 0.011 / cos ≈ 0.9999 가 나오기도 했어요. 즉 Voyage 는 bit-level 결정론을 보장하지 않습니다 — 운 좋게 같을 수도, 아닐 수도 있어요. 검색 품질에는 영향이 없지만 임베딩 자체를 hash 키로 쓰면 안 되는 이유 입니다.

🚨 자주 만나는 에러 & 해결

에러 메시지 원인 / 해결
RateLimitError: You have not yet added your payment method... 무료 티어 3 RPM 도달. 스크립트는 자동 대기하니 그대로 두면 OK. 너무 자주 걸리면 MIN_INTERVAL_SEC = 30.0 으로 늘려보세요.
voyageai.error.AuthenticationError .env 의 키 이름이 VOYAGE_API_KEY 인지 확인 (다른 이름 X). 따옴표 없이 VOYAGE_API_KEY=pa-xxx 형식 권장.
ModuleNotFoundError: voyageai pip3 install voyageai python-dotenv numpy
urllib3 NotOpenSSLWarning macOS LibreSSL 경고일 뿐 무시 가능. 숨기려면 export PYTHONWARNINGS="ignore::urllib3.exceptions.NotOpenSSLWarning"
AssertionError: 결정론 위반 이전 버전 코드. 위 §7 의 최신 test_3_near_determinism 으로 교체하면 됩니다 (assertion 을 코사인 유사도 기준으로 완화).

💰 비용 안내: 8개 테스트 전체에 사용되는 토큰은 약 200토큰 미만. 무료 티어로도 충분히 돌릴 수 있어요.

🌐 8. 임베딩 모델 선택 가이드

VoyageAI 외에도 다양한 옵션이 있어요.

Voyage 모델 라인업

모델 차원 강점 비용
voyage-3-large 1024 최고 품질·다국어 💰💰
voyage-3 1024 균형 💰
voyage-3-lite 512 빠름·저렴 💰 (저)
voyage-code-2 1536 코드 검색 특화 💰💰
voyage-finance-2 1024 금융 특화 💰💰
voyage-multilingual-2 1024 다국어 (한국어 포함) 💰💰

다른 제공자 옵션

제공자 모델 특징
OpenAI text-embedding-3-large 광범위 사용, 1536/3072
Cohere embed-multilingual-v3 다국어 강함
Sentence-Transformers BAAI/bge-m3 로컬 실행 가능, 무료
Sentence-Transformers jhgan/ko-sroberta-multitask 한국어 특화

결정 가이드

도메인이 코드인가? → voyage-code-2
도메인이 금융인가? → voyage-finance-2
한국어 위주인가? → voyage-multilingual-2 또는 ko-sroberta
사내 보안이 까다로운가? → 로컬 sentence-transformers
범용 + 영어 중심인가? → voyage-3-large 또는 OpenAI 3-large

🛡 9. 운영 환경에서 자주 마주치는 함정 (보너스)

📝 이 섹션은 원문 강의에 없는 작성자(블로거)의 보충 자료 입니다. 한국 독자에게 유용할 법한 실무 팁/패턴을 모은 것이며, Anthropic 공식 가이드는 아니라는 점 참고해주세요.

함정 1: 청크와 질문에 다른 모델 사용

# ❌ 청크는 모델 A, 질문은 모델 B
chunk_vec = embed_with_model_A(chunk)
query_vec = embed_with_model_B(query)

# 두 벡터 공간이 달라서 거리 계산이 무의미

같은 모델 + 같은 차원 으로 인덱싱·질의해야 해요.

함정 2: 모델 변경 시 재인덱싱 누락

새 모델로 바꿨는데 기존 인덱스를 그대로 쓰면 → 검색 정확도 폭망.

# 모델 변경하면 전체 재인덱싱 필수
for chunk in all_chunks:
    chunk["embedding"] = generate_embedding(chunk["text"], input_type="document")

함정 3: 임베딩 비용 폭주

문서 1만 페이지 = 청크 100만 개 = API 호출 100만 번. 비용 폭발.

대응:

  • 배치 호출 사용 (client.embed([chunk1, chunk2, ...]) 한 번에 수십 개)
  • 로컬 모델 로 전환 (sentence-transformers)
  • 한 번 인덱싱하면 영구 저장 — 매번 재계산 X

함정 4: 임베딩 저장소 미준비

벡터를 어디에 저장할지가 중요한 결정입니다.

[작은 규모]
- numpy array + JSON / parquet (수천 청크)

[중간 규모]
- FAISS (수십만 청크, 로컬)

[운영 규모]
- Pinecone, Weaviate, Qdrant, Chroma (수백만+, 클라우드)

[대규모 + 메타데이터]
- Postgres + pgvector

이 부분은 다음 강의(Implementing the RAG flow) 에서 다룹니다.

함정 5: 토큰 한도 초과

대부분의 임베딩 모델은 단일 입력당 토큰 한도 가 있어요 (보통 8K~32K 토큰).

# voyage-3-large 의 한도는 32K 토큰
# 청크가 너무 크면 임베딩 실패 또는 잘림

청크 크기를 모델 한도 미만 으로 유지하세요.

함정 6: 정규화 가정

일부 코드는 임베딩이 단위 벡터(norm=1) 라고 가정합니다.

# Voyage 임베딩은 보통 정규화되어 있지만, 명시적 확인 권장
import numpy as np

vec = generate_embedding("hello")
norm = np.linalg.norm(vec)
print(f"Norm: {norm}")  # 실측: 1.000000 ← Voyage 는 정규화됨

정규화되어 있으면 코사인 유사도 = 내적(dot product) 으로 단순화 가능 → 빠름. (§7 의 Test 2 에서 실측 확인)

함정 7: 임베딩을 hash 키로 사용

§7 의 Test 3 에서 확인했듯 Voyage 는 bit-identical 을 보장하지 않습니다 (실행에 따라 L2 = 0 일 수도 있고 ~0.01 의 미세 잡음일 수도 있음). 임베딩 자체를 캐시 키나 hash 로 쓰면 같은 텍스트가 다른 키로 인식돼 캐시 미스가 발생할 수 있어요.

# ❌ 위험: 임베딩 자체를 키로
cache[tuple(vec)] = result  # 다음 호출 때 키 불일치

# ✅ 안전: 원문 텍스트를 키로
cache[text] = vec

🌟 10. 한국 환경 추가 팁 (보너스)

📝 이 섹션은 원문 강의에 없는 작성자(블로거)의 보충 자료 입니다. 한국 독자에게 유용할 법한 실무 팁/패턴을 모은 것이며, Anthropic 공식 가이드는 아니라는 점 참고해주세요.

1. 한국어 임베딩 모델 비교

모델 한국어 정확도 다국어 비용·속도
voyage-multilingual-2 ⭐⭐⭐⭐ 다국어 API ($)
OpenAI text-embedding-3-large ⭐⭐⭐ 다국어 API ($$)
BAAI/bge-m3 ⭐⭐⭐⭐⭐ 다국어 로컬 (무료)
jhgan/ko-sroberta-multitask ⭐⭐⭐⭐⭐ 한국어 전용 로컬 (무료)
intfloat/multilingual-e5-large ⭐⭐⭐⭐ 다국어 로컬 (무료)

🎯 한국어 위주 서비스 추천: BAAI/bge-m3 로컬 모델 이 무료이면서 정확도도 최상위권. GPU 만 있으면 운영 비용 거의 0.

2. 한국어 임베딩 코드 (로컬)

# pip install sentence-transformers torch
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-m3")

def generate_embedding_local(text: str) -> list[float]:
    vec = model.encode(text, normalize_embeddings=True)
    return vec.tolist()


# 사용
vec = generate_embedding_local("이 시스템에 버그가 있나요?")
print(len(vec))  # 1024

API 호출 없이 로컬에서 실행. 데이터 외부 유출 없음 보안 위험 ↓.

3. 한국어 청크 + 영어 질문 호환성

다국어 모델은 언어가 달라도 같은 의미면 가까운 벡터 를 만들어요.

ko_chunk = generate_embedding("이 시스템에 버그가 있습니다", input_type="document")
en_query = generate_embedding("Are there bugs in this system?", input_type="query")
# 코사인 유사도가 높게 나옴 ✅

§7 Test 6 에서 한글 질문 ↔ 영문 청크 = 0.6241, 영문 질문 ↔ 영문 청크 = 0.6469 으로 거의 비슷하게 매칭되는 걸 실측했어요. 이 덕분에 한국 사용자가 영어 문서 검색 같은 cross-lingual RAG 도 가능해요.

4. 평가 시스템과 연동

post 14~15 의 평가로 임베딩 모델 선택을 자동화 할 수 있어요.

def measure_embedding_model(model_fn):
    chunks_emb = [model_fn(c) for c in test_chunks]
    queries_emb = [model_fn(q) for q in test_queries]
    return retrieval_accuracy(chunks_emb, queries_emb, ground_truth)


# 모델 비교
results = {
    "voyage-3-large": measure_embedding_model(voyage_embed),
    "bge-m3": measure_embedding_model(bge_embed),
    "ko-sroberta": measure_embedding_model(ko_sroberta_embed),
}

데이터로 결정.

5. 비용 추정

시나리오 예상 비용
1만 청크 1회 인덱싱 (Voyage 3) ~$1
사용자 1만 명/월 검색 (Voyage 3) ~$0.5 / 월
전체 위키 100만 청크 인덱싱 ~$30
로컬 BGE-M3 (GPU 1장) $0 / 호출

대규모 트래픽이면 로컬 모델 전환이 ROI 가 좋습니다.

6. 보안: 사내 데이터 임베딩

회사 내부 문서 임베딩 시 주의:

  • OpenAI Embeddings API 에 본문 전송 → 외부 유출 위험
  • 로컬 모델 (BGE-M3, ko-sroberta) → 사내 GPU 만 사용
  • ✅ Voyage 의 on-premise 옵션 (엔터프라이즈)
  • ✅ 청크에 권한 메타데이터 부착 후 검색 시 필터링

🔮 11. 다음 단계 미리보기

오늘은 임베딩을 만드는 데까지 했어요. 이걸 검색 에 어떻게 쓸지가 다음 강의의 주제입니다.

[오늘] 청크·질문 → 벡터로 변환 ✅
[다음] 코사인 유사도로 가장 가까운 벡터 찾기
[그다음] 실제 RAG 파이프라인 구현
[그다음] BM25 (키워드) 와의 하이브리드
[그다음] 멀티 인덱스 RAG

✨ 핵심 정리

  • 키워드 검색 은 동의어·맥락에 약함 → 의미 기반 검색 으로 해결
  • 텍스트 임베딩 = 의미를 고차원 벡터로 인코딩 한 것
  • 각 차원 [-1, +1], 보통 512~3072 차원 (Voyage 3-large 는 실측 1024차, norm=1.000000)
  • 개별 숫자는 블랙박스 지만 벡터 간 거리는 의미 있음
  • Anthropic 권장 = VoyageAI, 무료 시작 가능
  • input_type 구분 필수 ("query" vs "document") → 같은 문장도 cos 0.7433 으로 다르게 인코딩됨 (실측)
  • 결정론은 "거의"만 보장 — 임베딩 자체를 캐시 키로 쓰지 말 것 (실측 cos 0.99991.0, L2 00.01)
  • Cross-lingual 동작 확인 — 한글 질문 ↔ 영문 청크 매칭이 영문↔영문과 거의 동급
  • 모델 선택: 도메인별 (코드/금융/다국어) + 차원 (속도 vs 정확도)
  • 함정 7종: 모델 불일치, 재인덱싱 누락, 비용 폭주, 저장소 미준비, 토큰 한도, 정규화 가정, hash 키 오용
  • 한국 환경: BAAI/bge-m3 로컬 모델 강력 추천 (무료 + 한국어 정확도 최상위)
  • §7 검증 스크립트 로 위 모든 주장을 직접 재현 가능 — voyageAI/test_embeddings.py
  • 다음 강의: 임베딩으로 실제 검색 하는 법

📚 출처 (Source)

본 글은 Anthropic Academy"Building with the Claude API" 코스 중 'Text embeddings' 강의 내용을 한국어로 정리·요약한 것입니다.

  • 원문 출처: Anthropic Academy - Building with the Claude API
  • 강의 챕터: RAG and Agentic Search → Text embeddings
  • 관련 자료: 002_embeddings.ipynb, VoyageAI API Key Directions.pdf (강의 다운로드 자료)
  • 저작권: © Anthropic. All rights reserved.

⚠️ 본 글은 학습 목적의 요약본이며, 정확하고 최신화된 내용은 반드시 Anthropic 공식 문서VoyageAI 공식 문서를 참고해주세요.


이 글이 도움이 되셨다면 공감 ❤️ 과 구독 🔔 부탁드립니다! 여러분이 사용하시는 임베딩 모델은 무엇인가요? VoyageAI, OpenAI, 또는 로컬 모델 — 댓글로 비교 경험 공유해주세요. 다음 글에서는 The full RAG flow — 임베딩으로 실제 검색을 수행하고 답변을 생성하는 전체 흐름 을 다룹니다.

#Claude #ClaudeAPI #Anthropic #LLM #RAG #TextEmbedding #VectorSearch #SemanticSearch #VoyageAI #LLMOps #AI개발 #생성형AI #한국어임베딩 #BGE #BM25 #코사인유사도

관련글 더보기