목차
- 문제와 접근
- 임베딩(Embedding) 개념
- OpenAIEmbeddings - 텍스트를 벡터로 변환
- tiktoken - 토크나이저
- 텍스트 분할 (Chunking) - RecursiveCharacterTextSplitter
- from_tiktoken_encoder - 토큰 수 기준 분할
- 청킹 리포트 함수
- 코사인 유사도 (Cosine Similarity)
- 쿼리 vs 문서 유사도 검색
- 임베딩 기반 텍스트 분류 (Zero-shot)
- CacheBackedEmbeddings - 임베딩 캐시
- 핵심 개념 총정리
- 자주 나오는 실수 / 주의사항
- 검색 품질의 출발점은 문서 단위
1. 문제와 접근
임베딩을 사용하는 것만으로 검색 단위가 정해지는 것은 아니다. 어떤 길이와 경계로 문서를 나누고 어떤 정보와 함께 저장하는지가 검색 결과를 해석하는 기준이 된다. 텍스트 분할, 벡터 생성, 유사도 계산을 분리해 각 단계가 결과에 미치는 영향을 살펴본다.
임베딩이 왜 필요한가?
컴퓨터는 텍스트를 직접 비교할 수 없다. “재택근무 규정” == “재택근무 정책” → 컴퓨터: 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-small | 1536 | 빠르고 저렴, 일반 사용에 적합 |
| text-embedding-3-large | 3072 | 정확도 높음, 비용 더 높음 |
| text-embedding-ada-002 | 1536 | 구버전 (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 우선순위:
- “\n\n” (빈 줄) 로 먼저 자름
- 안되면 “\n” (줄바꿈)
- 안되면 “. “ (문장 끝)
- 안되면 “ “ (단어 경계)
- 마지막엔 강제로 문자 단위 분할
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.00 | 0.46 | 0.15 | 0.13 |
| AI의료 | 0.46 | 1.00 | 0.27 | 0.16 |
| 머신러닝 | 0.15 | 0.27 | 1.00 | 0.10 |
| 치킨 | 0.13 | 0.16 | 0.10 | 1.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에 가까울수록)
# 서로 반대 방향! 헷갈리지 않도록 주의
|
14. 검색 품질의 출발점은 문서 단위
임베딩 모델을 교체하기 전에 검색된 청크를 직접 읽어 보는 순서가 필요하다. 제목만 남았거나 수치와 단위가 분리된 문서라면 모델을 바꾸어도 입력 단계의 문제가 남는다.
청킹 설정을 비교할 때는 같은 질문에 대해 정답 근거가 어느 청크에 들어 있는지 표시한다. 청크 수나 평균 길이와 함께 근거 보존 여부를 기록하면 검색 결과의 변화를 전처리 단계까지 거슬러 설명할 수 있다.