LLM에게 모든 것을 외우게 할 필요는 없다
지금까지 우리는 LLM API를 호출하고, 프롬프트를 설계하고, 구조화 출력을 받고, Function Calling으로 외부 도구를 연결하는 방법을 살펴보았다. 이제 한 걸음 더 나아가 보자. 실제 서비스에서 가장 자주 마주치는 질문은 이것이다.
"우리 회사 문서를 읽고 답하게 하려면 모델을 새로 학습시켜야 할까?"
대부분의 경우 답은 "아니다"에 가깝다. 모델을 매번 다시 학습시키는 대신, 필요한 정보를 검색해서 프롬프트에 붙여 주면 된다. 이 방식이 뒤에서 본격적으로 다룰 RAG, 즉 Retrieval-Augmented Generation의 출발점이다. 한국어로 풀면 "검색으로 보강한 생성" 정도가 된다.
RAG의 핵심은 LLM에게 모든 것을 외우게 하는 것이 아니다. 필요한 순간에 필요한 정보를 찾아서, LLM이 그 정보를 보고 답하게 만드는 것이다. 마치 시험장에 모든 책을 암기한 사람을 데려가는 대신, 질문에 맞는 책의 관련 쪽을 빠르게 펼쳐 주는 것과 비슷하다.
그렇다면 어떻게 "관련 있는 문서"를 찾을 수 있을까? 전통적인 검색은 주로 키워드가 같은지를 본다. 사용자가 "구독 취소 방법"이라고 물었을 때 문서에 "계정 해지 절차"라고 적혀 있다면, 단순 키워드 검색은 놓칠 수 있다. 단어는 다르지만 의미는 가깝기 때문이다.
여기서 임베딩과 벡터 검색이 등장한다.
임베딩 API는 문장, 문서, 코드 조각 같은 텍스트를 숫자 벡터로 바꾼다. 이 벡터 공간에서는 의미가 비슷한 텍스트가 서로 가까운 위치에 놓인다. OpenAI의 임베딩 문서도 임베딩을 텍스트를 숫자로 바꾸어 검색, 군집화, 추천 등에 활용하는 방법으로 설명한다1. 이제 검색은 "같은 단어가 있나?"가 아니라 "의미상 가까운가?"라는 질문으로 바뀐다.

