문서
외부 문서·지식을 모델의 컨텍스트에 넣는 방법은 여러 가지입니다. 문서를 그대로 프롬프트에 직접 첨부하거나, 지침·자료를 묶은 **스킬(Agent Skills)**을 필요할 때 불러올 수도 있습니다. 이 장에서 다루는 RAG는 문서가 많고 길어 직접 넣기 어려울 때, 질의와 관련된 부분만 검색해 주입하는 방법입니다.
검색 보강 생성 (Retrieval-Augmented Generation), 또는 RAG는 외부 문서를 검색하여 그 결과를 바탕으로 답변을 생성하는 방식입니다. 기존의 생성형 언어 모델은 학습된 지식만을 기반으로 답변을 생성하지만, RAG는 질문과 관련된 정보를 문서에서 찾아 활용함으로써 더 정확하고 신뢰도 높은 응답을 생성할 수 있습니다.
RAG는 크게 두 가지 구성 요소로 이루어져 있습니다:
검색기(retriever)는 사용자의 질문과 관련된 문서를 외부에서 검색하는 역할을 합니다.
생성기(generator)는 검색된 문서를 바탕으로 자연어 형태의 답변을 생성합니다.
이 구조는 정보 검색과 텍스트 생성을 결합한 형태로, 단순한 질의응답을 넘어 다양한 분야에서 응용될 수 있습니다. 예를 들어, 도메인 특화 질문응답 시스템, 문서 요약, 실시간 지식 기반 챗봇 등에 활용할 수 있습니다.
RAG는 Facebook AI에서 제안한 방식이며, Hugging Face Transformers 라이브러리를 통해 쉽게 구현할 수 있습니다.
한국어 환경에서는 KoSBERT, KoT5, KoGPT 등의 사전학습 언어 모델과 함께 사용할 수 있습니다.
문서 검색기(retriever)는 사용자의 질문에 대해 관련 문서를 외부 데이터에서 찾아주는 구성 요소입니다. RAG에서의 첫 단계이며, 문서 기반 질문 응답 시스템의 핵심입니다.
retriever는 주로 다음 단계를 포함합니다:
질문을 임베딩 벡터로 변환
미리 임베딩된 문서 벡터들과 비교
가장 유사한 문서 n개 반환
먼저 임베딩과 벡터 검색에 필요한 라이브러리를 설치합니다.
sentence-transformers는 문장을 의미 벡터로 바꾸는 임베딩 모델을, faiss는 그 벡터들 사이의 유사도 검색을 빠르게 처리하는 기능을 제공합니다.
pip install sentence-transformers faiss-cpu검색 대상이 될 **문서 집합(corpus)**이 필요합니다. 여기서는 한국어–영어 번역 쌍 데이터셋을 불러와, 그중 한국어 문장을 검색할 문서로 사용합니다.
from datasets import load_dataset
dataset_id = 'codebasic/aihub-koen-translation-integrated-base-1m'
한영쌍 = load_dataset(dataset_id)이 데이터셋은 여러 분야의 번역 쌍으로 구성되어 있습니다.
그중 특허 분야 항목(source가 563)만 추려 한 도메인의 문서 집합으로 만듭니다.
영어(en)와 출처(source) 열은 버리고, 한국어(ko) 열은 '검색 대상 본문’이라는 뜻으로 text로 이름을 바꿉니다.
마지막 줄은 무작위 5건을 표로 미리 확인하는 코드입니다.
import pandas as pd
특허데이터 = 한영쌍.filter(lambda x: x['source'] in [563]).remove_columns(['en', 'source'])
특허데이터 = 특허데이터.rename_column('ko', 'text')
pd.DataFrame(특허데이터['train'].shuffle().take(5))1문서 임베딩¶
검색을 하려면 먼저 문서를 의미를 담은 벡터로 바꿔야 합니다.
한국어 문장 임베딩에 특화된 SBERT 모델(KR-SBERT)을 불러옵니다.
from sentence_transformers import SentenceTransformer
# 한국어 SBERT 모델
문서인코더 = SentenceTransformer("snunlp/KR-SBERT-V40K-klueNLI-augSTS")불러온 인코더로 특허 문서 전체를 한 번에 인코딩하여 (문서 수 × 벡터 차원) 형태의 임베딩 행렬을 얻습니다.
문서임베딩 = 문서인코더.encode(특허데이터['train']["text"], convert_to_numpy=True)2검색용 색인 생성¶
문서가 많아지면 질문 벡터를 모든 문서 벡터와 매번 직접 비교하는 것은 느립니다.
faiss로 벡터 색인을 만들어 유사도 검색을 빠르게 합니다.
IndexFlatL2는 L2(유클리드) 거리로 전수 비교하는 가장 단순하고 정확한 색인입니다.
만든 색인은 파일로 저장해 두면 매번 다시 임베딩하지 않고 불러와 재사용할 수 있습니다.
import faiss
dimension = 문서임베딩.shape[1]
index = faiss.IndexFlatL2(dimension)
index.add(문서임베딩)
faiss.write_index(index, "patent_index.faiss")3검색 수행¶
검색은 ① 질문을 문서와 같은 모델로 임베딩하고, ② 색인에서 질문 벡터와 가장 가까운 벡터 k개를 찾는 과정입니다.
search는 거리(D)와 문서 인덱스(I)를 돌려주며, 그 인덱스로 원문을 되찾아 출력합니다.
# 사용자 질문
query = "컴퓨터 관련 특허"
query_embedding = 문서인코더.encode([query], convert_to_numpy=True)
# 유사 문서 검색 (상위 5개)
index = faiss.read_index("patent_index.faiss")
D, I = index.search(query_embedding, k=5)
# 검색된 문서 출력
for i in I[0]:
print(특허데이터['train']["text"][i])4응용¶
검색으로 끝내지 않고, 찾은 문서들을 답변 생성을 위한 **맥락(context)**으로 묶습니다. 이 맥락을 질문과 함께 생성 모델(LLM)에 전달하면 외부 지식에 근거한 답을 만들 수 있습니다 — 이것이 RAG의 ‘생성(Generation)’ 단계입니다. 아래 코드는 그 입력이 될 맥락을 구성하는 부분으로, 검색된 문서들을 줄바꿈으로 이어 붙입니다.
from sentence_transformers import SentenceTransformer
# 한국어 SBERT 모델
문서인코더 = SentenceTransformer("snunlp/KR-SBERT-V40K-klueNLI-augSTS")
# 예시 질문
question = "컴퓨터 관련 특허를 획득하기 위해서는 어떤 절차가 필요한가요?"
# 질문 임베딩 및 검색
question_embedding = 문서인코더.encode([question], convert_to_numpy=True)
D, I = index.search(question_embedding, k=2)
# 검색된 문서 추출
retrieved_docs = [특허데이터['train']['text'][i] for i in I[0]]
context = "\n".join(retrieved_docs)
print(f'검색된 문서:\n{context}')5임베딩 모델 비교¶
검색 품질은 임베딩 모델 선택에 크게 좌우됩니다. 같은 질의·문서라도 모델에 따라 어떤 문서를 얼마나 가깝게 보는지가 달라지므로, 후보 모델을 같은 조건에서 비교해 보는 것이 좋습니다. 여기서는 지금까지 쓴 한국어 특화 모델 KR-SBERT와, 다국어 범용 모델 Qwen3-Embedding-0.6B를 같은 질의로 비교합니다.
Qwen3-Embedding처럼 instruction 기반 모델이 질의에 작업 지시를 붙이는 데는 분명한 목적이 있습니다. 하나의 임베딩 모델은 여러 종류의 검색에 두루 쓰이는데, 같은 문장이라도 '비슷한 질문 찾기’와 '답이 될 문서 찾기’는 원하는 결과가 다릅니다. 그래서 질의 앞에 "관련 문서를 찾기 위한 검색 질의"라는 지시를 덧붙이면, 모델은 질의를 글자가 비슷한 문장이 아니라 답이 될 만한 문서 쪽에 가깝게 임베딩합니다. 짧은 질문과 긴 문서 사이의 표현 간극을 메우는 장치이며, 덕분에 작업마다 모델을 새로 학습하지 않고 지시만 바꿔 같은 모델을 여러 작업에 적응시킬 수 있습니다.
import numpy as np
from sentence_transformers import SentenceTransformer
# (표시 이름, 모델 ID, 질의에 instruction을 붙일지)
모델목록 = [
("KR-SBERT", "snunlp/KR-SBERT-V40K-klueNLI-augSTS", False),
("Qwen3-Embedding-0.6B", "Qwen/Qwen3-Embedding-0.6B", True),
]
문서표본 = 특허데이터['train']['text'][:200] # 비교용 표본
질문 = "컴퓨터 관련 특허"
def 상위문서(인코더, use_query_prompt, k=3):
문서벡터 = 인코더.encode(문서표본, convert_to_numpy=True, normalize_embeddings=True)
옵션 = {"prompt_name": "query"} if use_query_prompt else {}
질문벡터 = 인코더.encode([질문], convert_to_numpy=True, normalize_embeddings=True, **옵션)
점수 = (질문벡터 @ 문서벡터.T)[0] # 정규화 벡터의 내적 = 코사인 유사도
return [(문서표본[i], float(점수[i])) for i in np.argsort(-점수)[:k]]
for 이름, 모델id, use_q in 모델목록:
인코더 = SentenceTransformer(모델id)
print(f"\n[{이름}]")
for 순위, (문서, 점수) in enumerate(상위문서(인코더, use_q), 1):
print(f" {순위}. ({점수:.3f}) {문서}")두 모델 모두 질의와 의미가 가까운 문서를 상위로 끌어올리지만, 점수 분포나 순위는 모델마다 다를 수 있습니다.
KR-SBERT: 한국어에 특화되어 가볍고 빠릅니다. 한국어 단일 언어 작업에는 충분합니다.
Qwen3-Embedding-0.6B: 다국어·범용으로 일반 성능이 높지만 모델이 크고(약 1.2GB), 질의 instruction 형식을 지켜야 제 성능이 납니다.
언어(단일/다국어)·도메인·자원(메모리·속도)에 맞춰 모델을 고르되, 가능하면 위처럼 실제 질의로 비교해 결정하는 것이 좋습니다.
6고급 컨텍스트 최적화 (Advanced Context Optimization)¶
기본 흐름을 넘어, 실무 RAG에서 검색·생성 품질을 끌어올리는 기법들을 살펴봅니다. 임베딩 전에 문서를 어떻게 나눌지(분할)부터, 검색된 문서를 어떻게 배치·압축해 프롬프트에 넣을지까지 다룹니다.
6.1문서 분할 (Chunking)¶
실제 문서(PDF, 매뉴얼, 웹페이지 등)는 임베딩 모델의 입력 한도를 넘기기 쉽고, 한 덩어리가 너무 길면 한 벡터에 여러 주제가 뒤섞여 검색 정밀도가 떨어집니다. 그래서 임베딩 전에 문서를 적당한 크기의 청크(chunk)로 나눕니다. 대표적인 전략은 다음과 같습니다.
고정 길이 + 겹침(overlap): 일정 글자·토큰 수로 자르되, 경계에서 문맥이 끊기지 않도록 앞뒤를 일부 겹쳐 둡니다.
구분자 기준: 문단·문장·제목 등 자연스러운 경계로 분할합니다.
의미 기반: 내용이 바뀌는 지점을 임베딩 유사도로 찾아 분할합니다.
다음은 고정 길이·겹침 방식의 간단한 예입니다.
def split_text(text, chunk_size=300, overlap=50):
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - overlap # 다음 청크를 overlap만큼 겹쳐 시작
return chunks6.2맥락 재배치 (Long-Context Re-ordering)¶
어텐션 연산 특성상 모델은 입력된 컨텍스트의 처음과 끝 부분에 강하게 주의를 기울이고, 중간 영역의 정보는 쉽게 누락시킵니다. 따라서 검색 유사도가 가장 높은 핵심 문서 조각(Chunk)들을 프롬프트의 맨 앞과 맨 뒤에 배치하여 유실을 피하는 것이 정석입니다.
아래는 상위 스코어 문서를 가장자리로 분산 배치해 주는 재배치 함수입니다.
def reorder_documents(docs):
"""
유사도 점수가 높은 핵심 문서를 프롬프트의 양끝(처음과 끝)으로 재정렬하여
Lost in the Middle 현상을 극복하도록 정렬합니다.
"""
sorted_docs = []
# 홀수 인덱스는 왼쪽에 쌓고, 짝수 인덱스는 오른쪽에 쌓는 방식으로 분산 정렬
left = True
for doc in docs:
if left:
sorted_docs.insert(0, doc)
else:
sorted_docs.append(doc)
left = not left
return sorted_docs
# 재배치 적용
reordered_docs = reorder_documents(retrieved_docs)
print("[원본 검색 순서 (상위 -> 하위)]")
for idx, doc in enumerate(retrieved_docs):
print(f"{idx+1}: {doc[:60]}...")
print("\n[재배치 정렬 순서 (상위 문서를 처음과 끝으로 배치)]")
for idx, doc in enumerate(reordered_docs):
print(f"{idx+1}: {doc[:60]}...")6.3컨텍스트 압축 (Context Compression)¶
필수적이지 않은 정보(조사, 중복 어휘 등)가 너무 많으면 프롬프트 공간을 낭비하게 됩니다. 소형 언어 모델이나 정보량 엔트로피 기반의 압축 기술(예: LLMLingua)을 사용하면 프롬프트의 핵심 의미를 보존하면서도 불필요한 단어를 지워낼 수 있습니다.
아래는 텍스트 내에서 문법적으로 불필요한 중복 조사를 필터링하여 프롬프트 토큰을 절감하는 예시형 압축 헬퍼 함수입니다.
import re
def simple_compress_context(text):
"""
정규식을 이용해 중복 단어와 조사를 제거하는 가벼운 휴리스틱 컨텍스트 압축 헬퍼 함수입니다.
실제 배포 환경에서는 LLMLingua 등 정보 엔트로피 기반의 소형 모델을 사용해 5~10배 수준까지 압축합니다.
"""
# 중복 공백 제거
compressed = re.sub(r'\s+', ' ', text)
# 한글 조사 및 중복된 문맥적 특이점 정제
compressed = compressed.replace("본 발명은", "[발명]")
compressed = compressed.replace("에 관한 것이다.", "")
return compressed.strip()
raw_context = "\n".join(reordered_docs)
compressed_context = simple_compress_context(raw_context)
print(f"원본 컨텍스트 크기: {len(raw_context)} 자")
print(f"압축된 컨텍스트 크기: {len(compressed_context)} 자 (절감률: {(1 - len(compressed_context)/len(raw_context))*100:.1f}%)")
# 다음 생성 셀에서 압축 및 재배치된 컨텍스트를 사용하도록 설정
context = compressed_contextfrom transformers import PreTrainedTokenizerFast, GPT2LMHeadModel
tokenizer = PreTrainedTokenizerFast.from_pretrained("skt/kogpt2-base-v2")
model = GPT2LMHeadModel.from_pretrained("skt/kogpt2-base-v2")
# 질문 + 검색 문서 기반 생성 입력
prompt = f"{context}\n\n질문: {question}\n답변:"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
output = model.generate(input_ids, max_new_tokens=256, do_sample=True, top_p=0.95, top_k=50)
answer = tokenizer.decode(output[0], skip_special_tokens=True)
print("생성된 답변:\n", answer)