포스트

2026-03-17 강의 정리

NLP 기초 - Tokenization + Embedding 이해 (BoW, TF-IDF, Word2Vec)

2026-03-17 강의 정리

목차

  1. 오늘 강의 개요
  2. 임베딩(Embedding) 개념
  3. OpenAIEmbeddings - 텍스트를 벡터로 변환
  4. tiktoken - 토크나이저
  5. 텍스트 분할 (Chunking) - RecursiveCharacterTextSplitter
  6. from_tiktoken_encoder - 토큰 수 기준 분할
  7. 청킹 리포트 함수
  8. 코사인 유사도 (Cosine Similarity)
  9. 쿼리 vs 문서 유사도 검색
  10. 임베딩 기반 텍스트 분류 (Zero-shot)
  11. CacheBackedEmbeddings - 임베딩 캐시
  12. 핵심 개념 총정리
  13. 자주 나오는 실수 / 주의사항

1. 오늘 강의 개요

오늘은 RAG 시스템의 핵심 기반 기술인 임베딩을 배운다. 텍스트 → 벡터(숫자 배열) 변환이 왜 필요하고 어떻게 활용하는지 이해한다.

임베딩이 왜 필요한가?

컴퓨터는 텍스트를 직접 비교할 수 없다. “재택근무 규정” == “재택근무 정책” → 컴퓨터: False (문자열이 다름) 하지만 의미는 같음!

임베딩을 사용하면: “재택근무 규정” → [0.12, -0.34, 0.89, …] (1536차원 벡터) “재택근무 정책” → [0.11, -0.35, 0.87, …] (비슷한 벡터) → 코사인 유사도 0.97 (매우 유사!)

오늘 배우는 것

임베딩 → 텍스트를 1536차원 벡터로 변환 tiktoken → 텍스트를 토큰으로 분리 (비용 계산) Chunking → 긴 문서를 작은 조각으로 분할 코사인 유사도 → 두 벡터의 유사도 측정 분류 → 임베딩 기반 Zero-shot 분류


2. 임베딩(Embedding) 개념

임베딩이란?

  • 이미지, 텍스트, 음성 등 고차원 데이터를 저차원 숫자 벡터로 압축 표현
  • 의미적으로 유사한 텍스트는 벡터 공간에서 가까운 위치에 존재

비유

1
2
3
4
텍스트 → 임베딩 → 벡터 공간에서 위치
"강아지" → [x1, y1, z1, ...]   ─┐
"고양이" → [x2, y2, z2, ...]   ─┤  가까운 위치 (둘 다 반려동물)
"자동차" → [x3, y3, z3, ...]    ─┘  먼 위치 (다른 개념)

텍스트 임베딩 모델 종류

모델명차원특징
text-embedding-3-small1536빠르고 저렴, 일반 사용에 적합
text-embedding-3-large3072정확도 높음, 비용 더 높음
text-embedding-ada-0021536구버전 (3-small 권장)

1536차원이 포착하는 정보

1
2
# 15000차원이라면: 주어와 목적어 사이의 관계, 주어랑 다음에 올지 모르는 단어와 동사의 관계 ...
# 1536차원:       주어, 목적어, 동사, 그들의 관계

차원이 클수록 더 세밀한 언어적 관계를 포착하지만, 비용과 속도 트레이드오프가 있음


3. OpenAIEmbeddings - 텍스트를 벡터로 변환

설정

1
2
3
from langchain_openai import OpenAIEmbeddings

embeddings_model = OpenAIEmbeddings(model='text-embedding-3-small')

단일 텍스트 임베딩 - embed_query()

1
2
3
4
test_emb = embeddings_model.embed_query("Hello")

print(len(test_emb))    # 1536  ← 1536차원 벡터!
print(test_emb[:5])     # [0.019, -0.064, -0.001, 0.078, 0.021]  처음 5개 값

embed_query() : 단일 텍스트 → 1개 벡터 (주로 검색 쿼리에 사용) 반환값: float 리스트 (길이 1536)

여러 텍스트 임베딩 - embed_documents()

1
2
3
4
5
6
7
8
texts = [
    "인공지능이 세상을 바꾸고 있습니다",
    "오늘 저녁에 치킨을 시켜먹었다"
]

doc_vectors = embeddings_model.embed_documents(texts)
print(len(doc_vectors))     # 2  (2개 텍스트 → 2개 벡터)
print(len(doc_vectors[0]))  # 1536  (각 벡터는 1536차원)

