로컬 임베딩 모델로 RAG 비용 0원 만들기 — Sentence Transformers 실전 가이드
관리형 벡터 데이터베이스 월 구독료가 부담스러웠습니다. 무료 티어 한도를 넘기면 유료 구간으로 전환되는데, 제 개인 프로젝트에 매달 고정비가 나가는 게 아까워서 로컬 방식을 찾기 시작했어요.
로컬 임베딩 모델과 SQLite를 조합하니 월 비용이 0원으로 떨어졌습니다. 이 글을 읽고 나면 외부 서비스 없이 자신의 컴퓨터에서 벡터 검색 파이프라인을 직접 돌릴 수 있게 됩니다.
준비물
실습에 필요한 것은 세 가지입니다.
- Python 3.8 이상 환경
- 약 500MB 디스크 공간 (모델 파일 + 인덱스)
- 샘플 텍스트 문서 5~10개 (txt, md, pdf 무관)
라이브러리는 두 개만 설치하면 됩니다. sentence-transformers가 임베딩 모델을 돌리고, numpy로 벡터 간 거리를 계산합니다. SQLite는 Python 표준 라이브러리에 포함되어 있어서 별도 설치가 필요 없어요.
pip install sentence-transformers numpy
모델은 intfloat/multilingual-e5-base를 쓸 겁니다. 한국어·영어·일본어 등 100개 언어를 지원하는 다국어 모델이라서 한글 문서를 그대로 넣어도 의미를 잘 잡아냅니다.
로컬 임베딩 모델은 어떻게 설치하나요?
첫 실행 시 모델 파일을 자동으로 다운로드합니다. 코드를 작성해 볼게요.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('intfloat/multilingual-e5-base')
print("모델 로드 완료")
이 코드를 실행하면 Hugging Face 허브에서 모델 가중치를 내려받습니다. 제 경우 Wi-Fi 속도가 괜찮아서 2분 정도 걸렸어요. 파일 크기가 약 470MB입니다. 다운로드된 파일은 ~/.cache/torch/sentence_transformers/ 아래 저장되고, 다음부터는 캐시를 씁니다.
모델이 제대로 들어왔는지 확인하려면 짧은 문장 하나를 임베딩해 보세요.
embedding = model.encode("테스트 문장입니다")
print(embedding.shape) # (768,) 출력
768차원 벡터가 출력되면 성공입니다. 이 숫자가 문장의 의미를 수치로 압축한 결과예요.
문서를 벡터로 어떻게 변환하죠?
샘플 문서 몇 개를 준비했다고 가정하겠습니다. 저는 제 블로그 포스트 10편을 마크다운 파일로 긁어왔어요.
문서를 청크 단위로 쪼갠 뒤 각 청크를 벡터로 바꿉니다. 청크 크기는 512자 정도가 적당했습니다. 저는 처음에 256자로 잘랐더니 검색할 때 문맥이 부족해서 엉뚱한 결과가 나왔어요. 1024자로 늘렸더니 이번엔 너무 긴 청크에 여러 주제가 섞여서 정확도가 떨어졌습니다. 512자가 제 문서 스타일에선 가장 균형 잡힌 값이더라고요.
import os
def split_chunks(text, chunk_size=512):
chunks = []
for i in range(0, len(text), chunk_size):
chunks.append(text[i:i+chunk_size])
return chunks
docs = []
for filename in os.listdir("./documents"):
with open(f"./documents/{filename}", "r", encoding="utf-8") as f:
content = f.read()
chunks = split_chunks(content)
docs.extend(chunks)
print(f"총 {len(docs)}개 청크 생성됨")
제 문서 10편이 평균 3000자씩이라서 총 62개 청크가 나왔습니다. 이제 이걸 임베딩으로 변환합니다.
embeddings = model.encode(docs, show_progress_bar=True)
print(embeddings.shape) # (62, 768) 출력
62개 청크가 각각 768차원 벡터로 바뀌었어요. 제 노트북(i5 CPU)에서 이 작업이 8초 걸렸습니다. GPU가 있으면 더 빠르겠지만, CPU만으로도 실용적인 속도예요. 메모리 사용량은 200MB 정도 늘었는데, 이 정도면 구형 노트북에서도 무리 없이 돌아갑니다.
SQLite에 벡터 인덱스 저장하기
인코딩한 배열을 영속 저장소에 써야 다음에 다시 쓸 수 있겠죠. NumPy 배열을 바이너리로 직렬화해서 SQLite 테이블에 넣습니다.
테이블 스키마를 설계할 때 vector 컬럼을 BLOB으로 정의했습니다. TEXT로 JSON 직렬화하는 방법도 있지만, 768개 float32 숫자를 문자열로 바꾸면 용량이 3배 가까이 불어나더라고요.
import sqlite3
import numpy as np
conn = sqlite3.connect("vector_index.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS embeddings (
id INTEGER PRIMARY KEY,
text TEXT,
vector BLOB
)
""")
for i, (doc, emb) in enumerate(zip(docs, embeddings)):
vector_blob = emb.astype(np.float32).tobytes()
cursor.execute("INSERT INTO embeddings (text, vector) VALUES (?, ?)",
(doc, vector_blob))
conn.commit()
conn.close()
print("인덱스 저장 완료")
저장된 vector_index.db 파일 크기가 240KB였습니다. 62개 청크니까 청크당 약 4KB 정도 차지하네요. 문서가 늘어나면 비례해서 커지겠지만, 1만 개 청크라도 40MB 안팎이라 로컬 디스크 부담은 거의 없어요.
배치 삽입으로 속도를 더 올릴 수도 있습니다. 위 코드는 개별 INSERT 62번이라 1.2초 걸렸는데, executemany()로 바꾸니 0.3초로 줄었어요. 청크가 수천 개 넘어가면 배치 방식이 필수입니다.
직렬화 방식도 선택지가 있습니다. pickle로 통째로 저장하는 방법도 있지만, tobytes()가 더 가볍습니다. 저는 나중에 Node.js에서 같은 DB를 읽을 일이 있어서 pickle 대신 tobytes를 택했어요.
검색 쿼리 실행하고 결과 확인
이제 질문을 던져서 가장 관련 높은 청크를 찾아볼게요. 앞 단계처럼 DB를 열고, 쿼리 문장을 같은 모델로 인코딩합니다.
query = "RAG 파이프라인을 로컬에서 구축하는 방법"
q_vec = model.encode([query])[0]
db = sqlite3.connect("vector_index.db")
all_rows = db.execute("SELECT id, text, vector FROM embeddings").fetchall()
matches = []
for row_id, chunk, blob in all_rows:
stored_vec = np.frombuffer(blob, dtype=np.float32)
cosine_sim = np.dot(q_vec, stored_vec) / (np.linalg.norm(q_vec) * np.linalg.norm(stored_vec))
matches.append((row_id, chunk, cosine_sim))
matches.sort(key=lambda x: x[2], reverse=True)
for i, (_, chunk, score) in enumerate(matches[:3], 1):
print(f"{i}위 (유사도 {score:.3f}): {chunk[:100]}...")
제 경우 62개 청크 전체를 탐색하는 데 0.03초가 걸렸습니다. 상위 3개 결과가 실제로 RAG 관련 문단이더라고요.
유사도 점수 임계값은 실험으로 정했습니다. 제 데이터셋에서 0.7 이상 나온 청크는 대부분 정답이었고, 0.5~0.7 구간은 애매했어요. 0.5 미만은 거의 무관한 내용이었습니다. 그래서 0.7을 컷오프로 잡았습니다.
거짓양성을 줄이려면 상위 K개를 늘려서 재랭킹하는 방법도 있습니다. 저는 top 10을 먼저 뽑은 뒤, 쿼리와의 단어 겹침도를 추가로 계산해서 최종 3개로 좁혔어요. 이렇게 하니 엉뚱한 결과가 거의 사라졌습니다.
청크 수가 수천 개로 늘어나면 전수 탐색 대신 FAISS나 Annoy 같은 근사 최근접 이웃 라이브러리를 추가해야 합니다. 하지만 수백 개 규모라면 SQLite 전수 탐색만으로 충분히 빠릅니다. 제 노트북에서 500개 청크도 0.1초 안에 끝났거든요.
흔한 실수와 해결법
처음 돌렸을 때 검색 결과가 엉뚱하게 나왔어요. 원인은 청크 경계를 무작정 자른 탓이었습니다. 문장 중간에서 잘리니까 문맥이 깨지더라고요. 해결책은 문장 단위로 자르는 겁니다. nltk나 kss 같은 문장 분리기를 쓰면 됩니다. 저는 kss.split_sentences()로 바꿨더니 유사도 점수가 0.1 정도 올라갔어요.
두 번째 함정은 한국어 문서인데 영어 전용 모델을 썼던 겁니다. all-MiniLM-L6-v2 모델로 한글을 임베딩했더니 유사도가 0.2~0.3밖에 안 나왔어요. 다국어 모델로 바꾸니까 0.7 이상으로 올라갔습니다. 한국어 문서라면 반드시 multilingual 모델을 선택하세요.
세 번째는 벡터를 float64로 저장해서 DB 파일이 두 배로 커진 경우예요. float32로 변환(astype(np.float32))하면 정확도 손실 없이 용량을 절반으로 줄일 수 있습니다. 저는 이것 때문에 480KB짜리 DB가 240KB로 줄었거든요.
다음 단계
이 구조를 FastAPI나 Flask로 감싸면 REST API 검색 서버로 만들 수 있어요. 제 경우 문서 100개짜리 인덱스를 Docker 컨테이너에 넣어서 사내 검색봇으로 쓰고 있습니다. 외부 서비스 의존성이 없으니 네트워크 끊겨도 계속 돌아가더라고요.
벡터 검색을 처음 시작한다면 관리형 서비스보다 로컬 모델로 감을 잡는 게 낫습니다. 비용 부담 없이 여러 모델을 바꿔가며 실험할 수 있거든요.