문서를 로드하고, 자르고, 저장하고, 검색하는 일반적인 RAG 개발 과정에서 필요한 개념을 학습한다. 우리가 보편적으로 사용하는 LLM은 사전 학습단계를 거쳐 만들어진 그야말로 범용 모델이다. 우리가 쓰는 언어는 텍스트로 표현된다. 언어는 비정형데이터로서 컴퓨터가 이해하기 힘들다. 그렇기 때문에 텍스트를 일종의 단어사전(맵핑지도)을 통해 수치화 하는 과정이 필요한데, 여기서 필요한것이 토크나이저 이다.
토크나이저는 내부 규칙에 의해서 텍스트를 자르고, 이것을 숫자로 변환한다. 이 토크나이저로 자르고 변환하는 규칙은 각각의 토크나이저별로 다르기 때문에, 반드시 LLM과 세트로 써야한다. 예를들어 BERT 모델을 쓰면서 OPENAPI의 토크나이저를 쓰면 바보가 된다. 학습시 사용했던 토크나이저 방식으로 적용해야한다.
"토크나이저와 임베딩모델은 뭔 차이?"
1. 토크나이저 (Tokenizer): "단어에 부여하는 단순한 '고유번호'"
텍스트를 적당한 크기(Token)로 쪼개고, 각 조각에 고유한 정수 ID(Index 번호)를 매겨주는 작업이다. 토크나이저는 그저 사전에 등록된 순서대로 번호를 붙일 뿐이다. 번호만 봐서는 103번(사과)과 904번(바나나)이 비슷한 과일인지, 22번(자동차)이 전혀 다른 물건인지 기계는 절대 알 수 없다. 즉, 의미를 파악할 수 없다.
- 토크나이저 거친 후: [103], [904], [22]
2. 임베딩 모델 (Embedding Model): "단어의 의미를 담은 '좌표'"
'토크나이저'가 만든 정수ID들을 입력으로 받아서, 그 단어가 가진 '의미(Context)'를 다차원 공간의 실수 벡터(좌표)로 변환해 주는 모델이다. 이 벡터 좌표에는 '깊은 의미와 관계'가 담겨 있다.
- 임베딩 거친 후: 사과 [0.9, 0.1, -0.2], 자동차 [-0.9, 0.8, 0.9]
우리가 보기에는 그냥 숫자일 뿐, 의미를 파악할 수 없다. 이 둘은 경쟁하거나 선택하는 관계가 아니라, 순서대로 이어지는 하나의 파이프라인이다.
"사과를 먹는다"를 입력하면 텍스트가 토크나이저를 거쳐 [103, 15, 88] 같은 정수 행렬이 되고, 임베딩 모델을 거쳐 실수 벡터 좌표로 변환되어 벡터 데이터베이스에 들어가게 된다.
🛠️ 토크나이저 및 임베딩 테스트 코드
# 필요한 라이브러리 설치: pip install openai transformers torch
from transformers import AutoTokenizer
from openai import OpenAI
import os
from dotenv import load_dotenv
load_dotenv()
# 1. 토크나이저 테스트 (HuggingFace Open Source 기준)
tokenizer = AutoTokenizer.from_pretrained("bert-base-multilingual-cased")
text = "사과를 먹는다"
tokens = tokenizer.tokenize(text)
input_ids = tokenizer.convert_tokens_to_ids(tokens)
print(f"--- [Step 1: Tokenizer] ---")
print(f"원본 텍스트: {text}")
print(f"쪼개진 토큰: {tokens}")
print(f"정수 인덱스(ID): {input_ids}\n")
# 2. 임베딩 모델 테스트 (OpenAI API 기준)
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
response = client.embeddings.create(
input=text,
model="text-embedding-3-small"
)
embedding_vector = response.data[0].embedding
print(f"--- [Step 2: Embedding] ---")
print(f"임베딩 벡터 차원 수: {len(embedding_vector)}")
print(f"벡터 샘플(앞 5개): {embedding_vector[:5]}")
"그럼 임베딩 모델과 LLM 모델은 상관없이 다르게 써도 되나?"
결론부터 말하면, 완벽하게 독립적이다. 전혀 상관없다.
OpenAI의 임베딩 모델로 문서를 검색하고, 찾아낸 문서를 바탕으로 다른 LLM모델이 답변을 생성하게 하는 것은 완전히 가능하며, 실제로 현업 RAG 아키텍처에서 아주 흔하게 쓰이는 디커플링(Decoupling) 패턴이다. 헷갈릴 수 있지만, LLM과 임베딩모델은 세트가 아니다.
내부 사정 : "순수 텍스트(Plain Text)로 전달한다"
임베딩 모델과 생성 모델(LLM/SLM)이 만나는 지점에는 '벡터(숫자)'가 오가지 않는다. 오직 '사람이 읽을 수 있는 글자'만 오간다. 임베딩 모델이 질문을 벡터로 바꾼 뒤 DB를 서칭하면, Vector DB가 가장 유사도 높은 문서 조각을 찾아낸다. 이때 꺼내는 것은 벡터 숫자가 아니라, 원래 저장되어 있던 순수 텍스트다.
Q. "어떻게 숫자로 검색했는데, 텍스트가 나오는가?"
일반적으로 Vector DB의 저장 방식은 "라벨과 내용물을 같이 넣는다". 처음 문서를 DB에 넣을 때, 내부에는 사실 여러 데이터가 한 쌍(Row)으로 묶여서 들어간다.
- ID: doc_001 (문서 고유 번호)
- Vector: [0.12, -0.55, 0.89 ...] (임베딩 모델이 만들어준 숫자 좌표)
- Document: "제3조: 법인카드 식대 한도는 3만 원이다." (원본 텍스트, Payload)
- Metadata: {"부서": "영업부", "연도": 2024} (필터용)
쉽게 말해 숫자를 보고 해당하는 문장을 맵핑해서 리턴한다.
"저장된 Payload 상에 있는 텍스트를 직접 조회할 수 있나?"
가능하다. 굳이 AI나 임베딩 모델을 거치지 않고도, 전통적인 데이터베이스(RDB)처럼 저장된 텍스트(Payload)를 마음대로 조회하고 필터링할 수 있다.
- AI 검색용 .query(): 임베딩 모델이 작동하여 수학적 거리를 계산한다. (벡터 기반 유사도 검색)
- 관리자 조회용 .get(): 벡터 계산을 전혀 하지 않고 내부의 SQLite를 이용해 일반적인 SQL의 SELECT 문법처럼 조건에 맞는 텍스트와 메타데이터만 뽑아온다. (조건 기반 정확도 검색)
🛠️ Vector DB Payload 및 조회 테스트 코드
import chromadb
# 1. 크로마 DB 클라이언트 설정 (메모리 모드)
client = chromadb.Client()
collection = client.create_collection(name="office_rules")
# 2. 데이터 삽입 (ID, Vector, Document, Metadata 한 쌍)
collection.add(
ids=["doc_001", "doc_002"],
embeddings=[[0.1, 0.2, 0.3], [0.7, 0.8, 0.9]],
metadatas=[{"category": "식대", "year": 2024}, {"category": "휴가", "year": 2024}],
documents=["제3조: 법인카드 식대 한도는 3만 원이다.", "제5조: 연차 휴가는 15일이다."]
)
print("=== [Data Stored Successfully] ===\n")
# 🔍 Case 1: AI 검색 (.query) -> 벡터 유사도 기반
search_res = collection.query(
query_embeddings=[[0.12, 0.21, 0.33]],
n_results=1
)
print(f"--- [1. AI Query Result] ---")
print(f"검색된 본문: {search_res['documents'][0][0]}")
print(f"메타데이터: {search_res['metadatas'][0][0]}\n")
# 🛠️ Case 2: 관리자 조회 (.get) -> 메타데이터 필터 기반
get_res = collection.get(
where={"category": "휴가"}
)
print(f"--- [2. Admin Get Result] ---")
print(f"필터링된 본문: {get_res['documents'][0]}")
print(f"연결된 ID: {get_res['ids'][0]}")