embed_documents() : 여러 텍스트 → 여러 벡터 (주로 문서 인덱싱에 사용)


4. tiktoken - 토크나이저

토크나이저란?

  • LLM은 텍스트를 통째로 처리하지 않고 “토큰(token)” 단위로 처리
  • tiktoken: OpenAI 모델의 공식 토크나이저 라이브러리
  • BPE (Byte Pair Encoding): 자주 나오는 문자 조합을 하나의 토큰으로 묶는 방식

기본 사용법

1
2
3
4
5
6
7
8
9
10
11
12
import tiktoken

enc = tiktoken.encoding_for_model('gpt-4o-mini')

text   = "안녕하세요, 오늘 LLM에 대해 배워보겠습니다"
tokens = enc.encode(text)   # 텍스트 → 토큰 ID 리스트

print(tokens)
# [14307, 171731, 11, 106820, 451, 19641, 3107, 67946, 33628, 33771, 8122, 105216]

enc.decode(tokens)          # 토큰 ID → 텍스트
# '안녕하세요, 오늘 LLM에 대해 배워보겠습니다'

각 토큰 확인

1
2
3
4
5
6
7
8
9
10
11
for i, token_id in enumerate(tokens):
    token_text = enc.decode([token_id])
    print(f" token {i+1}: ID={token_id}'{token_text}'")

# token 1: ID=14307  → '안'
# token 2: ID=171731 → '녕하세요'
# token 3: ID=11     → ','
# token 4: ID=106820 → ' 오늘'
# token 5: ID=451    → ' L'
# token 6: ID=19641  → 'LM'
# ...

한국어 vs 영어 토큰 비교

1
2
3
4
5
6
7
# 영어: 1 단어 ≈ 1 토큰
# "I have an apple" → 4 토큰

# 한국어: 1 글자 ≈ 1~2 토큰
# "안녕하세요" → 2 토큰 (안 / 녕하세요)

# 결론: 한국어 프롬프트는 영어보다 토큰 비용이 더 많이 든다!

토큰 수 계산 유틸 함수

1
2
3
4
5
6
def count_token(text, model='gpt-4o-mini'):
    enc = tiktoken.encoding_for_model(model)
    return len(enc.encode(text))

print(count_token("Hello, world!"))          # 4
print(count_token("안녕하세요, 반갑습니다"))  # 약 8~10

API 비용 = 토큰 수 × 단가 → count_token()으로 미리 계산 가능

API 비용 기준 (gpt-4o-mini)

1
2
3
4
5
6
# input:  $0.05  / 1M tokens
# output: $0.20  / 1M tokens

total_tokens = count_token(my_long_prompt)
cost = total_tokens * 0.05 / 1_000_000
print(f"예상 입력 비용: ${cost:.6f}")

5. 텍스트 분할 (Chunking) - RecursiveCharacterTextSplitter

왜 텍스트를 분할하나?

문제: LLM의 context window 한계 - gpt-4o-mini: 최대 ~128K 토큰 - 긴 문서 전체를 넣으면 비용 폭발 + 느려짐 + 관련 없는 부분 포함

해결: 관련 있는 부분만 쏙 뽑아서 LLM에 전달 → 이것이 RAG의 핵심 → 긴 문서를 작은 “청크(chunk)”로 분할 → 검색으로 관련 청크만 전달

RecursiveCharacterTextSplitter - 문자 수 기준

1
2
3
4
5
6
7
8
9
10
11
12
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size    = 100,              # 청크 최대 길이 (문자 수)
    chunk_overlap = 20,               # 청크 간 중복 길이 (문맥 보존용)
    separators    = ["\n\n", "\n", ". ", " ", ""]  # 분할 우선순위
)

chunks = splitter.split_text(long_text)
print(f"분할된 청크 수: {len(chunks)}")
for i, chunk in enumerate(chunks):
    print(f"청크 {i+1} ({len(chunk)}자): {chunk[:50]}...")

separators 우선순위:

  1. “\n\n” (빈 줄) 로 먼저 자름
  2. 안되면 “\n” (줄바꿈)
  3. 안되면 “. “ (문장 끝)
  4. 안되면 “ “ (단어 경계)
  5. 마지막엔 강제로 문자 단위 분할

chunk_overlap (중복):

  • 청크 사이에 겹치는 부분을 만들어 문맥 단절 방지
  • 예: 청크1 끝 20자 = 청크2 시작 20자 (같은 내용)

