26장. 임베딩과 RAG, 내 문서로 답하게 하기
이 장의 목표 회사 위키, 사내 매뉴얼, 회의록 등 내 문서를 AI가 참고해서 답하게 만드는 기술, RAG 의 원리를 끝까지 이해합니다.
26.1 왜 RAG 인가
문제 상황.
"우리 회사의 휴가 정책 알려줘"
↓
모델: "일반적으로 회사들은 ..." (헛소리)
모델은 우리 회사 내부 문서를 본 적이 없습니다.
해결책 두 가지.
| 방법 | 한 줄 |
|---|---|
| 파인튜닝 (32장) | 모델 자체를 다시 학습 |
| RAG | 답할 때마다 필요한 문서를 찾아서 같이 줌 |
RAG가 압도적으로 흔한 이유:
- 문서가 자주 바뀌어도 즉시 반영
- 모델 학습 안 함 (시간·비용·하드웨어 절약)
- 출처 명시 가능
- 작은 모델로도 동작
26.2 RAG의 큰 그림
[사용자 질문]
│
▼
[질문을 벡터로 변환] ← 임베딩 모델
│
▼
[벡터 DB에서 비슷한 문서 찾기]
│
▼
[관련 문서 + 질문을 LLM에 같이 전달]
│
▼
[LLM이 그 문서 보면서 답]
이게 다입니다.
26.3 임베딩 — 의미를 숫자로
문장을 벡터 로 바꾸는 모델 (9장).
"휴가 정책 알려줘"
↓
[0.12, -0.45, 0.88, ..., 0.03]
비슷한 의미는 가까운 벡터 가 됩니다.
"휴가 정책 알려줘" → [0.12, -0.45, ...]
"휴가는 어떻게 신청해?" → [0.13, -0.41, ...] (가까움)
"점심 메뉴 추천해" → [-0.55, 0.66, ...] (멀음)
벡터 간 거리 또는 코사인 유사도 로 가까움을 잼.
26.4 대표 임베딩 모델
| 모델 | 특징 |
|---|---|
| BAAI/bge-m3 | 다국어, 가장 인기 |
| BAAI/bge-large-en | 영어 강함 |
| intfloat/e5-mistral-7b | 큰 임베딩, 품질↑ |
| nomic-embed-text | 빠름·작음 |
| jinaai/jina-embeddings-v3 | 다국어, 긴 컨텍스트 |
대부분 수백 MB ~ 2GB. LLM보다 훨씬 가볍습니다.
Ollama로 받기:
$ ollama pull bge-m3
$ ollama pull nomic-embed-text
26.5 임베딩 API
Ollama:
$ curl http://localhost:11434/api/embeddings -d '{
"model": "bge-m3",
"prompt": "휴가 정책 알려줘"
}'
{
"embedding": [0.123, -0.456, 0.789, ...]
}
OpenAI 호환 형식:
$ curl http://localhost:11434/v1/embeddings -d '{
"model": "bge-m3",
"input": "휴가 정책 알려줘"
}'
26.6 청킹(Chunking) — 문서를 잘게 자르기
큰 문서를 통째로 넣으면
- 임베딩이 부정확해지고
- 컨텍스트가 폭주합니다
→ 잘게 잘라서 각 조각의 벡터를 저장.
[원본 문서]
"인사 규정 ... (5,000자) ..."
↓ 청킹 (예: 500자 단위)
[조각 1] "인사 규정 ... 휴가는 ..."
[조각 2] "휴가 신청은 ... "
[조각 3] "병가는 ..."
...
청크 크기:
- 너무 작음(100자) → 맥락 부족
- 너무 큼(2000자) → 한 조각에 여러 주제 섞임
- 권장: 300~800자, 약간 겹치게(overlap)
26.7 벡터 DB — 청크를 저장·검색
수만 개 청크 벡터를 어떻게 빨리 검색?
→ 벡터 DB 가 해줍니다 (27장에서 본격).
작은 프로젝트면 그냥 SQLite/JSON도 OK.
이름 | 청크 텍스트 | 벡터
---------+-----------+--------------
chunk_001 | "휴가는 ..." | [0.12, ...]
chunk_002 | "병가는 ..." | [-0.34, ...]
...
질문이 오면:
- 질문도 임베딩 → 벡터
- DB에서 가장 가까운 N개 청크 찾기
- 그 청크들을 LLM 프롬프트에 첨부
26.8 RAG 프롬프트 패턴
청크를 찾은 다음 LLM에게 줄 프롬프트.
[시스템]
다음 "근거"만 보고 한국어로 답하세요.
근거에 없는 내용은 "확인 필요"라고 답하세요.
[근거]
[1] (chunk_023) "정직원의 연차는 1년 만근 시 15일이며..."
[2] (chunk_047) "휴가 신청은 사내 그룹웨어에서..."
[3] (chunk_005) "연차 사용은 부서장 승인 후..."
[질문]
정직원 연차는 몇 일이야?
[답변]
이렇게 주면 모델은 거의 환각 없이 답합니다.
핵심 규칙: “근거에 없으면 모른다고 답해” 한 줄이 환각을 막는 가장 큰 장벽.
26.9 가장 작은 RAG 코드 (Python)
from openai import OpenAI
import numpy as np
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# 1. 문서 청크
docs = [
"정직원의 연차는 1년 만근 시 15일이며...",
"휴가 신청은 사내 그룹웨어에서...",
"병가는 진단서가 필요하며...",
"사내 식사 보조비는...",
]
# 2. 모든 청크 임베딩
def embed(text):
r = client.embeddings.create(model="bge-m3", input=text)
return np.array(r.data[0].embedding)
doc_vecs = [embed(d) for d in docs]
# 3. 검색
def search(query, k=2):
qv = embed(query)
sims = [np.dot(qv, dv) / (np.linalg.norm(qv) * np.linalg.norm(dv)) for dv in doc_vecs]
idx = np.argsort(sims)[::-1][:k]
return [docs[i] for i in idx]
# 4. RAG 답변
def rag_answer(query):
chunks = search(query)
context = "\n".join(f"[{i+1}] {c}" for i, c in enumerate(chunks))
messages = [
{"role": "system", "content": "다음 근거만 보고 한국어로 답해. 없으면 '확인 필요'."},
{"role": "user", "content": f"[근거]\n{context}\n\n[질문]\n{query}"},
]
resp = client.chat.completions.create(model="qwen3:8b", messages=messages)
return resp.choices[0].message.content
print(rag_answer("정직원 연차는?"))
이 60줄이 RAG의 본질입니다.
26.10 RAG의 자주 만나는 함정
① 검색이 정확하지 않음
- 청크가 너무 큼 → 잘게 쪼개기
- 임베딩이 약함 → bge-m3 같은 다국어 강한 모델로
- 질문이 모호함 → Hybrid Search (키워드 + 벡터) 병행
② 모델이 근거 무시하고 학습 지식으로 답함
- 시스템 프롬프트를 더 강하게: “근거에 명시되지 않은 내용은 절대 추가하지 않음”
- 작은 모델일수록 자주 발생 → 14B 이상 권장
③ 같은 청크가 여러 번 반환됨
- 중복 제거 후 retrieval
④ “확인 필요“가 너무 자주
- top-k 를 2 → 4 정도로 늘림
- 청크 크기 조정
26.11 RAG 품질 끌어올리기 — 다음 단계
기본 RAG가 동작하기 시작하면 다음 단계로:
| 기법 | 설명 |
|---|---|
| Reranker | 1차 검색 결과를 reranker 모델로 재정렬 (9장) |
| Hybrid Search | 벡터 + BM25 키워드 검색 병합 |
| Query Rewriting | 질문을 검색 친화적으로 LLM이 다시 씀 |
| Multi-hop | 한 번 검색 → 부족하면 추가 검색 |
| Citation | 답변에 [1] [2] 같은 출처 표시 |
이 중 가장 효과 큰 건 Reranker. 다음 장에서 실제 도구와 함께 봅니다.
이 장에서 기억할 한 가지
RAG의 본질:
- 문서를 청크로 쪼개고 임베딩으로 저장
- 질문이 오면 비슷한 청크를 찾아서
- LLM에 “이 근거만 보고 답해” 라고 함께 전달
이게 사내 챗봇·문서 챗봇의 거의 모든 모습입니다.
손으로 해볼 것
1. 임베딩 모델 받기
$ ollama pull bge-m3
2. 임베딩 직접 호출
$ curl http://localhost:11434/v1/embeddings -d '{
"model": "bge-m3",
"input": "휴가 정책 알려줘"
}' | jq '.data[0].embedding | length'
벡터의 차원이 출력됩니다. (보통 1024)
3. 26.9 절 코드 실행
가상환경에 numpy 와 openai 설치 후
26.9 의 60줄 코드를 그대로 실행.
같은 질문을 RAG 없이 LLM 단독으로 시키고 응답 차이를 비교하세요.
다음 장에서는 진짜 벡터 DB — Chroma, Qdrant 같은 도구를 봅니다.
수천~수만 문서로 가도 무너지지 않는 구조가 보입니다.