임베딩 API가 하는 일
앞에서 우리는 Transformer 내부의 임베딩을 배웠다. 토큰 ID가 임베딩 테이블을 지나 벡터가 되고, 그 벡터가 Attention과 Feed Forward Network를 통과했다. 이번 장에서 말하는 임베딩 API도 큰 방향은 비슷하다. 텍스트를 벡터로 바꾼다.
다만 목적이 조금 다르다. Transformer 내부의 토큰 임베딩은 모델이 다음 토큰을 예측하기 위해 사용하는 중간 표현이다. 반면 검색용 임베딩 API는 보통 문장이나 문서 조각 전체를 하나의 고정 길이 벡터로 바꾸는 데 사용한다.
예를 들어 다음 세 문장이 있다고 하자.
A. 비밀번호를 재설정하는 방법을 알려 주세요.
B. 로그인 암호를 잊어버렸을 때 복구하는 절차입니다.
C. 오늘 점심 메뉴는 김치찌개입니다.키워드만 보면 A와 B는 완전히 같지 않다. A에는 "비밀번호", "재설정"이 있고, B에는 "로그인 암호", "복구"가 있다. 하지만 의미는 매우 가깝다. 임베딩 모델은 이런 문장들을 벡터 공간에서 가까운 위치에 놓도록 학습되어 있다. 반면 C는 음식 이야기이므로 멀리 떨어질 가능성이 높다.
이 벡터는 보통 수백 개에서 수천 개의 실수로 이루어진다. 예를 들어 어떤 모델은 768차원 벡터를 만들고, 어떤 모델은 1536차원이나 3072차원 벡터를 만든다. 여기서 차원 수가 크다고 무조건 좋은 것은 아니다. 차원이 커지면 표현력은 늘 수 있지만 저장 비용, 검색 비용, 네트워크 전송량도 함께 늘어난다.
임베딩 벡터의 중요한 특징은 개별 숫자 하나하나를 사람이 해석하기 어렵다는 점이다. 벡터의 첫 번째 값이 "기쁨"이고 두 번째 값이 "기술"이라고 딱 잘라 말할 수 없다. 대신 벡터 전체가 하나의 의미 좌표처럼 작동한다. 그래서 우리는 벡터끼리의 거리나 유사도를 계산해 "어느 쪽이 더 가까운가"를 판단한다.
첫 번째 임베딩 호출
먼저 임베딩 API를 호출해 보자. 아래 예시는 OpenAI의 Python SDK를 사용하는 간단한 코드다. 실제 실행 전에는 환경 변수에 API 키를 설정해야 한다.
pip install openai numpy python-dotenvfrom openai import OpenAI
import numpy as np
client = OpenAI()
def embed_texts(texts, model='text-embedding-3-small'):
response = client.embeddings.create(
model=model,
input=texts,
)
vectors = [item.embedding for item in response.data]
return np.array(vectors, dtype=np.float32)
texts = [
'비밀번호를 재설정하는 방법을 알려 주세요.',
'로그인 암호를 잊어버렸을 때 복구하는 절차입니다.',
'오늘 점심 메뉴는 김치찌개입니다.',
]
embeddings = embed_texts(texts)
print(embeddings.shape)
print(embeddings[0][:5])출력은 모델에 따라 달라지지만, 대략 이런 형태가 된다.
(3, 1536)
[-0.0123 0.0041 0.0188 -0.0312 0.0097]여기서 (3, 1536)은 텍스트 3개가 각각 1536차원 벡터로 바뀌었다는 뜻이다. 모델에 따라 차원 수는 달라질 수 있으므로 코드에서 고정값으로 가정하기보다 len(vector) 또는 array.shape으로 확인하는 습관이 좋다.
실무에서는 텍스트를 하나씩 보내기보다 여러 개를 묶어서 보내는 편이 낫다. 문서 청크가 1만 개라면 API를 1만 번 호출하는 대신, 모델과 API 제한이 허용하는 범위에서 배치로 묶어 호출한다. 이렇게 하면 네트워크 왕복 비용과 지연 시간을 줄일 수 있다.
또 하나 중요한 점은 캐싱이다. 같은 문서 조각은 매번 같은 임베딩을 생성해도 결과가 사실상 동일하게 쓰인다. 문서가 바뀌지 않았다면 임베딩을 다시 만들 필요가 없다. 따라서 문서 ID, 내용 해시, 임베딩 모델 이름을 함께 저장해 두고, 내용이 바뀐 경우에만 새로 임베딩을 만드는 구조가 좋다.
유사도는 어떻게 계산할까
임베딩 벡터가 생겼다면 이제 벡터끼리 얼마나 가까운지 계산해야 한다. 대표적인 방법은 세 가지다.
Cosine similarity
Dot product
Euclidean distance
Cosine similarity는 두 벡터가 가리키는 방향이 얼마나 비슷한지를 본다. 벡터의 길이보다 각도에 집중한다. 문장 의미 검색에서는 가장 자주 등장하는 방식이다.
수식으로 쓰면 다음과 같다.
cosine_similarity(a, b) = (a · b) / (||a|| ||b||)여기서 a · b는 두 벡터의 dot product, 즉 내적이다. ||a||와 ||b||는 각 벡터의 길이다. 값이 클수록 방향이 비슷하고, 의미가 가깝다고 판단한다.
Dot product는 두 벡터의 각 성분을 곱해 모두 더한 값이다. 계산이 단순하고 빠르다. 만약 두 벡터가 길이 1이 되도록 정규화되어 있다면, dot product와 cosine similarity는 사실상 같은 순위를 만든다. 정규화란 벡터의 방향은 유지하되 길이를 1로 맞추는 작업이다.
Euclidean distance는 우리가 익숙하게 생각하는 직선거리다. 두 점이 공간에서 얼마나 떨어져 있는지를 계산한다. 이 경우에는 값이 작을수록 더 가깝다.
import numpy as np
def normalize(matrix):
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
return matrix / np.clip(norms, 1e-12, None)
def cosine_similarity(a, b):
a = np.array(a, dtype=np.float32)
b = np.array(b, dtype=np.float32)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def dot_product(a, b):
return float(np.dot(a, b))
def euclidean_distance(a, b):
return float(np.linalg.norm(np.array(a) - np.array(b)))실제 벡터 검색 시스템을 만들 때는 "어떤 계산법이 절대적으로 최고인가?"보다 "내가 쓰는 임베딩 모델과 벡터 저장소가 어떤 거리 함수를 권장하고 지원하는가?"가 더 중요하다. 어떤 모델은 이미 정규화된 벡터를 반환하고, 어떤 모델은 사용자가 직접 정규화해야 한다. 벡터 DB도 cosine, dot product, L2 distance를 모두 지원하기도 하고, 일부만 지원하기도 한다.
또 하나 주의할 점이 있다. 유사도 점수의 절댓값을 지나치게 믿으면 안 된다. 어떤 임베딩 모델은 전반적으로 점수가 높게 나오는 분포를 가질 수 있다. BAAI의 BGE 계열 문서에서도 특정 버전의 유사도 점수는 절댓값보다 상대적 순위가 더 중요하다고 설명한다2. 따라서 "0.8 이상이면 관련 있음" 같은 기준은 모델과 데이터셋을 바꿀 때마다 다시 점검해야 한다.
작은 의미 검색 엔진 만들기
이제 아주 작은 벡터 검색기를 직접 만들어 보자. 아직 Chroma, FAISS, OpenSearch 같은 벡터 저장소를 쓰지 않고, NumPy 배열만으로 구현한다. 원리는 단순하다.
문서를 임베딩한다. 사용자의 질문도 임베딩한다. 질문 벡터와 문서 벡터들의 유사도를 계산한다. 점수가 높은 문서를 반환한다.
from openai import OpenAI
import numpy as np
client = OpenAI()
def embed_texts(texts, model='text-embedding-3-small'):
response = client.embeddings.create(
model=model,
input=texts,
)
return np.array([item.embedding for item in response.data], dtype=np.float32)
def normalize(matrix):
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
return matrix / np.clip(norms, 1e-12, None)
documents = [
{
'id': 'doc-1',
'title': '비밀번호 재설정',
'text': '비밀번호를 잊어버린 경우 로그인 화면에서 비밀번호 찾기를 눌러 이메일 인증 후 새 비밀번호를 설정할 수 있습니다.',
},
{
'id': 'doc-2',
'title': '요금제 변경',
'text': '요금제는 설정 메뉴의 결제 관리 화면에서 변경할 수 있으며, 변경 사항은 다음 결제일부터 적용됩니다.',
},
{
'id': 'doc-3',
'title': '계정 삭제',
'text': '계정을 삭제하면 저장된 프로젝트와 결제 이력이 함께 삭제될 수 있습니다. 삭제 전 데이터를 백업하세요.',
},
{
'id': 'doc-4',
'title': '팀 초대',
'text': '관리자는 워크스페이스 설정에서 팀원을 이메일로 초대하고 역할을 관리자 또는 멤버로 지정할 수 있습니다.',
},
]
texts = [doc['text'] for doc in documents]
doc_vectors = normalize(embed_texts(texts))
def search(query, top_k=3):
query_vector = normalize(embed_texts([query]))[0]
scores = doc_vectors @ query_vector
ranked_indices = np.argsort(scores)[::-1][:top_k]
results = []
for index in ranked_indices:
doc = documents[index]
results.append({
'id': doc['id'],
'title': doc['title'],
'text': doc['text'],
'score': float(scores[index]),
})
return results
results = search('로그인 암호를 잊어버렸어요. 어떻게 복구하나요?', top_k=2)
for item in results:
print(item['score'], item['title'])키워드가 정확히 일치하지 않아도 "로그인 암호", "복구"라는 질문은 "비밀번호 재설정" 문서와 가까운 벡터를 만들 가능성이 높다. 이것이 의미 기반 검색의 힘이다.
물론 이 코드는 작은 예제일 뿐이다. 문서가 수백 개 정도라면 이런 방식으로도 실험할 수 있다. 하지만 문서가 수십만 개, 수백만 개가 되면 매번 모든 벡터와 비교하는 방식은 느려진다. 그때는 벡터 데이터베이스나 검색 엔진의 인덱스를 사용해야 한다.
문서를 검색 가능한 형태로 바꾸는 파이프라인
문서 기반 검색 시스템은 단순히 "문서 전체를 임베딩해서 저장"하면 끝나지 않는다. 긴 문서 하나를 통째로 하나의 벡터로 만들면, 세부 내용을 찾기 어렵다. 예를 들어 100쪽짜리 제품 매뉴얼 전체를 하나의 벡터로 만들면 "비밀번호 초기화"와 관련된 작은 단락이 전체 의미 속에 묻힐 수 있다.
그래서 문서를 적절한 단위로 나눈다. 이 작업을 청킹이라고 한다. chunk는 덩어리라는 뜻이다. RAG에서 청크는 검색과 프롬프트에 넣기 좋은 크기의 문서 조각이다.
일반적인 문서 검색 파이프라인은 다음 흐름을 따른다.
원문 수집
텍스트 정제와 청킹
각 청크의 임베딩 생성
벡터와 원문, 메타데이터 저장
사용자 질문 임베딩
top-k 유사도 검색
검색된 근거를 LLM 프롬프트에 삽입
Google Cloud의 Vertex AI 관련 글에서는 많은 데이터를 LLM에게 억지로 외우게 하는 대신, 임베딩과 벡터 검색을 외부 기억처럼 붙여 grounding하는 방식을 설명한다3. 여기서 grounding은 모델의 답변을 외부 근거에 연결한다는 뜻이다. "그럴듯한 말"을 하게 두는 것이 아니라, 검색된 문서를 보고 답하게 만드는 것이다.
간단한 청킹 함수를 만들어 보자. 실제 서비스에서는 토큰 기준으로 나누는 편이 더 정확하지만, 여기서는 이해를 위해 문자 수 기준으로 작성한다.
def chunk_text(text, max_chars=600, overlap=80):
chunks = []
start = 0
while start < len(text):
end = start + max_chars
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = end - overlap
if start < 0:
start = 0
if start >= len(text):
break
return chunks
raw_documents = [
{
'source': 'help-center',
'title': '계정 관리 가이드',
'text': '긴 문서 내용이 여기에 들어갑니다. 비밀번호 변경, 계정 삭제, 팀원 초대, 결제 관리 등의 설명이 포함됩니다.',
},
]
chunks = []
for doc_index, doc in enumerate(raw_documents):
for chunk_index, chunk in enumerate(chunk_text(doc['text'])):
chunks.append({
'id': f'doc-{doc_index}-chunk-{chunk_index}',
'source': doc['source'],
'title': doc['title'],
'chunk_index': chunk_index,
'text': chunk,
})청크에는 반드시 원문 텍스트만 저장하지 말고 메타데이터를 함께 저장해야 한다. 메타데이터는 검색 결과를 해석하고 필터링하는 데 큰 역할을 한다. 예를 들어 고객지원 챗봇이라면 문서 출처, 문서 버전, 제품명, 언어, 공개 여부, 작성일, 권한 범위 같은 정보가 필요하다.
질문이 들어왔을 때 "관리자 문서만 검색", "한국어 문서만 검색", "2026년 이후 문서만 검색", "현재 사용자가 접근 가능한 팀 문서만 검색" 같은 조건을 걸 수 있어야 한다. 벡터 검색은 의미적으로 가까운 문서를 찾는 데 강하지만, 권한이나 날짜 같은 명확한 조건은 메타데이터 필터가 더 잘 처리한다.
Microsoft의 RAG 가이드도 사용자 질문과 문서 청크를 같은 임베딩 모델로 벡터화한 뒤 관련 청크를 찾아 LLM에 grounding data로 전달하는 흐름을 설명한다4. 여기서 "같은 임베딩 모델"이라는 점이 중요하다. 문서 청크는 A 모델로 임베딩하고 질문은 B 모델로 임베딩하면 두 벡터가 같은 의미 공간에 있다고 보장하기 어렵다. 특별한 이유가 없다면 인덱싱과 검색에 같은 임베딩 모델을 사용하자.
검색 결과를 프롬프트에 붙이기
벡터 검색 자체는 답변을 생성하지 않는다. 검색은 관련 문서 조각을 찾아 줄 뿐이다. 답변은 LLM이 만든다. 따라서 검색 결과를 잘 정리해서 프롬프트에 넣어야 한다.
def build_context(results):
lines = []
for i, item in enumerate(results, start=1):
header = '[문서 {} | 제목: {} | 출처: {}]'.format(
i,
item['title'],
item.get('source', 'unknown'),
)
lines.append(header + 'n' + item['text'])
return 'nn'.join(lines)
def build_rag_prompt(question, search_results):
context = build_context(search_results)
return '''아래 근거 문서를 사용해 질문에 답하세요.
근거에 없는 내용은 추측하지 말고, 모르면 모른다고 답하세요.
근거 문서:
{}
질문:
{}
'''.format(context, question)이 프롬프트에는 중요한 안전장치가 들어 있다. "근거에 없는 내용은 추측하지 말라"는 지시다. 물론 이 문장 하나만으로 환각이 완전히 사라지지는 않는다. 하지만 RAG 시스템에서는 검색된 근거를 제공하고, 모델이 그 근거 안에서 답하도록 압력을 주는 것이 기본이다.
다음 장에서 RAG의 전체 구조를 다루겠지만, 지금 단계에서 이미 핵심은 보인다.
문서를 벡터로 저장한다. 질문을 벡터로 바꾼다. 가까운 문서를 찾는다. 찾은 문서를 프롬프트에 붙인다. LLM은 그 문서를 보고 답한다.
이 흐름이 현대 LLM 서비스의 가장 중요한 패턴 중 하나다.
임베딩 모델을 고르는 기준
임베딩 모델은 검색 품질에 직접적인 영향을 준다. LLM 답변이 아무리 좋아도 검색 단계에서 엉뚱한 문서를 가져오면 결과는 나빠진다. 그래서 모델 선택은 가볍게 넘길 문제가 아니다.
OpenAI Embeddings는 API로 쉽게 사용할 수 있고, 서비스에 붙이기 편하다. 자체 인프라 없이도 안정적으로 임베딩을 만들 수 있다는 장점이 있다. Hugging Face 생태계에서는 BAAI의 BGE, FlagEmbedding 계열 모델이 널리 쓰인다. BAAI 문서에 따르면 FlagEmbedding은 텍스트를 dense vector로 매핑해 검색, 분류, 군집화, semantic search, 벡터 데이터베이스 활용에 사용할 수 있다2. Google Cloud 쪽에서는 Vertex AI Embeddings와 Vertex AI Vector Search를 함께 사용해 엔터프라이즈 검색과 RAG 시스템을 구성할 수 있다3.
모델을 고를 때는 다음 기준을 함께 봐야 한다.
언어 지원과 도메인 적합성
검색 성능과 평가 결과
벡터 차원 수와 저장 비용
API 비용과 지연 시간
자체 배포 가능 여부와 라이선스
벡터 DB, 프레임워크와의 연동성
한국어 문서를 검색한다면 한국어 성능이 중요하다. 영어 벤치마크에서 높은 점수를 받은 모델이 한국어 고객지원 문서에서도 항상 좋은 것은 아니다. 법률, 의료, 코드, 금융처럼 전문 용어가 많은 도메인이라면 일반 임베딩 모델만으로 부족할 수도 있다.
차원 수도 비용에 영향을 준다. 100만 개 청크를 저장한다고 생각해 보자. 384차원 벡터와 3072차원 벡터는 저장 공간과 검색 비용이 크게 다르다. 더 큰 벡터가 더 좋은 검색 결과를 줄 수는 있지만, 그 차이가 비용 증가를 정당화하는지는 직접 평가해야 한다.
지연 시간도 중요하다. 사용자가 질문할 때마다 질문 임베딩을 만들어야 한다. 임베딩 API 호출이 느리면 전체 응답이 느려진다. 반면 문서 임베딩은 보통 미리 만들어 저장해 둘 수 있으므로, 실시간 요청 경로에서 제외하는 것이 좋다.
벡터 저장소가 필요한 순간
앞의 NumPy 예제는 모든 문서 벡터를 메모리에 올리고 전체를 순회했다. 문서가 적으면 괜찮다. 하지만 문서가 많아지면 검색 전용 저장소가 필요하다.
벡터 저장소는 벡터와 메타데이터를 저장하고, 주어진 쿼리 벡터와 가까운 벡터를 빠르게 찾아 준다. 이때 자주 쓰이는 기술이 ANN, 즉 Approximate Nearest Neighbor다. 한국어로는 근사 최근접 이웃 검색이라고 한다. 정확히 가장 가까운 벡터를 완전 탐색하는 대신, 거의 가까운 후보를 훨씬 빠르게 찾는 방식이다.
대표적인 선택지는 다음과 같다.
FAISS: 로컬 실험과 대규모 벡터 검색에 많이 쓰이는 라이브러리
Chroma: 개발 환경에서 RAG 프로토타입을 빠르게 만들기 좋은 벡터 저장소
OpenSearch: 검색 엔진 기능과 벡터 검색을 함께 활용하기 좋은 선택지
pgvector: PostgreSQL 안에서 벡터 검색을 처리하고 싶을 때 유용한 확장
Pinecone, Weaviate, Qdrant 같은 전용 벡터 데이터베이스
OpenSearch 문서는 LangChain, LlamaIndex, Haystack 같은 LLM 프레임워크가 OpenSearch를 벡터 저장소로 연동할 수 있다고 설명한다5. 이런 연동을 사용하면 벡터 저장, top-k 검색, 메타데이터 필터링, RAG 애플리케이션 연결을 직접 모두 구현하지 않아도 된다.
Google BigQuery도 임베딩 생성, 벡터 인덱스 생성, VECTOR_SEARCH를 통한 유사 텍스트 검색, 검색 결과를 활용한 RAG 흐름을 제공한다6. 이미 데이터가 BigQuery에 있고 분석 파이프라인이 그쪽에 있다면, 별도의 벡터 DB를 새로 도입하지 않고도 벡터 검색을 시작할 수 있다.
선택의 기준은 단순하다. 실험 단계라면 관리가 쉬운 도구를 고른다. 운영 단계라면 데이터 규모, 업데이트 빈도, 권한 관리, 필터링, 백업, 지연 시간, 비용, 팀의 운영 역량까지 함께 봐야 한다.
검색 품질 평가하기
벡터 검색을 붙이면 그럴듯하게 동작하는 데모는 금방 만들 수 있다. 하지만 좋은 서비스가 되려면 "정말 잘 찾는지"를 측정해야 한다. 몇 개 질문을 눈으로 확인하고 "괜찮아 보인다"에서 멈추면 위험하다.
가장 먼저 할 일은 대표 질문 세트를 만드는 것이다. 예를 들어 고객지원 챗봇이라면 실제 사용자가 물을 법한 질문을 모은다. 그리고 각 질문에 대해 정답 근거가 되는 문서 ID를 적어 둔다.
test_cases = [
{
'query': '비밀번호를 잊어버렸을 때 어떻게 해야 하나요?',
'expected_ids': ['doc-1'],
},
{
'query': '요금제 변경은 언제 반영되나요?',
'expected_ids': ['doc-2'],
},
{
'query': '팀원을 초대하려면 어디로 가야 하나요?',
'expected_ids': ['doc-4'],
},
]이제 검색 결과가 기대 문서를 포함하는지 계산할 수 있다. 가장 쉬운 지표는 recall@k다. k개 검색 결과 안에 정답 문서가 들어 있는지를 보는 지표다. 예를 들어 recall@3은 상위 3개 안에 정답 문서가 들어왔는지를 본다.
MRR, 즉 Mean Reciprocal Rank도 자주 쓴다. 정답 문서가 몇 번째에 등장했는지를 반영한다. 정답이 1등이면 1점, 2등이면 1/2점, 3등이면 1/3점이다. 검색 결과에서 정답이 위에 있을수록 점수가 높다.
def evaluate(test_cases, top_k=3):
recall_scores = []
reciprocal_ranks = []
for case in test_cases:
results = search(case['query'], top_k=top_k)
retrieved_ids = [item['id'] for item in results]
expected_ids = set(case['expected_ids'])
hits = expected_ids.intersection(retrieved_ids)
recall = len(hits) / len(expected_ids)
recall_scores.append(recall)
rr = 0.0
for rank, doc_id in enumerate(retrieved_ids, start=1):
if doc_id in expected_ids:
rr = 1.0 / rank
break
reciprocal_ranks.append(rr)
return {
'recall_at_k': sum(recall_scores) / len(recall_scores),
'mrr': sum(reciprocal_ranks) / len(reciprocal_ranks),
}
print(evaluate(test_cases, top_k=3))검색 품질을 평가할 때는 숫자만 보지 말고 실제 근거도 함께 봐야 한다. top-k 안에 정답 문서가 있더라도, LLM 프롬프트에 들어간 청크가 너무 길거나 노이즈가 많으면 답변 품질이 떨어질 수 있다. 반대로 검색 결과 1위는 아니지만 3위에 충분히 좋은 근거가 있고, 프롬프트가 잘 구성되어 있다면 답변은 괜찮을 수도 있다.
따라서 RAG 품질은 두 단계로 나눠 봐야 한다. 첫째, 검색이 올바른 근거를 찾았는가. 둘째, LLM이 그 근거를 제대로 사용해 답했는가.
자주 생기는 실수들
임베딩과 벡터 검색은 간단해 보이지만, 실제로는 작은 실수 하나가 품질을 크게 떨어뜨린다.
첫 번째 실수는 문서와 질문에 서로 다른 임베딩 모델을 쓰는 것이다. 문서 인덱스를 만들 때 사용한 모델과 검색할 때 질문을 임베딩하는 모델은 같아야 한다. 모델을 바꾸고 싶다면 기존 문서 벡터도 다시 만들어야 한다.
두 번째 실수는 청크를 너무 크게 만들거나 너무 작게 만드는 것이다. 너무 크면 검색 결과가 뭉뚱그려지고, 너무 작으면 답변에 필요한 문맥이 잘린다. 문서 종류에 따라 적절한 크기가 다르다. 제품 도움말은 섹션 단위가 좋을 수 있고, 법률 문서는 조항 단위가 좋을 수 있으며, 코드는 함수나 클래스 단위가 좋을 수 있다.
세 번째 실수는 메타데이터를 소홀히 하는 것이다. 검색 결과에 문서 제목, URL, 작성일, 권한, 버전이 없으면 답변 출처를 표시하기 어렵고, 잘못된 문서를 걸러내기도 어렵다. 특히 기업 내부 문서 검색에서는 권한 필터링이 필수다. 사용자가 볼 수 없는 문서가 검색되어 프롬프트에 들어가면 보안 사고가 된다.
네 번째 실수는 유사도 점수 하나로 모든 것을 결정하는 것이다. 점수 분포는 모델과 데이터마다 다르다. threshold를 정할 때는 대표 질문 세트로 실험해야 한다. 또한 검색 결과가 애매할 때는 "관련 문서를 찾지 못했다"고 답하는 정책도 필요하다.
다섯 번째 실수는 검색 평가 없이 LLM 프롬프트만 계속 고치는 것이다. RAG가 틀린 답을 할 때 원인은 LLM이 아니라 검색일 때가 많다. 엉뚱한 문서를 넣어 놓고 모델에게 정확한 답을 기대할 수는 없다. 답변이 이상하다면 먼저 "검색된 청크가 맞는가?"를 확인해야 한다.
다음 장으로 가는 다리
이번 장에서 우리는 텍스트를 벡터로 바꾸고, 벡터끼리의 유사도를 계산하고, 간단한 의미 검색을 구현했다. 이제 RAG의 핵심 재료가 준비되었다.
임베딩은 텍스트를 의미 공간의 좌표로 바꾼다. 벡터 검색은 질문과 가까운 문서 조각을 찾아준다. LLM은 그 조각을 읽고 자연어 답변을 만든다.
중요한 것은 이 순서다. LLM이 먼저 기억을 뒤지는 것이 아니다. 애플리케이션이 먼저 필요한 근거를 검색하고, 그 근거를 LLM에게 건넨다. 그래서 RAG 시스템을 잘 만든다는 것은 LLM을 잘 부르는 일만이 아니다. 좋은 문서 파이프라인, 좋은 청킹, 좋은 임베딩 모델, 좋은 벡터 검색, 좋은 평가 세트를 함께 설계하는 일이다.
다음 장에서는 이 재료들을 하나로 묶어 RAG의 전체 구조를 완성한다. 사용자의 질문이 들어와서, 관련 문서를 찾고, 근거를 프롬프트에 붙이고, 최종 답변과 출처를 반환하는 흐름을 하나의 서비스 구조로 바라볼 것이다.
참고
1Vector embeddings | OpenAI API
2BAAI/llm-embedder · Hugging Face
3Vertex AI Embeddings for Text: Grounding LLMs made easy
4Develop a RAG Solution - Generate Embeddings Phase - Azure Architecture Center
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.