chunk_size와 chunk_overlap 조절 효과

chunk_size 크게 + chunk_overlap 작게 → 청크 수 적음, 청크당 내용 많음 chunk_size 작게 + chunk_overlap 크게 → 청크 수 많음, 문맥 보존 강화


6. from_tiktoken_encoder - 토큰 수 기준 분할

왜 토큰 기준 분할이 필요한가?

  • 한국어는 1글자 ≈ 2토큰 → 문자 수 기준이 부정확
  • 토큰 수 기준으로 분할하면 API 비용 예측이 정확

사용 예시

1
2
3
4
5
6
7
splitter_tiktoken = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    model_name    = 'gpt-4o-mini',
    chunk_size    = 50,   # 청크 최대 토큰 수
    chunk_overlap = 10
)

chunks = splitter_tiktoken.split_text(long_text)

문자 수가 아닌 토큰 수 기준으로 분할 한국어 텍스트에서 더 정확한 분할 가능

문자 기준 vs 토큰 기준 비교

방법기준한국어 적합도
RecursiveCharacterTextSplitter문자 수보통
from_tiktoken_encoder토큰 수좋음 (정확한 비용 예측)

7. 청킹 리포트 함수

분할 결과를 분석하는 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np

def split_and_report(text, chunk_size=50, chunk_overlap=10):
    splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
        model_name    = 'gpt-4o-mini',
        chunk_size    = chunk_size,
        chunk_overlap = chunk_overlap
    )
    chunks       = splitter.split_text(text)
    token_counts = [count_token(c) for c in chunks]

    print(f"청킹 설정: chunk_size={chunk_size}, chunk_overlap={chunk_overlap}")
    print("-" * 60)
    for i, (chunk, tc) in enumerate(zip(chunks, token_counts)):
        first_line = chunk.split('\n')[0][:40]
        print(f"[{i+1}] {tc} tokens, {len(chunk)} characters | {first_line}")

    print("-" * 60)
    print(f"{len(chunks)}개 청크")
    print(f"평균 토큰: {np.mean(token_counts):.2f}  최대: {max(token_counts)}")

실행 예시

1
2
3
4
5
6
7
split_and_report(long_text, chunk_size=50, chunk_overlap=10)
# 청킹 설정: chunk_size=50, chunk_overlap=10
# [1] 47 tokens, 95 characters | 파이썬은 1991년 귀도 반 로섬이 만든 프로...
# [2] 38 tokens, 76 characters | 풍부한 라이브러리 생태계와 간결한 문법으...
# ...
# 총 7개 청크
# 평균 토큰: 41.43  최대: 47

한국어 연습 텍스트 (in-class 실습)

1
2
3
4
5
6
7
8
9
10
test_text = """서울은 대한민국의 수도이자 최대 도시입니다. 한강을 중심으로 강남과 강북으로 나뉘며, 약 1000만 명의 인구가 거주하고 있습니다.

서울에는 경복궁, 창덕궁 등 조선시대 궁궐과 N서울타워, 롯데월드타워 등 현대적 랜드마크가 공존합니다.

교통 면에서 서울은 세계적으로 우수한 대중교통 시스템을 갖추고 있습니다. 지하철 9개 노선과 수천 대의 버스가 시민들의 이동을 돕고 있습니다."""

# 한국어 test_text를 chunking 해보세요. 다양한 chunk size, overlap size를 이용
split_and_report(test_text, chunk_size=30, chunk_overlap=5)
split_and_report(test_text, chunk_size=50, chunk_overlap=10)
split_and_report(test_text, chunk_size=100, chunk_overlap=20)

8. 코사인 유사도 (Cosine Similarity)

코사인 유사도란?

  • 두 벡터 사이의 각도를 이용한 유사도 측정
  • 범위: -1 ~ 1 (1에 가까울수록 유사, 0에 가까울수록 관련 없음)

    벡터 A와 B의 코사인 유사도 = (A · B) / ( A × B )

    = A와 B 사이의 각도 cos(θ) θ = 0° → cos = 1.0 (같은 방향, 완전 동일) θ = 90° → cos = 0.0 (직각, 무관) θ = 180°→ cos = -1.0 (반대 방향)

실습 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
from sklearn.metrics.pairwise import cosine_similarity

texts = [
    "AI가 세상을 바꾸고 있습니다",
    "AI가 의료분야를 혁신하고 있다",
    "머신러닝으로 질병을 예측할 수 있다",
    "오늘 저녁에 치킨을 시켜먹었다"
]

doc_vectors = embeddings_model.embed_documents(texts)
sim_matrix  = cosine_similarity(doc_vectors)

print(sim_matrix)

유사도 행렬 결과 예시

 AI바꿈AI의료머신러닝치킨
AI바꿈1.000.460.150.13
AI의료0.461.000.270.16
머신러닝0.150.271.000.10
치킨0.130.160.101.00

“AI 세상” ↔ “AI 의료” : 0.46 → 같은 AI 주제, 높은 유사도 “AI 세상” ↔ “치킨” : 0.13 → 전혀 다른 주제, 낮은 유사도 대각선은 자기 자신과의 유사도 → 항상 1.00


9. 쿼리 vs 문서 유사도 검색

검색 원리

1
2
3
4
사용자 질문(쿼리) → 임베딩 → 쿼리 벡터
문서들           → 임베딩 → 문서 벡터들
쿼리 벡터와 각 문서 벡터의 코사인 유사도 계산
→ 유사도 높은 순서로 정렬 → 상위 k개 반환

코드

1
2
3
4
5
6
7
8
9
10
11
12
query = "인공지능이 세상을 바꾸고 있습니다"
query_vector = embeddings_model.embed_query(query)

all_vectors = [query_vector] + doc_vectors
all_texts   = [query] + texts

similarity_matrix = cosine_similarity(all_vectors)

import pandas as pd
labels = ["query"] + [f"doc{i+1}" for i in range(len(texts))]
df = pd.DataFrame(similarity_matrix, index=labels, columns=labels)
print(df)

결과 예시

1
2
3
# query ↔ doc1("AI가 세상을 바꾸고 있습니다") = 0.77   ← 가장 유사
# query ↔ doc2("AI가 의료분야...")            = 0.46
# query ↔ doc4("치킨")                       = 0.13   ← 가장 다름

RAG에서 쿼리와 가장 유사한 문서 k개를 검색해서 LLM에게 전달하는 것이 이 원리


10. 임베딩 기반 텍스트 분류 (Zero-shot)

아이디어

  • 카테고리별 예시 문장들의 임베딩 평균 벡터 = 카테고리 대표 벡터
  • 입력 텍스트와 각 카테고리 대표 벡터의 유사도 비교 → 가장 높은 카테고리로 분류
  • 별도 분류 모델 학습 없이 임베딩만으로 분류 가능 (Zero-shot)

코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
categories = {
    "기술"  : ["인공지능과 머신러닝이 산업을 혁신하고 있다",
                "클라우드 서비스가 기업의 디지털 전환을 가속화한다"],
    "스포츠": ["프로야구 시즌이 시작되어 팬들이 열광하고 있다",
                "올림픽에서 한국 선수가 금메달을 획득했다"],
    "음식"  : ["이 레스토랑의 파스타가 정말 맛있었다",
                "한국의 김치는 세계적으로 유명한 발효 식품이다"],
}

def classify(text, categories):
    # 1. 각 카테고리의 대표 벡터 계산 (예시 문장들의 평균)
    cat_vectors = {}
    for cat, examples in categories.items():
        embs = embeddings_model.embed_documents(examples)
        cat_vectors[cat] = np.mean(embs, axis=0)   # 평균 벡터

    # 2. 입력 텍스트 임베딩
    text_emb = embeddings_model.embed_query(text)

    # 3. 각 카테고리와의 유사도 계산
    scores = {}
    for cat, cat_vec in cat_vectors.items():
        sim = cosine_similarity([text_emb], [cat_vec])[0][0]
        scores[cat] = round(sim, 4)

    # 4. 가장 높은 유사도 카테고리 선택
    best_cat = max(scores, key=scores.get)
    return best_cat, scores

분류 결과 예시

1
2
3
4
5
6
7
text, scores = classify("새로운 GPU가 출시되어 AI 학습속도가 빨라졌습니다", categories)
# 기술: 0.3836  ← 가장 높음!  스포츠: 0.2215  음식: 0.1105
# → 분류: "기술"

text, scores = classify("이 식당 불고기가 정말 맛있다", categories)
# 기술: 0.1340  스포츠: 0.1711  음식: 0.4692  ← 가장 높음!
# → 분류: "음식"

11. CacheBackedEmbeddings - 임베딩 캐시

문제 상황

  • 같은 텍스트를 임베딩할 때마다 API 호출 → 비용 낭비 + 느림
  • RAG 시스템에서 문서들을 반복해서 임베딩하면 비용 폭발

해결: CacheBackedEmbeddings

1
2
3
4
5
6
7
8
9
10
from langchain_classic.embeddings import CacheBackedEmbeddings
from langchain_core.stores import InMemoryByteStore

store = InMemoryByteStore()   # 메모리 캐시 스토어

cached_embeddings = CacheBackedEmbeddings.from_bytes_store(
    embeddings_model,
    store,
    namespace = 'embedding-cache'  # 캐시 키 접두사
)

효과 확인

1
2
3
4
5
6
7
8
9
10
11
12
13
import time

texts = ["오늘 점심에는 라면을 먹었습니다", "내일 저녁에는 햄버거를 먹을겁니다"]

# 1차 요청: API 호출
start = time.time()
vecs1 = cached_embeddings.embed_documents(texts)
print(f"1차 call: {time.time()-start:.4f}")   # 약 0.42초

# 2차 요청: 캐시에서 반환
start = time.time()
vecs2 = cached_embeddings.embed_documents(texts)
print(f"2차 call: {time.time()-start:.4f}")   # 약 0.001초 (100배 이상 빠름!)

InMemoryByteStore : 메모리에 캐시 → 프로세스 종료 시 삭제 namespace : 캐시 키 충돌 방지 같은 텍스트 재요청 시 API 호출 없이 캐시에서 즉시 반환


12. 핵심 개념 총정리

개념핵심 내용
임베딩텍스트 → 1536차원 float 벡터 변환
embed_query()단일 텍스트 임베딩 (쿼리용)
embed_documents()여러 텍스트 임베딩 (문서용)
tiktoken토큰 분리 및 수 계산 (비용 산정)
한국어 토큰1글자 ≈ 1~2토큰 (영어보다 비용 높음)
Chunking긴 문서를 chunk_size/overlap으로 분할
문자 기준RecursiveCharacterTextSplitter
토큰 기준from_tiktoken_encoder
코사인 유사도벡터 각도로 유사도 측정 (0~1, 높을수록 유사)
Zero-shot 분류카테고리 대표 벡터와 비교 → 분류
CacheBackedEmbeddings중복 API 호출 방지 캐시

RAG 관점에서 오늘 배운 것의 위치

1
2
3
4
5
6
7
문서들
  ↓ [임베딩 - 오늘 배움]
벡터스토어
  ↓ [코사인 유사도 - 오늘 배움]
관련 문서 검색
  ↓ [다음 강의]
LLM에 전달 → 답변

13. 자주 나오는 실수 / 주의사항

실수 1: embed_query vs embed_documents 혼용

1
2
3
4
5
6
7
8
9
10
11
12
# embed_query → 벡터 1개 반환 (리스트)
single_vec = embeddings_model.embed_query("Hello")
print(len(single_vec))      # 1536  (벡터 하나)

# embed_documents → 벡터 리스트 반환 (리스트의 리스트)
multi_vecs = embeddings_model.embed_documents(["Hello", "World"])
print(len(multi_vecs))      # 2  (벡터 2개)
print(len(multi_vecs[0]))   # 1536  (각 벡터)

# cosine_similarity에서:
# embed_query 결과는 [vec] 로 감싸야 함
cosine_similarity([single_vec], multi_vecs)   # ← [  ] 필요!

실수 2: chunk_overlap > chunk_size

1
2
3
4
5
# overlap이 size보다 크면 에러!
splitter = RecursiveCharacterTextSplitter(
    chunk_size    = 50,
    chunk_overlap = 100   # ValueError! overlap은 size보다 작아야 함
)

실수 3: CacheBackedEmbeddings namespace 빠뜨리기

1
2
3
4
5
# namespace 없으면 다른 캐시와 충돌 가능
# → 항상 namespace 지정 권장
cached_embeddings = CacheBackedEmbeddings.from_bytes_store(
    model, store, namespace='my-project-embeddings'   # ← 명시
)

실수 4: 코사인 유사도와 FAISS 점수 방향 혼동

1
2
3
4
# 코사인 유사도: 높을수록 유사 (1에 가까울수록)
# FAISS L2 거리: 낮을수록 유사 (0에 가까울수록)

# 서로 반대 방향! 헷갈리지 않도록 주의

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그